Compare commits
41 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6ad2f6b6af | |||
| b49f0e9064 | |||
| 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 | |||
| ac3727f22f | |||
| 43a4e552ce | |||
| a532e639ea | |||
| f099ad8fe9 | |||
| dbed0d0da7 | |||
| 617c103055 | |||
| edb202071c | |||
| c5fa755006 | |||
| 92e94ddc17 | |||
| 0f88aa0055 |
@@ -4,8 +4,8 @@
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: moko-platform.Automation
|
||||
# VERSION: 01.25.02
|
||||
# INGROUP: mokocli.Automation
|
||||
# VERSION: 01.29.00
|
||||
# BRIEF: Auto-create feature branch when an issue is opened
|
||||
|
||||
name: "Universal: Issue Branch"
|
||||
|
||||
+10
-12
@@ -1,21 +1,19 @@
|
||||
# Changelog
|
||||
## [Unreleased]
|
||||
|
||||
## [01.25.00] --- 2026-06-20
|
||||
## [01.28.00] --- 2026-06-22
|
||||
|
||||
## [01.25.00] --- 2026-06-20
|
||||
### Changed
|
||||
- Remote upload failure no longer marks the entire backup as failed — local archive is preserved with status 'complete' (#66)
|
||||
|
||||
## [01.24.00] --- 2026-06-20
|
||||
### Added
|
||||
- Snapshots now capture tags, custom fields, field values, and field-category mappings when articles are included (#57)
|
||||
- Snapshot retention settings: max count and max age with automatic cleanup (#63)
|
||||
|
||||
## [01.24.00] --- 2026-06-19
|
||||
## [01.27.03] --- 2026-06-21
|
||||
|
||||
## [01.23.00] --- 2026-06-18
|
||||
## [01.27.03] --- 2026-06-21
|
||||
|
||||
## [01.21.00] --- 2026-06-16
|
||||
## [01.27.00] --- 2026-06-21
|
||||
|
||||
### Fixed
|
||||
- Admin submenu items (Dashboard, Backups, Profiles) not created on install — `<submenu>` block in manifest was empty
|
||||
- Submenu items not created on update — added `ensureSubmenuItems()` using Joomla's `MenuTable` API with proper nested set positioning
|
||||
- Submenu icons not rendering in Joomla 6 — set `menu_icon` param for level 2+ items (Atum only renders `img` column icons for level 1)
|
||||
- CSS selector `#menu` → `.main-nav` for icon injection (Joomla 6 uses dynamic `id="menu{moduleId}"`)
|
||||
- Use `margin-inline-end` instead of `margin-right` for RTL layout support
|
||||
## [01.27.00] --- 2026-06-21
|
||||
|
||||
@@ -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.25.02 -->
|
||||
<!-- VERSION: 01.29.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.25.02</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,27 @@ class BackupsController extends ApiController
|
||||
|
||||
$data = [];
|
||||
|
||||
// Strip sensitive credentials before serialization
|
||||
$sensitiveFields = [
|
||||
'ftp_password', 'ftp_username',
|
||||
's3_access_key', 's3_secret_key',
|
||||
'gdrive_client_secret', 'gdrive_refresh_token',
|
||||
'encryption_password', 'ntfy_token',
|
||||
];
|
||||
|
||||
foreach ($items as $item) {
|
||||
$safe = clone $item;
|
||||
|
||||
foreach ($sensitiveFields as $field) {
|
||||
if (isset($safe->$field) && $safe->$field !== '') {
|
||||
$safe->$field = '***';
|
||||
}
|
||||
}
|
||||
|
||||
$data[] = [
|
||||
'type' => 'profiles',
|
||||
'id' => $item->id,
|
||||
'attributes' => $item,
|
||||
'id' => $safe->id,
|
||||
'attributes' => $safe,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -167,6 +167,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"
|
||||
@@ -268,6 +269,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"
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
-->
|
||||
<extension type="component" method="upgrade">
|
||||
<name>MokoSuiteBackup</name>
|
||||
<version>01.25.02</version>
|
||||
<version>01.29.00</version>
|
||||
<creationDate>2026-06-02</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -49,6 +49,13 @@ class BackupsController extends AdminController
|
||||
$engine = new BackupEngine();
|
||||
$result = $engine->run($profileId, $description, 'backend');
|
||||
|
||||
// Surface preflight warnings as Joomla messages
|
||||
if (!empty($result['warnings'])) {
|
||||
foreach ($result['warnings'] as $warning) {
|
||||
$this->app->enqueueMessage($warning, 'warning');
|
||||
}
|
||||
}
|
||||
|
||||
if ($result['success']) {
|
||||
$this->setMessage($result['message']);
|
||||
} else {
|
||||
|
||||
@@ -360,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
|
||||
|
||||
@@ -32,16 +32,21 @@ class BackupEngine
|
||||
*/
|
||||
public function run(int $profileId, string $description, string $origin = 'backend'): array
|
||||
{
|
||||
// Run pre-flight checks before creating any backup record
|
||||
$preflight = new PreflightCheck();
|
||||
$preflightResult = $preflight->run($profileId);
|
||||
|
||||
if (!$preflightResult['pass']) {
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => 'Pre-flight failed: ' . implode('; ', $preflightResult['errors']),
|
||||
'warnings' => $preflightResult['warnings'],
|
||||
];
|
||||
}
|
||||
|
||||
// Override PHP limits for long-running backup operations
|
||||
$this->overridePhpLimits();
|
||||
|
||||
// Verify required extensions
|
||||
$extCheck = $this->checkRequiredExtensions();
|
||||
|
||||
if ($extCheck !== true) {
|
||||
return ['success' => false, 'message' => $extCheck];
|
||||
}
|
||||
|
||||
$db = Factory::getDbo();
|
||||
|
||||
// Load profile
|
||||
@@ -53,7 +58,12 @@ class BackupEngine
|
||||
$profile = $db->loadObject();
|
||||
|
||||
if (!$profile) {
|
||||
return ['success' => false, 'message' => 'Profile not found: ' . $profileId];
|
||||
return ['success' => false, 'message' => 'Profile not found: ' . $profileId, 'warnings' => []];
|
||||
}
|
||||
|
||||
// Log any preflight warnings
|
||||
foreach ($preflightResult['warnings'] as $warning) {
|
||||
$this->log('PREFLIGHT WARNING: ' . $warning);
|
||||
}
|
||||
|
||||
// Read settings directly from profile columns
|
||||
@@ -68,13 +78,14 @@ class BackupEngine
|
||||
$this->backupDir = BackupDirectory::resolve($resolver->resolve($configuredDir));
|
||||
|
||||
if (!BackupDirectory::ensureReady($this->backupDir)) {
|
||||
return ['success' => false, 'message' => 'Cannot create backup directory: ' . $this->backupDir, 'record_id' => 0];
|
||||
return ['success' => false, 'message' => 'Cannot create backup directory: ' . $this->backupDir, 'record_id' => 0, 'warnings' => $preflightResult['warnings']];
|
||||
}
|
||||
|
||||
// Create backup record
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$tag = $resolver->getTag();
|
||||
$archiveFormat = $profile->archive_format ?? 'zip';
|
||||
$archiveName = '';
|
||||
$archiver = $this->createArchiver($archiveFormat);
|
||||
$archiveExt = $archiver->getExtension();
|
||||
$nameFormat = $profile->archive_name_format ?? '[host]_[datetime]_profile[profile_id]';
|
||||
@@ -120,12 +131,15 @@ 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';
|
||||
$dumper = new DatabaseDumper($excludeTables);
|
||||
$dbSize = $dumper->dumpToFile($sqlTempFile);
|
||||
$archiver->addFile($sqlTempFile, 'database.sql');
|
||||
$tablesCount = $dumper->getTablesCount();
|
||||
$this->log('Database dump complete: ' . $tablesCount . ' tables, ' . number_format($dbSize) . ' bytes');
|
||||
}
|
||||
@@ -193,6 +207,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 ?? '';
|
||||
|
||||
@@ -236,26 +255,36 @@ class BackupEngine
|
||||
}
|
||||
|
||||
$remoteFilename = '';
|
||||
$uploadFailed = false;
|
||||
|
||||
// Step 3: Remote upload (if configured)
|
||||
// Wrapped in its own try-catch so a remote failure does not mark
|
||||
// the entire backup as failed — the local archive is preserved.
|
||||
$remoteStorage = $profile->remote_storage ?? 'none';
|
||||
|
||||
if ($remoteStorage !== 'none') {
|
||||
$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)');
|
||||
// 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.');
|
||||
}
|
||||
}
|
||||
@@ -290,9 +319,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);
|
||||
|
||||
@@ -300,10 +334,22 @@ class BackupEngine
|
||||
'success' => true,
|
||||
'message' => 'Backup complete: ' . $archiveName . ' (' . $sizeHuman . ')',
|
||||
'record_id' => $recordId,
|
||||
'warnings' => $preflightResult['warnings'],
|
||||
];
|
||||
} catch (\Throwable $e) {
|
||||
$this->log('FATAL: ' . $e->getMessage());
|
||||
|
||||
// Clean up temp SQL file on failure
|
||||
if (!empty($sqlTempFile) && is_file($sqlTempFile)) {
|
||||
@unlink($sqlTempFile);
|
||||
}
|
||||
|
||||
// If encryption was intended and failed, remove the plaintext archive
|
||||
if (!empty($encryptionPassword) && !empty($archivePath) && is_file($archivePath)) {
|
||||
@unlink($archivePath);
|
||||
$this->log('Plaintext archive removed after encryption failure');
|
||||
}
|
||||
|
||||
$update = (object) [
|
||||
'id' => $recordId,
|
||||
'status' => 'fail',
|
||||
@@ -328,7 +374,7 @@ class BackupEngine
|
||||
// Dispatch event for actionlog and other listeners
|
||||
$this->dispatchAfterRun(false, $recordId, $description, $profileId, $origin);
|
||||
|
||||
return ['success' => false, 'message' => 'Backup failed: ' . $e->getMessage(), 'record_id' => $recordId];
|
||||
return ['success' => false, 'message' => 'Backup failed: ' . $e->getMessage(), 'record_id' => $recordId, 'warnings' => $preflightResult['warnings'] ?? []];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -383,35 +429,6 @@ class BackupEngine
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify required PHP extensions are loaded.
|
||||
*
|
||||
* @return true|string True if all ok, or error message string
|
||||
*/
|
||||
private function checkRequiredExtensions(): true|string
|
||||
{
|
||||
$required = [
|
||||
'zip' => 'ext-zip (required for archive creation)',
|
||||
'pdo' => 'ext-pdo (required for database operations)',
|
||||
'pdo_mysql' => 'ext-pdo_mysql (required for MySQL database dumps)',
|
||||
'mbstring' => 'ext-mbstring (required for binary-safe operations)',
|
||||
];
|
||||
|
||||
$missing = [];
|
||||
|
||||
foreach ($required as $ext => $label) {
|
||||
if (!extension_loaded($ext)) {
|
||||
$missing[] = $label;
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($missing)) {
|
||||
return 'Missing PHP extensions: ' . implode(', ', $missing);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the appropriate archiver based on the archive format.
|
||||
*/
|
||||
@@ -420,7 +437,7 @@ class BackupEngine
|
||||
return match ($format) {
|
||||
'zip' => new ZipArchiver(),
|
||||
'tar.gz' => new TarGzArchiver(),
|
||||
default => new ZipArchiver(),
|
||||
default => throw new \InvalidArgumentException('Unknown archive format: ' . $format),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -219,6 +219,138 @@ class DatabaseDumper
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dump all database tables directly to a file, streaming row by row.
|
||||
* Avoids loading the entire dump into RAM.
|
||||
*
|
||||
* @param string $filePath Absolute path to write the SQL file
|
||||
*
|
||||
* @return int Size of the dump file in bytes
|
||||
*/
|
||||
public function dumpToFile(string $filePath): int
|
||||
{
|
||||
$db = Factory::getDbo();
|
||||
$prefix = $db->getPrefix();
|
||||
|
||||
$fp = fopen($filePath, 'w');
|
||||
|
||||
if ($fp === false) {
|
||||
throw new \RuntimeException('Cannot open dump file for writing: ' . $filePath);
|
||||
}
|
||||
|
||||
fwrite($fp, "-- MokoSuiteBackup Database Dump\n");
|
||||
fwrite($fp, "-- Generated: " . date('Y-m-d H:i:s') . "\n");
|
||||
fwrite($fp, "-- Server: " . $db->getServerType() . "\n");
|
||||
fwrite($fp, "-- Database: " . $db->getName() . "\n");
|
||||
fwrite($fp, "-- Original Prefix: " . $prefix . "\n");
|
||||
fwrite($fp, "-- Abstract Prefix: #__\n");
|
||||
fwrite($fp, "-- Note: Table names use #__ placeholder. Replace with your prefix on restore.\n\n");
|
||||
fwrite($fp, "SET SQL_MODE = \"NO_AUTO_VALUE_ON_ZERO\";\n");
|
||||
fwrite($fp, "SET time_zone = \"+00:00\";\n\n");
|
||||
|
||||
// Get all tables with the site prefix
|
||||
$tables = $db->getTableList();
|
||||
$siteTables = [];
|
||||
|
||||
foreach ($tables as $table) {
|
||||
if (str_starts_with($table, $prefix)) {
|
||||
$siteTables[] = $table;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($siteTables as $table) {
|
||||
$abstractName = '#__' . substr($table, strlen($prefix));
|
||||
|
||||
if ($this->isExcludedBoth($abstractName, $table)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$skipData = $this->isExcludedDataOnly($abstractName, $table);
|
||||
$skipStructure = $this->isExcludedStructureOnly($abstractName, $table);
|
||||
|
||||
$this->tablesCount++;
|
||||
|
||||
fwrite($fp, "-- --------------------------------------------------------\n");
|
||||
fwrite($fp, "-- Table: " . $abstractName . "\n");
|
||||
|
||||
if ($skipData) {
|
||||
fwrite($fp, "-- (data excluded)\n");
|
||||
}
|
||||
|
||||
if ($skipStructure) {
|
||||
fwrite($fp, "-- (structure excluded)\n");
|
||||
}
|
||||
|
||||
fwrite($fp, "-- --------------------------------------------------------\n\n");
|
||||
|
||||
if (!$skipStructure) {
|
||||
$db->setQuery('SHOW CREATE TABLE ' . $db->quoteName($table));
|
||||
$createRow = $db->loadRow();
|
||||
|
||||
if (!$createRow || empty($createRow[1])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$createSql = str_replace('`' . $prefix, '`#__', $createRow[1]);
|
||||
fwrite($fp, 'DROP TABLE IF EXISTS `' . $abstractName . "`;\\n");
|
||||
fwrite($fp, $createSql . ";\n\n");
|
||||
}
|
||||
|
||||
if ($skipData) {
|
||||
fwrite($fp, "\n");
|
||||
continue;
|
||||
}
|
||||
|
||||
$db->setQuery('SELECT COUNT(*) FROM ' . $db->quoteName($table));
|
||||
$rowCount = (int) $db->loadResult();
|
||||
|
||||
if ($rowCount === 0) {
|
||||
fwrite($fp, "-- (empty table)\n\n");
|
||||
continue;
|
||||
}
|
||||
|
||||
$chunkSize = 500;
|
||||
|
||||
for ($offset = 0; $offset < $rowCount; $offset += $chunkSize) {
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->select('*')
|
||||
->from($db->quoteName($table)),
|
||||
$offset,
|
||||
$chunkSize
|
||||
);
|
||||
$rows = $db->loadAssocList();
|
||||
|
||||
if (empty($rows)) {
|
||||
break;
|
||||
}
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$values = [];
|
||||
|
||||
foreach ($row as $value) {
|
||||
if ($value === null) {
|
||||
$values[] = 'NULL';
|
||||
} else {
|
||||
$values[] = $db->quote($value);
|
||||
}
|
||||
}
|
||||
|
||||
$columns = array_map([$db, 'quoteName'], array_keys($row));
|
||||
fwrite($fp, 'INSERT INTO `' . $abstractName . '`'
|
||||
. ' (' . implode(', ', $columns) . ')'
|
||||
. ' VALUES (' . implode(', ', $values) . ");\n");
|
||||
}
|
||||
}
|
||||
|
||||
fwrite($fp, "\n");
|
||||
}
|
||||
|
||||
fclose($fp);
|
||||
|
||||
return filesize($filePath) ?: 0;
|
||||
}
|
||||
|
||||
public function getTablesCount(): int
|
||||
{
|
||||
return $this->tablesCount;
|
||||
|
||||
@@ -206,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)) {
|
||||
|
||||
@@ -303,6 +303,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(
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package MokoSuiteBackup
|
||||
* @subpackage com_mokosuitebackup
|
||||
* @author Moko Consulting <hello@mokoconsulting.tech>
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*
|
||||
* Pre-flight validation for backup operations.
|
||||
*
|
||||
* Runs before any backup record is created, catching problems early
|
||||
* with clear messages instead of failing mid-backup. Returns a result
|
||||
* with errors (blockers) and warnings (informational).
|
||||
*/
|
||||
|
||||
namespace Joomla\Component\MokoSuiteBackup\Administrator\Engine;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\Component\MokoSuiteBackup\Administrator\Utility\BackupDirectory;
|
||||
|
||||
class PreflightCheck
|
||||
{
|
||||
/** @var string[] Fatal issues that prevent backup from starting */
|
||||
private array $errors = [];
|
||||
|
||||
/** @var string[] Non-fatal issues the user should know about */
|
||||
private array $warnings = [];
|
||||
|
||||
/**
|
||||
* Run all pre-flight checks for a backup profile.
|
||||
*
|
||||
* @param int $profileId Profile to validate
|
||||
*
|
||||
* @return array{pass: bool, errors: string[], warnings: string[]}
|
||||
*/
|
||||
public function run(int $profileId): array
|
||||
{
|
||||
try {
|
||||
$db = Factory::getDbo();
|
||||
} catch (\Exception $e) {
|
||||
$this->errors[] = 'Cannot connect to database: ' . $e->getMessage();
|
||||
|
||||
return $this->result();
|
||||
}
|
||||
|
||||
// Load profile
|
||||
try {
|
||||
$query = $db->getQuery(true)
|
||||
->select('*')
|
||||
->from($db->quoteName('#__mokosuitebackup_profiles'))
|
||||
->where($db->quoteName('id') . ' = ' . (int) $profileId);
|
||||
$db->setQuery($query);
|
||||
$profile = $db->loadObject();
|
||||
} catch (\Exception $e) {
|
||||
$this->errors[] = 'Cannot load profile: ' . $e->getMessage();
|
||||
|
||||
return $this->result();
|
||||
}
|
||||
|
||||
if (!$profile) {
|
||||
$this->errors[] = 'Profile not found: #' . $profileId;
|
||||
|
||||
return $this->result();
|
||||
}
|
||||
|
||||
if (!$profile->published) {
|
||||
$this->errors[] = 'Profile is unpublished: ' . $profile->title;
|
||||
|
||||
return $this->result();
|
||||
}
|
||||
|
||||
$this->checkPhpExtensions($profile);
|
||||
$this->checkBackupDirectory($profile);
|
||||
$this->checkDiskSpace($profile, $db);
|
||||
$this->checkRunningBackup($profile, $db);
|
||||
$this->checkExcludedTables($profile, $db);
|
||||
$this->checkRemoteCredentials($profile);
|
||||
|
||||
return $this->result();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check that required PHP extensions are loaded.
|
||||
*/
|
||||
private function checkPhpExtensions(object $profile): void
|
||||
{
|
||||
$required = ['pdo', 'pdo_mysql', 'mbstring'];
|
||||
|
||||
// ZIP is required unless using tar.gz
|
||||
$format = $profile->archive_format ?? 'zip';
|
||||
|
||||
if ($format === 'zip') {
|
||||
$required[] = 'zip';
|
||||
}
|
||||
|
||||
foreach ($required as $ext) {
|
||||
if (!extension_loaded($ext)) {
|
||||
$this->errors[] = 'Missing required PHP extension: ext-' . $ext;
|
||||
}
|
||||
}
|
||||
|
||||
// curl is only needed for remote upload and ntfy notifications
|
||||
$needsCurl = ($profile->remote_storage ?? 'none') !== 'none'
|
||||
|| !empty($profile->ntfy_topic);
|
||||
|
||||
if ($needsCurl && !extension_loaded('curl')) {
|
||||
$this->warnings[] = 'ext-curl is not loaded — remote upload and ntfy notifications will not work';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check that the backup directory exists and is writable.
|
||||
*/
|
||||
private function checkBackupDirectory(object $profile): void
|
||||
{
|
||||
$configuredDir = $profile->backup_dir ?: BackupDirectory::PLACEHOLDER;
|
||||
|
||||
// Resolve placeholders using a temporary resolver
|
||||
$resolver = new PlaceholderResolver($profile);
|
||||
$resolvedDir = BackupDirectory::resolve($resolver->resolve($configuredDir));
|
||||
|
||||
if (BackupDirectory::hasPlaceholders($resolvedDir)) {
|
||||
$this->warnings[] = 'Backup directory contains unresolved placeholders: ' . $resolvedDir
|
||||
. ' — directory cannot be validated until backup runs';
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!is_dir($resolvedDir)) {
|
||||
// Try to create it
|
||||
if (!@mkdir($resolvedDir, 0755, true)) {
|
||||
$lastError = error_get_last();
|
||||
$reason = $lastError['message'] ?? 'unknown reason';
|
||||
$this->errors[] = 'Backup directory does not exist and cannot be created: ' . $resolvedDir
|
||||
. ' (' . $reason . ')';
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!is_writable($resolvedDir)) {
|
||||
$this->errors[] = 'Backup directory is not writable: ' . $resolvedDir;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check available disk space against the last backup size + 20% buffer.
|
||||
* Skipped if no previous backup exists for this profile.
|
||||
*/
|
||||
private function checkDiskSpace(object $profile, object $db): void
|
||||
{
|
||||
$configuredDir = $profile->backup_dir ?: BackupDirectory::PLACEHOLDER;
|
||||
$resolver = new PlaceholderResolver($profile);
|
||||
$resolvedDir = BackupDirectory::resolve($resolver->resolve($configuredDir));
|
||||
|
||||
if (BackupDirectory::hasPlaceholders($resolvedDir) || !is_dir($resolvedDir)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Find last successful backup size for this profile
|
||||
$query = $db->getQuery(true)
|
||||
->select($db->quoteName('total_size'))
|
||||
->from($db->quoteName('#__mokosuitebackup_records'))
|
||||
->where($db->quoteName('profile_id') . ' = ' . (int) $profile->id)
|
||||
->where($db->quoteName('status') . ' = ' . $db->quote('complete'))
|
||||
->where($db->quoteName('total_size') . ' > 0')
|
||||
->order($db->quoteName('backupstart') . ' DESC');
|
||||
$db->setQuery($query, 0, 1);
|
||||
$lastSize = (int) $db->loadResult();
|
||||
|
||||
if ($lastSize === 0) {
|
||||
// No previous backup — skip disk space check
|
||||
return;
|
||||
}
|
||||
|
||||
$requiredBytes = (int) ($lastSize * 1.2); // 20% buffer
|
||||
$freeBytes = @disk_free_space($resolvedDir);
|
||||
|
||||
if ($freeBytes === false) {
|
||||
$this->warnings[] = 'Could not determine free disk space for: ' . $resolvedDir;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($freeBytes < $requiredBytes) {
|
||||
$freeMB = number_format($freeBytes / 1048576, 1);
|
||||
$neededMB = number_format($requiredBytes / 1048576, 1);
|
||||
|
||||
$this->warnings[] = 'Low disk space: ' . $freeMB . ' MB free, estimated ' . $neededMB . ' MB needed'
|
||||
. ' (based on last backup + 20% buffer)';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if another backup is already running for this profile.
|
||||
*/
|
||||
private function checkRunningBackup(object $profile, object $db): void
|
||||
{
|
||||
$query = $db->getQuery(true)
|
||||
->select('COUNT(*)')
|
||||
->from($db->quoteName('#__mokosuitebackup_records'))
|
||||
->where($db->quoteName('profile_id') . ' = ' . (int) $profile->id)
|
||||
->where($db->quoteName('status') . ' = ' . $db->quote('running'));
|
||||
$db->setQuery($query);
|
||||
$running = (int) $db->loadResult();
|
||||
|
||||
if ($running > 0) {
|
||||
$this->errors[] = 'Another backup is already running for profile: ' . $profile->title
|
||||
. ' — wait for it to finish or delete the stale record';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check that excluded tables actually exist in the database.
|
||||
* Missing tables are warnings, not errors — the profile may have
|
||||
* been copied from another site or a table may have been removed.
|
||||
*/
|
||||
private function checkExcludedTables(object $profile, object $db): void
|
||||
{
|
||||
$excludeRaw = BackupDirectory::parseNewlineList($profile->exclude_tables ?? '');
|
||||
|
||||
if (empty($excludeRaw)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$prefix = $db->getPrefix();
|
||||
$allTables = array_flip($db->getTableList());
|
||||
|
||||
foreach ($excludeRaw as $entry) {
|
||||
// Strip :data-only / :structure-only suffixes
|
||||
$tableName = preg_replace('/:(?:data-only|structure-only)$/', '', $entry);
|
||||
|
||||
// Resolve #__ prefix to real prefix
|
||||
$realName = str_replace('#__', $prefix, $tableName);
|
||||
|
||||
if (!isset($allTables[$realName])) {
|
||||
$this->warnings[] = 'Excluded table does not exist: ' . $tableName
|
||||
. ' — it will be silently skipped during backup';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check that remote storage credentials are minimally configured.
|
||||
* Does not test the actual connection (too slow for preflight).
|
||||
*/
|
||||
private function checkRemoteCredentials(object $profile): void
|
||||
{
|
||||
$remote = $profile->remote_storage ?? 'none';
|
||||
|
||||
if ($remote === 'none') {
|
||||
return;
|
||||
}
|
||||
|
||||
switch ($remote) {
|
||||
case 'ftp':
|
||||
if (empty($profile->ftp_host)) {
|
||||
$this->warnings[] = 'FTP host is not configured — remote upload will fail';
|
||||
}
|
||||
|
||||
if (empty($profile->ftp_username)) {
|
||||
$this->warnings[] = 'FTP username is not configured — remote upload will fail';
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case 's3':
|
||||
if (empty($profile->s3_bucket)) {
|
||||
$this->warnings[] = 'S3 bucket is not configured — remote upload will fail';
|
||||
}
|
||||
|
||||
if (empty($profile->s3_access_key) || empty($profile->s3_secret_key)) {
|
||||
$this->warnings[] = 'S3 credentials are not configured — remote upload will fail';
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case 'google_drive':
|
||||
if (empty($profile->gdrive_client_id) || empty($profile->gdrive_client_secret)) {
|
||||
$this->warnings[] = 'Google Drive OAuth credentials are not configured — remote upload will fail';
|
||||
}
|
||||
|
||||
if (empty($profile->gdrive_refresh_token)) {
|
||||
$this->warnings[] = 'Google Drive refresh token is missing — remote upload will fail';
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the result array.
|
||||
*/
|
||||
private function result(): array
|
||||
{
|
||||
return [
|
||||
'pass' => empty($this->errors),
|
||||
'errors' => $this->errors,
|
||||
'warnings' => $this->warnings,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -76,8 +76,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);
|
||||
@@ -190,6 +191,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 +224,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');
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -41,6 +41,10 @@ class SnapshotEngine
|
||||
private const ARTICLE_RELATED = [
|
||||
'#__workflow_associations',
|
||||
'#__contentitem_tag_map',
|
||||
'#__tags',
|
||||
'#__fields',
|
||||
'#__fields_values',
|
||||
'#__fields_categories',
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -107,6 +111,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 — dump all (small, article-scoped)
|
||||
$rows = $this->dumpTable($db, str_replace('#__', $prefix, '#__fields_values'), '#__fields_values', 'articles');
|
||||
$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
|
||||
@@ -231,6 +261,30 @@ 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')
|
||||
*/
|
||||
private function dumpFieldCategories(object $db, string $prefix): array
|
||||
{
|
||||
$fcTable = $prefix . 'fields_categories';
|
||||
$fTable = $prefix . 'fields';
|
||||
|
||||
$subQuery = $db->getQuery(true)
|
||||
->select($db->quoteName('id'))
|
||||
->from($db->quoteName($fTable))
|
||||
->where($db->quoteName('context') . ' = ' . $db->quote('com_content.article'));
|
||||
|
||||
$query = $db->getQuery(true)
|
||||
->select('*')
|
||||
->from($db->quoteName($fcTable))
|
||||
->where($db->quoteName('field_id') . ' IN (' . $subQuery . ')');
|
||||
$db->setQuery($query);
|
||||
|
||||
return $db->loadAssocList() ?: [];
|
||||
}
|
||||
|
||||
private function log(string $message): void
|
||||
{
|
||||
$this->log[] = '[' . date('H:i:s') . '] ' . $message;
|
||||
|
||||
@@ -33,6 +33,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
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -282,6 +286,40 @@ 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':
|
||||
// Delete all field values — they are article-scoped
|
||||
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 +341,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)) {
|
||||
|
||||
@@ -32,6 +32,18 @@ class SteppedBackupEngine
|
||||
*/
|
||||
public function init(int $profileId, string $description = '', string $origin = 'backend'): array
|
||||
{
|
||||
// Run pre-flight checks before creating any backup record
|
||||
$preflight = new PreflightCheck();
|
||||
$preflightResult = $preflight->run($profileId);
|
||||
|
||||
if (!$preflightResult['pass']) {
|
||||
return [
|
||||
'error' => true,
|
||||
'message' => 'Pre-flight failed: ' . implode('; ', $preflightResult['errors']),
|
||||
'warnings' => $preflightResult['warnings'],
|
||||
];
|
||||
}
|
||||
|
||||
$db = Factory::getDbo();
|
||||
|
||||
// Load profile
|
||||
@@ -43,7 +55,7 @@ class SteppedBackupEngine
|
||||
$profile = $db->loadObject();
|
||||
|
||||
if (!$profile) {
|
||||
return ['error' => true, 'message' => 'Profile not found: ' . $profileId];
|
||||
return ['error' => true, 'message' => 'Profile not found: ' . $profileId, 'warnings' => []];
|
||||
}
|
||||
|
||||
// Create session
|
||||
@@ -130,6 +142,11 @@ class SteppedBackupEngine
|
||||
$session->phase = ($profile->backup_type !== 'files') ? 'database' : 'files';
|
||||
$session->log('Backup initialized: ' . $session->description);
|
||||
$session->log('Total steps: ' . $totalSteps . ' (tables: ' . count($session->tables) . ', file batches: ' . count($session->fileBatches) . ')');
|
||||
// Log any preflight warnings into the session
|
||||
foreach ($preflightResult['warnings'] as $warning) {
|
||||
$session->log('PREFLIGHT WARNING: ' . $warning);
|
||||
}
|
||||
|
||||
$session->statusMessage = 'Initialized — starting backup...';
|
||||
$session->save();
|
||||
|
||||
@@ -138,6 +155,7 @@ class SteppedBackupEngine
|
||||
'phase' => $session->phase,
|
||||
'progress' => $session->getProgress(),
|
||||
'message' => $session->statusMessage,
|
||||
'warnings' => $preflightResult['warnings'],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -371,37 +389,47 @@ 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),
|
||||
'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
|
||||
@@ -415,14 +443,16 @@ 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
@@ -472,6 +502,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());
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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 = [])
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -270,10 +270,17 @@ $listDirn = $this->escape($this->state->get('list.direction'));
|
||||
|
||||
if (initResult.error) {
|
||||
updateProgress(0, 'ERROR: ' + initResult.message, 'failed');
|
||||
setTimeout(hideModal, 3000);
|
||||
setTimeout(hideModal, 5000);
|
||||
return;
|
||||
}
|
||||
|
||||
// Show preflight warnings if any
|
||||
if (initResult.warnings && initResult.warnings.length > 0) {
|
||||
var warningEl = document.getElementById('mb-phase');
|
||||
warningEl.textContent = 'Warnings: ' + initResult.warnings.join('; ');
|
||||
warningEl.style.color = '#856404';
|
||||
}
|
||||
|
||||
const sessionId = initResult.session_id;
|
||||
updateProgress(initResult.progress, initResult.message, initResult.phase);
|
||||
|
||||
|
||||
@@ -255,10 +255,17 @@ document.querySelectorAll('.mb-tile').forEach(function(tile) {
|
||||
|
||||
if (initResult.error) {
|
||||
updateProgress(0, 'ERROR: ' + initResult.message, 'failed');
|
||||
setTimeout(hideModal, 3000);
|
||||
setTimeout(hideModal, 5000);
|
||||
return;
|
||||
}
|
||||
|
||||
// Show preflight warnings if any
|
||||
if (initResult.warnings && initResult.warnings.length > 0) {
|
||||
var warningEl = document.getElementById('mb-phase');
|
||||
warningEl.textContent = 'Warnings: ' + initResult.warnings.join('; ');
|
||||
warningEl.style.color = '#856404';
|
||||
}
|
||||
|
||||
const sessionId = initResult.session_id;
|
||||
updateProgress(initResult.progress, initResult.message, initResult.phase);
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
-->
|
||||
<extension type="plugin" group="actionlog" method="upgrade">
|
||||
<name>Action Log - MokoSuiteBackup</name>
|
||||
<version>01.25.02</version>
|
||||
<version>01.29.00</version>
|
||||
<creationDate>2026-06-04</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
-->
|
||||
<extension type="plugin" group="console" method="upgrade">
|
||||
<name>Console - MokoSuiteBackup</name>
|
||||
<version>01.25.02</version>
|
||||
<version>01.29.00</version>
|
||||
<creationDate>2026-06-04</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -86,7 +86,7 @@ class RestoreCommand extends AbstractCommand
|
||||
}
|
||||
|
||||
$engine = new RestoreEngine();
|
||||
$result = $engine->restore($record->absolute_path, $record->backup_type);
|
||||
$result = $engine->restore($recordId);
|
||||
|
||||
if ($result['success']) {
|
||||
$io->success($result['message']);
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
-->
|
||||
<extension type="plugin" group="content" method="upgrade">
|
||||
<name>Content - MokoSuiteBackup</name>
|
||||
<version>01.25.02</version>
|
||||
<version>01.29.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.25.02</version>
|
||||
<version>01.29.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.25.02</version>
|
||||
<version>01.29.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();
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
-->
|
||||
<extension type="plugin" group="task" method="upgrade">
|
||||
<name>Task - MokoSuiteBackup</name>
|
||||
<version>01.25.02</version>
|
||||
<version>01.29.00</version>
|
||||
<creationDate>2026-06-02</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
-->
|
||||
<extension type="plugin" group="webservices" method="upgrade">
|
||||
<name>Web Services - MokoSuiteBackup</name>
|
||||
<version>01.25.02</version>
|
||||
<version>01.29.00</version>
|
||||
<creationDate>2026-06-02</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<extension type="package" method="upgrade">
|
||||
<name>Package - MokoSuiteBackup</name>
|
||||
<packagename>mokosuitebackup</packagename>
|
||||
<version>01.25.02</version>
|
||||
<version>01.29.00</version>
|
||||
<creationDate>2026-06-02</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
Reference in New Issue
Block a user