Compare commits
33 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -4,8 +4,8 @@
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: moko-platform.Automation
|
||||
# VERSION: 01.26.00
|
||||
# INGROUP: mokocli.Automation
|
||||
# VERSION: 01.00.00
|
||||
# BRIEF: Auto-create feature branch when an issue is opened
|
||||
|
||||
name: "Universal: Issue Branch"
|
||||
|
||||
+6
-6
@@ -1,14 +1,14 @@
|
||||
# Changelog
|
||||
## [Unreleased]
|
||||
|
||||
## [01.26.00] --- 2026-06-21
|
||||
## [01.27.03] --- 2026-06-21
|
||||
|
||||
## [01.26.00] --- 2026-06-21
|
||||
## [01.27.03] --- 2026-06-21
|
||||
|
||||
## [01.25.00] --- 2026-06-20
|
||||
## [01.27.00] --- 2026-06-21
|
||||
|
||||
## [01.25.00] --- 2026-06-20
|
||||
## [01.27.00] --- 2026-06-21
|
||||
|
||||
## [01.24.00] --- 2026-06-20
|
||||
## [01.27.00] --- 2026-06-21
|
||||
|
||||
## [01.24.00] --- 2026-06-19
|
||||
## [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.26.00 -->
|
||||
<!-- VERSION: 01.27.03 -->
|
||||
|
||||
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.26.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,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,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
-->
|
||||
<extension type="component" method="upgrade">
|
||||
<name>MokoSuiteBackup</name>
|
||||
<version>01.26.00</version>
|
||||
<version>01.27.03</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 ?? '';
|
||||
|
||||
@@ -300,10 +319,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 +359,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 +414,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 +422,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);
|
||||
|
||||
@@ -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'],
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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.26.00</version>
|
||||
<version>01.27.03</version>
|
||||
<creationDate>2026-06-04</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
-->
|
||||
<extension type="plugin" group="console" method="upgrade">
|
||||
<name>Console - MokoSuiteBackup</name>
|
||||
<version>01.26.00</version>
|
||||
<version>01.27.03</version>
|
||||
<creationDate>2026-06-04</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -86,7 +86,7 @@ class RestoreCommand extends AbstractCommand
|
||||
}
|
||||
|
||||
$engine = new RestoreEngine();
|
||||
$result = $engine->restore($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.26.00</version>
|
||||
<version>01.27.03</version>
|
||||
<creationDate>2026-06-04</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<extension type="plugin" group="quickicon" method="upgrade">
|
||||
<name>Quick Icon - MokoSuiteBackup</name>
|
||||
<version>01.26.00</version>
|
||||
<version>01.27.03</version>
|
||||
<creationDate>2026-06-02</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
-->
|
||||
<extension type="plugin" group="system" method="upgrade">
|
||||
<name>System - MokoSuiteBackup</name>
|
||||
<version>01.26.00</version>
|
||||
<version>01.27.03</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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
-->
|
||||
<extension type="plugin" group="task" method="upgrade">
|
||||
<name>Task - MokoSuiteBackup</name>
|
||||
<version>01.26.00</version>
|
||||
<version>01.27.03</version>
|
||||
<creationDate>2026-06-02</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
-->
|
||||
<extension type="plugin" group="webservices" method="upgrade">
|
||||
<name>Web Services - MokoSuiteBackup</name>
|
||||
<version>01.26.00</version>
|
||||
<version>01.27.03</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.26.00</version>
|
||||
<version>01.27.03</version>
|
||||
<creationDate>2026-06-02</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
Reference in New Issue
Block a user