26 Commits

Author SHA1 Message Date
jmiller d2546db9de chore: sync security-audit.yml from Template-Joomla [skip ci] 2026-06-07 17:59:05 +00:00
jmiller 476d79cca7 chore: sync repo-health.yml from Template-Joomla [skip ci] 2026-06-07 17:59:05 +00:00
jmiller a7b2716d20 chore: sync pre-release.yml from Template-Joomla [skip ci] 2026-06-07 17:59:04 +00:00
jmiller 59dffdb38f chore: sync pr-check.yml from Template-Joomla [skip ci] 2026-06-07 17:59:04 +00:00
jmiller d26f938c26 chore: sync notify.yml from Template-Joomla [skip ci] 2026-06-07 17:59:04 +00:00
jmiller 9b90738e54 chore: sync issue-branch.yml from Template-Joomla [skip ci] 2026-06-07 17:59:03 +00:00
jmiller b7dc38dcbc chore: sync gitleaks.yml from Template-Joomla [skip ci] 2026-06-07 17:59:03 +00:00
jmiller ef6d3f4dc9 chore: sync deploy-manual.yml from Template-Joomla [skip ci] 2026-06-07 17:59:02 +00:00
jmiller c6826ebc45 chore: sync cleanup.yml from Template-Joomla [skip ci] 2026-06-07 17:59:02 +00:00
jmiller 6a2788786e chore: sync ci-joomla.yml from Template-Joomla [skip ci] 2026-06-07 17:59:02 +00:00
jmiller 78e507e034 chore: sync ci-generic.yml from Template-Joomla [skip ci] 2026-06-07 17:59:01 +00:00
jmiller c842998d1a chore: sync cascade-dev.yml from Template-Joomla [skip ci] 2026-06-07 17:59:01 +00:00
jmiller cc1c84f662 chore: sync branch-cleanup.yml from Template-Joomla [skip ci] 2026-06-07 17:59:01 +00:00
jmiller 2b66d4a9d1 chore: sync auto-release.yml from Template-Joomla [skip ci] 2026-06-07 17:59:00 +00:00
jmiller 7eb56e07a9 chore: sync auto-bump.yml from Template-Joomla [skip ci] 2026-06-07 17:59:00 +00:00
gitea-actions[bot] 9af3b7ba9d chore(release): build 01.08.00 [skip ci] 2026-06-07 15:06:21 +00:00
jmiller abe68c1e64 Merge pull request 'Release v01.07.00 — scheduled task, install warnings, AkeebaImporter cleanup' (#44) from dev into main
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Report Issues (push) Blocked by required conditions
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
2026-06-07 15:06:14 +00:00
gitea-actions[bot] 4d74627720 chore(version): pre-release bump to 01.07.01-dev [skip ci]
Branch Cleanup / Delete merged branch (pull_request) Has been skipped
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Universal: Build & Release / Build & Release Pipeline (pull_request) Successful in 10s
2026-06-07 14:59:37 +00:00
Jonathan Miller e5fb88e1a4 feat: auto-create default scheduled task on install, fix AkeebaImporter constants
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Report Issues (push) Blocked by required conditions
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 2s
Universal: Auto Version Bump / Version Bump (push) Successful in 11s
- Create "Monthly Full Backup" scheduled task (30-day interval, profile 1,
  03:00 execution) on fresh install via com_scheduler
- Skips if any MokoJoomBackup task already exists
- Failure notifications enabled to Super Users group by default
- Replace hardcoded backup dir paths in AkeebaImporter with
  BackupDirectory::DEFAULT_RELATIVE constant
2026-06-07 09:50:32 -05:00
Jonathan Miller df44518611 fix: skip all postflight actions on uninstall, add install warnings
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Report Issues (push) Blocked by required conditions
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
Universal: Auto Version Bump / Version Bump (push) Successful in 8s
- Early return on uninstall to prevent license warning, menu sync, and
  default-dir check from running during package removal
- Add warnDefaultBackupDir() — warns on install/update if any profile
  uses the default web-root backup directory
- Add profile review reminder on fresh install with link to profiles view
2026-06-07 09:46:46 -05:00
gitea-actions[bot] 5e0683c0c7 chore(release): build 01.07.00 [skip ci] 2026-06-07 14:44:24 +00:00
jmiller bbf3dcd449 Merge pull request 'Refactor: extract BackupDirectory utility class' (#43) from dev into main
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Report Issues (push) Blocked by required conditions
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 2s
2026-06-07 14:44:09 +00:00
gitea-actions[bot] 139423cbe9 chore(version): pre-release bump to 01.06.01-dev [skip ci]
Branch Cleanup / Delete merged branch (pull_request) Has been skipped
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Universal: Build & Release / Build & Release Pipeline (pull_request) Successful in 18s
2026-06-07 14:41:18 +00:00
Jonathan Miller 814d1b147c refactor: extract BackupDirectory utility to eliminate code duplication
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Report Issues (push) Blocked by required conditions
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
Universal: Auto Version Bump / Version Bump (push) Successful in 8s
- Create BackupDirectory utility class with centralized:
  - DEFAULT_RELATIVE constant and PLACEHOLDER constant
  - resolve() — path resolution with [DEFAULT_DIR] and relative path handling
  - hasPlaceholders() — check for unresolved placeholder tokens
  - isWebAccessible() — web-root boundary check
  - protect() — .htaccess and index.html creation with error logging
  - ensureReady() — mkdir + protect in one call
  - parseNewlineList() — newline-separated text parsing
  - logPathFromArchive() — derive .log path from archive path
- Remove duplicated methods from BackupEngine, SteppedBackupEngine,
  ProfileTable, AjaxController, and DashboardModel
- All consumers now use BackupDirectory static methods
- Net reduction: ~180 lines of duplicated code eliminated
2026-06-07 09:39:43 -05:00
gitea-actions[bot] 6dc7fdfa91 chore(release): build 01.06.00 [skip ci] 2026-06-07 14:23:16 +00:00
jmiller 3de786cf71 Merge pull request 'Release v01.05.00 — dashboard menu, [DEFAULT_DIR], live validation, security hardening' (#42) from rc into main 2026-06-07 14:23:05 +00:00
38 changed files with 2160 additions and 1920 deletions
+1 -1
View File
@@ -5,7 +5,7 @@
<display-name>Package - MokoJoomBackup</display-name>
<org>MokoConsulting</org>
<description>Full-site backup and restore for Joomla — database, files, and configuration</description>
<version>01.05.00-dev</version>
<version>01.08.00-dev</version>
<license spdx="GPL-3.0-or-later">GNU General Public License v3</license>
</identity>
<governance>
+66 -66
View File
@@ -1,66 +1,66 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: mokoplatform.Release
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokoplatform
# PATH: /.mokogitea/workflows/auto-bump.yml
# VERSION: 09.23.00
# BRIEF: Auto patch-bump version on every push to dev (skips merge commits)
name: "Universal: Auto Version Bump"
on:
push:
branches:
- dev
- rc
- 'feature/**'
- 'patch/**'
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
permissions:
contents: write
jobs:
bump:
name: Version Bump
runs-on: release
if: >-
!contains(github.event.head_commit.message, '[skip ci]') &&
!contains(github.event.head_commit.message, '[skip bump]') &&
!startsWith(github.event.head_commit.message, 'Merge pull request')
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
token: ${{ secrets.MOKOGITEA_TOKEN }}
fetch-depth: 1
- name: Setup mokoplatform tools
run: |
if ! command -v composer &> /dev/null; then
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
fi
if [ -d "/opt/mokoplatform/cli" ]; then
echo "MOKO_CLI=/opt/mokoplatform/cli" >> "$GITHUB_ENV"
else
git clone --depth 1 --branch main --quiet \
"https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/MokoConsulting/mokoplatform.git" \
/tmp/mokoplatform-api
cd /tmp/mokoplatform-api && composer install --no-dev --no-interaction --quiet
echo "MOKO_CLI=/tmp/mokoplatform-api/cli" >> "$GITHUB_ENV"
fi
- name: Bump version
run: |
php ${MOKO_CLI}/version_auto_bump.php \
--path . --branch "${GITHUB_REF_NAME}" \
--token "${{ secrets.MOKOGITEA_TOKEN }}" \
--repo-url "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: moko-platform.Release
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
# PATH: /.mokogitea/workflows/auto-bump.yml
# VERSION: 09.02.00
# BRIEF: Auto patch-bump version on every push to dev (skips merge commits)
name: "Universal: Auto Version Bump"
on:
push:
branches:
- dev
- rc
- 'feature/**'
- 'patch/**'
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
permissions:
contents: write
jobs:
bump:
name: Version Bump
runs-on: release
if: >-
!contains(github.event.head_commit.message, '[skip ci]') &&
!contains(github.event.head_commit.message, '[skip bump]') &&
!startsWith(github.event.head_commit.message, 'Merge pull request')
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
token: ${{ secrets.MOKOGITEA_TOKEN }}
fetch-depth: 1
- name: Setup moko-platform tools
run: |
if ! command -v composer &> /dev/null; then
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
fi
if [ -d "/opt/moko-platform/cli" ]; then
echo "MOKO_CLI=/opt/moko-platform/cli" >> "$GITHUB_ENV"
else
git clone --depth 1 --branch main --quiet \
"https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/MokoConsulting/moko-platform.git" \
/tmp/moko-platform-api
cd /tmp/moko-platform-api && composer install --no-dev --no-interaction --quiet
echo "MOKO_CLI=/tmp/moko-platform-api/cli" >> "$GITHUB_ENV"
fi
- name: Bump version
run: |
php ${MOKO_CLI}/version_auto_bump.php \
--path . --branch "${GITHUB_REF_NAME}" \
--token "${{ secrets.MOKOGITEA_TOKEN }}" \
--repo-url "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
+91 -35
View File
@@ -4,8 +4,8 @@
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: mokoplatform.Release
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/mokoplatform
# INGROUP: moko-platform.Release
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/moko-platform
# PATH: /templates/workflows/universal/auto-release.yml.template
# VERSION: 05.00.00
# BRIEF: Universal build & release detects platform from manifest.xml
@@ -17,7 +17,7 @@
# | Reads manifest.xml (joomla|dolibarr|generic) to branch logic. |
# | |
# | Platform-specific: |
# | joomla: XML manifest, updates.xml, type-prefixed packages |
# | joomla: XML manifest, type-prefixed packages |
# | dolibarr: mod*.class.php, update.txt, dev version reset |
# | generic: README-only, no update stream |
# | |
@@ -66,25 +66,30 @@ jobs:
token: ${{ secrets.MOKOGITEA_TOKEN }}
fetch-depth: 1
- name: Setup mokoplatform tools
- name: Setup moko-platform tools
env:
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
run: |
if ! command -v composer &> /dev/null; then
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
if [ -f /opt/moko-platform/cli/version_bump.php ] && [ -f /opt/moko-platform/vendor/autoload.php ]; then
echo Using pre-installed /opt/moko-platform
echo MOKO_CLI=/opt/moko-platform/cli >> $GITHUB_ENV
else
echo Falling back to fresh clone
if ! command -v composer > /dev/null 2>&1; then
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer > /dev/null 2>&1
fi
rm -rf /tmp/moko-platform-api
CLONE_URL=https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git
git clone --depth 1 --branch main --quiet $CLONE_URL /tmp/moko-platform-api
cd /tmp/moko-platform-api
composer install --no-dev --no-interaction --quiet
echo MOKO_CLI=/tmp/moko-platform-api/cli >> $GITHUB_ENV
fi
# Always fetch latest CLI tools — never use stale cache from previous runs
rm -rf /tmp/mokoplatform-api
git clone --depth 1 --branch main --quiet \
"https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/mokoplatform.git" \
/tmp/mokoplatform-api
cd /tmp/mokoplatform-api
composer install --no-dev --no-interaction --quiet
- name: Rename branch to rc
run: |
php /tmp/mokoplatform-api/cli/branch_rename.php \
php ${MOKO_CLI}/branch_rename.php \
--from "${{ github.event.pull_request.head.ref || 'dev' }}" --to rc \
--token "${{ secrets.MOKOGITEA_TOKEN }}" \
--api-base "${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" \
@@ -100,16 +105,15 @@ jobs:
- name: Publish RC release
run: |
php /tmp/mokoplatform-api/cli/release_publish.php \
php ${MOKO_CLI}/release_publish.php \
--path . --stability rc --bump minor --branch rc \
--token "${{ secrets.MOKOGITEA_TOKEN }}" \
--skip-update-stream
--token "${{ secrets.MOKOGITEA_TOKEN }}"
- name: Summary
if: always()
run: |
echo "## Promoted to Release Candidate" >> $GITHUB_STEP_SUMMARY
echo "Branch renamed to rc, minor bump, RC release built (updates.xml managed by Gitea Pages)" >> $GITHUB_STEP_SUMMARY
echo "Branch renamed to rc, minor bump, RC release built" >> $GITHUB_STEP_SUMMARY
# ── Merged PR → Build & Release (or promote RC to stable) ────────────────────
release:
@@ -145,31 +149,83 @@ jobs:
fi
echo "No conflict markers found"
- name: Setup mokoplatform tools
- name: Setup moko-platform tools
env:
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GH_MIRROR_TOKEN }}"}}'
run: |
# Ensure PHP + Composer are available
if ! command -v composer &> /dev/null; then
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
if [ -f /opt/moko-platform/cli/version_bump.php ] && [ -f /opt/moko-platform/vendor/autoload.php ]; then
echo Using pre-installed /opt/moko-platform
echo MOKO_CLI=/opt/moko-platform/cli >> $GITHUB_ENV
else
echo Falling back to fresh clone
if ! command -v composer > /dev/null 2>&1; then
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer > /dev/null 2>&1
fi
rm -rf /tmp/moko-platform-api
CLONE_URL=https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git
git clone --depth 1 --branch main --quiet $CLONE_URL /tmp/moko-platform-api
cd /tmp/moko-platform-api
composer install --no-dev --no-interaction --quiet
echo MOKO_CLI=/tmp/moko-platform-api/cli >> $GITHUB_ENV
fi
# Always fetch latest CLI tools — never use stale cache from previous runs
rm -rf /tmp/mokoplatform-api
git clone --depth 1 --branch main --quiet \
"https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/mokoplatform.git" \
/tmp/mokoplatform-api
cd /tmp/mokoplatform-api
composer install --no-dev --no-interaction --quiet
- name: "Determine version bump level"
id: bump
run: |
# Fix/patch branches: version was already bumped by pre-release, just strip suffix
# Feature/dev branches: bump minor for the new stable release
HEAD_REF="${{ github.event.pull_request.head.ref || 'dev' }}"
case "$HEAD_REF" in
fix/*|patch/*|hotfix/*|bugfix/*) BUMP="none" ;;
*) BUMP="minor" ;;
esac
echo "level=${BUMP}" >> "$GITHUB_OUTPUT"
echo "Bump level: ${BUMP} (from branch: ${HEAD_REF})"
- name: "Publish stable release"
run: |
php /tmp/mokoplatform-api/cli/release_publish.php \
--path . --stability stable --bump minor --branch main \
--token "${{ secrets.MOKOGITEA_TOKEN }}" \
--skip-update-stream
BUMP_FLAG=""
if [ "${{ steps.bump.outputs.level }}" != "none" ]; then
BUMP_FLAG="--bump ${{ steps.bump.outputs.level }}"
fi
php ${MOKO_CLI}/release_publish.php \
--path . --stability stable ${BUMP_FLAG} --branch main \
--token "${{ secrets.MOKOGITEA_TOKEN }}"
- name: Update release notes from CHANGELOG.md
run: |
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
# Extract [Unreleased] section from changelog
if [ -f "CHANGELOG.md" ]; then
NOTES=$(awk '/^## \[Unreleased\]/{found=1; next} /^## \[/{if(found) exit} found{print}' CHANGELOG.md)
[ -z "$NOTES" ] && NOTES="Stable release"
else
NOTES="Stable release"
fi
# Update release body via API
RELEASE_ID=$(curl -sf -H "Authorization: token ${{ secrets.MOKOGITEA_TOKEN }}" \
"${API_BASE}/releases/tags/stable" | python3 -c "import json,sys; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)
if [ -n "$RELEASE_ID" ]; then
python3 -c "
import json, urllib.request
body = open('/dev/stdin').read()
payload = json.dumps({'body': body}).encode()
req = urllib.request.Request(
'${API_BASE}/releases/${RELEASE_ID}',
data=payload, method='PATCH',
headers={
'Authorization': 'token ${{ secrets.MOKOGITEA_TOKEN }}',
'Content-Type': 'application/json'
})
urllib.request.urlopen(req)
" <<< "$NOTES"
echo "Release notes updated from CHANGELOG.md"
fi
# -- STEP 9: Mirror to GitHub (stable only) --------------------------------
- name: "Step 9: Mirror release to GitHub"
@@ -182,7 +238,7 @@ jobs:
RELEASE_TAG="${{ steps.version.outputs.release_tag }}"
GH_REPO="${{ vars.GH_MIRROR_REPO || github.repository }}"
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
php /tmp/mokoplatform-api/cli/release_mirror.php \
php ${MOKO_CLI}/release_mirror.php \
--version "$VERSION" --tag "$RELEASE_TAG" \
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \
--gh-token "${{ secrets.GH_MIRROR_TOKEN }}" --gh-repo "$GH_REPO" \
@@ -256,7 +312,7 @@ jobs:
continue-on-error: true
run: |
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
php /tmp/mokoplatform-api/cli/version_reset_dev.php \
php ${MOKO_CLI}/version_reset_dev.php \
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "${API_BASE}" \
--branch dev --path . 2>&1 || true
+3 -3
View File
@@ -4,10 +4,10 @@
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: MokoPlatform.Universal
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokoplatform
# INGROUP: MokoStandards.Universal
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
# PATH: /.mokogitea/workflows/branch-cleanup.yml
# VERSION: 09.23.00
# VERSION: 01.00.00
# BRIEF: Delete feature branches after PR merge
name: "Branch Cleanup"
+10
View File
@@ -0,0 +1,10 @@
# DISABLED — auto-release Step 11 recreates dev from main after every release.
# Cascade-dev is redundant and causes version conflicts when both main and dev
# have different version numbers in templateDetails.xml / manifest.xml.
name: "Cascade Main → Dev (DISABLED)"
on: workflow_dispatch
jobs:
noop:
runs-on: ubuntu-latest
steps:
- run: echo "Cascade disabled — auto-release handles dev recreation"
+204
View File
@@ -0,0 +1,204 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: MokoStandards.CI
# REPO: https://git.mokoconsulting.tech/MokoConsulting/Template-Generic
# PATH: /.gitea/workflows/ci-generic.yml
# VERSION: 01.00.00
# BRIEF: CI pipeline — lint, validate, and test for generic projects (PHP + Node.js)
name: "Generic: Project CI"
on:
push:
branches:
- main
- dev
- dev/**
- rc/**
- version/**
pull_request:
branches:
- main
- dev
- dev/**
- rc/**
workflow_dispatch:
permissions:
contents: read
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
# ── Lint & Validate ───────────────────────────────────────────────────
lint:
name: Lint & Validate
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Detect toolchain
id: detect
run: |
HAS_PHP=false
HAS_NODE=false
[ -f "composer.json" ] && HAS_PHP=true
[ -f "package.json" ] && HAS_NODE=true
echo "has_php=$HAS_PHP" >> "$GITHUB_OUTPUT"
echo "has_node=$HAS_NODE" >> "$GITHUB_OUTPUT"
echo "Toolchain: PHP=$HAS_PHP Node=$HAS_NODE"
- name: Setup PHP
if: steps.detect.outputs.has_php == 'true'
run: |
if ! command -v php &> /dev/null; then
sudo apt-get update -qq
sudo apt-get install -y -qq php-cli php-mbstring php-xml >/dev/null 2>&1
fi
php -v
- name: Setup Node.js
if: steps.detect.outputs.has_node == 'true'
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install PHP dependencies
if: steps.detect.outputs.has_php == 'true'
run: |
if [ -f "composer.json" ]; then
composer install --no-interaction --prefer-dist --quiet 2>/dev/null || true
fi
- name: Install Node.js dependencies
if: steps.detect.outputs.has_node == 'true'
run: |
if [ -f "package.json" ]; then
npm ci --quiet 2>/dev/null || npm install --quiet 2>/dev/null || true
fi
- name: PHP syntax check
if: steps.detect.outputs.has_php == 'true'
run: |
ERRORS=0
while IFS= read -r -d '' file; do
if ! php -l "$file" 2>&1 | grep -q "No syntax errors"; then
echo "::error file=${file}::PHP syntax error"
ERRORS=$((ERRORS + 1))
fi
done < <(find . -name "*.php" -not -path "./.git/*" -not -path "./vendor/*" -not -path "./node_modules/*" -print0)
echo "## PHP Lint" >> $GITHUB_STEP_SUMMARY
if [ "$ERRORS" -eq 0 ]; then
echo "All PHP files passed syntax check." >> $GITHUB_STEP_SUMMARY
else
echo "${ERRORS} file(s) with syntax errors." >> $GITHUB_STEP_SUMMARY
exit 1
fi
- name: TypeScript/JavaScript lint
if: steps.detect.outputs.has_node == 'true'
run: |
if [ -f "node_modules/.bin/eslint" ]; then
npx eslint src/ --quiet 2>&1 || { echo "::error::ESLint errors found"; exit 1; }
echo "## ESLint" >> $GITHUB_STEP_SUMMARY
echo "All files passed ESLint." >> $GITHUB_STEP_SUMMARY
elif [ -f ".eslintrc.json" ] || [ -f ".eslintrc.js" ] || [ -f "eslint.config.js" ]; then
echo "::warning::ESLint config found but eslint not installed"
else
echo "No ESLint configured — skipping"
fi
- name: TypeScript compile check
if: steps.detect.outputs.has_node == 'true'
run: |
if [ -f "tsconfig.json" ] && [ -f "node_modules/.bin/tsc" ]; then
npx tsc --noEmit 2>&1 || { echo "::error::TypeScript compilation errors"; exit 1; }
echo "## TypeScript" >> $GITHUB_STEP_SUMMARY
echo "TypeScript compilation passed." >> $GITHUB_STEP_SUMMARY
fi
- name: PHPStan static analysis
if: steps.detect.outputs.has_php == 'true'
run: |
if [ -f "phpstan.neon" ] && [ -f "vendor/bin/phpstan" ]; then
vendor/bin/phpstan analyse --no-progress 2>&1 || { echo "::warning::PHPStan found issues"; }
fi
# ── Tests ─────────────────────────────────────────────────────────────
test:
name: Tests
runs-on: ubuntu-latest
needs: lint
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Detect toolchain
id: detect
run: |
HAS_PHP=false
HAS_NODE=false
[ -f "composer.json" ] && HAS_PHP=true
[ -f "package.json" ] && HAS_NODE=true
echo "has_php=$HAS_PHP" >> "$GITHUB_OUTPUT"
echo "has_node=$HAS_NODE" >> "$GITHUB_OUTPUT"
- name: Setup PHP
if: steps.detect.outputs.has_php == 'true'
run: |
if ! command -v php &> /dev/null; then
sudo apt-get update -qq
sudo apt-get install -y -qq php-cli php-mbstring php-xml >/dev/null 2>&1
fi
- name: Setup Node.js
if: steps.detect.outputs.has_node == 'true'
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install dependencies
run: |
[ -f "composer.json" ] && composer install --no-interaction --prefer-dist --quiet 2>/dev/null || true
[ -f "package.json" ] && { npm ci --quiet 2>/dev/null || npm install --quiet 2>/dev/null || true; }
- name: Run PHP tests
if: steps.detect.outputs.has_php == 'true'
run: |
if [ -f "vendor/bin/phpunit" ]; then
vendor/bin/phpunit --testdox 2>&1
echo "## PHPUnit" >> $GITHUB_STEP_SUMMARY
echo "Tests passed." >> $GITHUB_STEP_SUMMARY
elif [ -f "phpunit.xml" ] || [ -f "phpunit.xml.dist" ]; then
echo "::warning::PHPUnit config found but phpunit not installed"
else
echo "No PHPUnit configured — skipping"
fi
- name: Run Node.js tests
if: steps.detect.outputs.has_node == 'true'
run: |
if jq -e '.scripts.test' package.json > /dev/null 2>&1; then
npm test 2>&1
echo "## Node.js Tests" >> $GITHUB_STEP_SUMMARY
echo "Tests passed." >> $GITHUB_STEP_SUMMARY
else
echo "No test script in package.json — skipping"
fi
- name: Build check
run: |
if [ -f "Makefile" ]; then
make build 2>&1 || echo "::warning::Build failed or not configured"
elif [ -f "package.json" ] && jq -e '.scripts.build' package.json > /dev/null 2>&1; then
npm run build 2>&1 || echo "::warning::Build failed"
fi
+75 -61
View File
@@ -35,25 +35,32 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
uses: actions/checkout@v4
- name: Setup PHP
run: |
if ! command -v php &> /dev/null; then
sudo apt-get update -qq
sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
fi
php -v && composer --version
- name: Clone MokoStandards
- name: Setup moko-platform tools
env:
GA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN || secrets.MOKOGITEA_TOKEN || github.token }}
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN || secrets.MOKOGITEA_TOKEN || github.token }}
MOKO_CLONE_HOST: ${{ secrets.MOKOGITEA_TOKEN && 'git.mokoconsulting.tech/MokoConsulting' || 'github.com/mokoconsulting-tech' }}
MOKO_CLONE_TOKEN: ${{ secrets.GA_TOKEN || github.token }}
MOKO_CLONE_HOST: ${{ secrets.GA_TOKEN && 'git.mokoconsulting.tech/MokoConsulting' || 'github.com/mokoconsulting-tech' }}
run: |
git clone --depth 1 --branch main --quiet \
"https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/MokoStandards-API.git" \
/tmp/mokostandards-api
if [ -d "/tmp/moko-platform" ] || [ -d "/opt/moko-platform" ]; then
echo "moko-platform already available on runner — skipping clone"
else
git clone --depth 1 --branch main --quiet \
"https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git" \
/tmp/moko-platform 2>/dev/null || echo "moko-platform clone skipped — continuing without it"
fi
- name: Install dependencies
env:
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GH_MIRROR_TOKEN || github.token }}"}}'
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GH_TOKEN || secrets.GA_TOKEN || github.token }}"}}'
run: |
if [ -f "composer.json" ]; then
composer install \
@@ -67,7 +74,7 @@ jobs:
- name: PHP syntax check
run: |
ERRORS=0
for DIR in source/ src/ htdocs/; do
for DIR in src/ htdocs/; do
if [ -d "$DIR" ]; then
FOUND=1
while IFS= read -r -d '' FILE; do
@@ -124,13 +131,8 @@ jobs:
echo "Manifest is well-formed XML." >> $GITHUB_STEP_SUMMARY
fi
# Check required tags
REQUIRED_TAGS="name version author"
# namespace is only required for non-package extensions
if ! grep -q 'type="package"' "$MANIFEST" 2>/dev/null; then
REQUIRED_TAGS="$REQUIRED_TAGS namespace"
fi
for TAG in $REQUIRED_TAGS; do
# Check required tags: name, version, author
for TAG in name version author; do
if ! grep -q "<${TAG}>" "$MANIFEST" 2>/dev/null; then
echo "Missing required tag: \`<${TAG}>\`" >> $GITHUB_STEP_SUMMARY
ERRORS=$((ERRORS + 1))
@@ -138,6 +140,19 @@ jobs:
echo "Found required tag: \`<${TAG}>\`" >> $GITHUB_STEP_SUMMARY
fi
done
# Namespace is required for components/plugins but not packages
EXT_TYPE=$(grep -oP '<extension[^>]*\btype="\K[^"]+' "$MANIFEST" | head -1)
if [ "$EXT_TYPE" != "package" ]; then
if ! grep -q "<namespace" "$MANIFEST" 2>/dev/null; then
echo "Missing required tag: \`<namespace>\` (required for Joomla 5+ ${EXT_TYPE} extensions)" >> $GITHUB_STEP_SUMMARY
ERRORS=$((ERRORS + 1))
else
echo "Found required tag: \`<namespace>\`" >> $GITHUB_STEP_SUMMARY
fi
else
echo "Package extension — \`<namespace>\` not required." >> $GITHUB_STEP_SUMMARY
fi
fi
if [ "${ERRORS}" -gt 0 ]; then
@@ -202,44 +217,13 @@ jobs:
echo "**Language file check passed.**" >> $GITHUB_STEP_SUMMARY
fi
- name: Check en-GB and en-US language directories exist
run: |
echo "### Language Directory Check" >> $GITHUB_STEP_SUMMARY
ERRORS=0
for DIR in source/ src/ htdocs/; do
[ -d "$DIR" ] || continue
# Find all language directories
while IFS= read -r -d '' LANG_DIR; do
HAS_GB=false
HAS_US=false
[ -d "${LANG_DIR}/en-GB" ] && HAS_GB=true
[ -d "${LANG_DIR}/en-US" ] && HAS_US=true
if [ "$HAS_GB" = false ]; then
echo "Missing \`en-GB\` in: \`${LANG_DIR}\`" >> $GITHUB_STEP_SUMMARY
ERRORS=$((ERRORS + 1))
fi
if [ "$HAS_US" = false ]; then
echo "Missing \`en-US\` in: \`${LANG_DIR}\`" >> $GITHUB_STEP_SUMMARY
ERRORS=$((ERRORS + 1))
fi
done < <(find "$DIR" -type d -name "language" -print0)
done
if [ "${ERRORS}" -gt 0 ]; then
echo "**${ERRORS} missing language director(ies).**" >> $GITHUB_STEP_SUMMARY
exit 1
else
echo "All language directories have en-GB and en-US." >> $GITHUB_STEP_SUMMARY
fi
- name: Check index.html files in directories
run: |
echo "### Index.html Check" >> $GITHUB_STEP_SUMMARY
MISSING=0
CHECKED=0
for DIR in source/ src/ htdocs/; do
for DIR in src/ htdocs/; do
if [ -d "$DIR" ]; then
while IFS= read -r -d '' SUBDIR; do
CHECKED=$((CHECKED + 1))
@@ -252,7 +236,7 @@ jobs:
done
if [ "${CHECKED}" -eq 0 ]; then
echo "No source/, src/, or htdocs/ directories found — skipping." >> $GITHUB_STEP_SUMMARY
echo "No src/ or htdocs/ directories found — skipping." >> $GITHUB_STEP_SUMMARY
elif [ "${MISSING}" -gt 0 ]; then
echo "" >> $GITHUB_STEP_SUMMARY
echo "**${MISSING} director(ies) missing index.html out of ${CHECKED} checked.**" >> $GITHUB_STEP_SUMMARY
@@ -268,7 +252,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
uses: actions/checkout@v4
- name: Validate release readiness
run: |
@@ -276,8 +260,8 @@ jobs:
echo "" >> $GITHUB_STEP_SUMMARY
ERRORS=0
# Extract version from README.md (supports both FILE INFORMATION block and HTML comment format)
README_VERSION=$(sed -n 's/.*VERSION:\s*\([0-9]\{2\}\.[0-9]\{2\}\.[0-9]\{2\}\).*/\1/p' README.md | head -1)
# Extract version from README.md
README_VERSION=$(grep -oP '^\s*VERSION:\s*\K[0-9]{2}\.[0-9]{2}\.[0-9]{2}' README.md | head -1)
if [ -z "$README_VERSION" ]; then
echo "No VERSION found in README.md FILE INFORMATION block." >> $GITHUB_STEP_SUMMARY
ERRORS=$((ERRORS + 1))
@@ -301,7 +285,7 @@ jobs:
echo "Manifest: \`${MANIFEST}\`" >> $GITHUB_STEP_SUMMARY
# Check <version> matches README VERSION
MANIFEST_VERSION=$(sed -n 's/.*<version>\([^<]*\)<\/version>.*/\1/p' "$MANIFEST" | head -1)
MANIFEST_VERSION=$(grep -oP '<version>\K[^<]+' "$MANIFEST" | head -1)
if [ -z "$MANIFEST_VERSION" ]; then
echo "No \`<version>\` tag in manifest." >> $GITHUB_STEP_SUMMARY
ERRORS=$((ERRORS + 1))
@@ -374,15 +358,19 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
uses: actions/checkout@v4
- name: Setup PHP ${{ matrix.php }}
run: |
if ! command -v php &> /dev/null; then
sudo apt-get update -qq
sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
fi
php -v && composer --version
- name: Install dependencies
env:
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GH_MIRROR_TOKEN || github.token }}"}}'
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GH_TOKEN || secrets.GA_TOKEN || github.token }}"}}'
run: |
if [ -f "composer.json" ]; then
composer install \
@@ -420,14 +408,19 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
uses: actions/checkout@v4
- name: Setup PHP
run: php -v && composer --version
run: |
if ! command -v php &> /dev/null; then
sudo apt-get update -qq
sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
fi
php -v && composer --version
- name: Install dependencies
env:
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GH_MIRROR_TOKEN || github.token }}"}}'
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GH_TOKEN || secrets.GA_TOKEN || github.token }}"}}'
run: |
if [ -f "composer.json" ]; then
composer install --no-interaction --prefer-dist --optimize-autoloader
@@ -450,7 +443,7 @@ jobs:
# Determine source directory
SRC_DIR=""
for DIR in source/ src/ htdocs/ lib/; do
for DIR in src/ htdocs/ lib/; do
if [ -d "$DIR" ]; then
SRC_DIR="$DIR"
break
@@ -458,7 +451,7 @@ jobs:
done
if [ -z "$SRC_DIR" ]; then
echo "No source directory found (source/, src/, htdocs/, lib/) — skipping." >> $GITHUB_STEP_SUMMARY
echo "No source directory found (src/, htdocs/, lib/) — skipping." >> $GITHUB_STEP_SUMMARY
exit 0
fi
@@ -484,3 +477,24 @@ jobs:
echo '```' >> $GITHUB_STEP_SUMMARY
fi
exit $EXIT
pre-release:
name: Build RC Pre-Release
runs-on: ubuntu-latest
needs: [lint-and-validate, test]
if: github.event_name == 'pull_request'
steps:
- name: Trigger pre-release build
env:
GA_TOKEN: ${{ secrets.GA_TOKEN }}
REPO: ${{ github.repository }}
BRANCH: ${{ github.head_ref }}
run: |
curl -s -X POST \
"${GITEA_URL:-https://git.mokoconsulting.tech}/api/v1/repos/${REPO}/actions/workflows/pre-release.yml/dispatches" \
-H "Authorization: token ${GA_TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"ref\":\"${BRANCH}\",\"inputs\":{\"stability\":\"release-candidate\"}}"
echo "### Pre-Release" >> $GITHUB_STEP_SUMMARY
echo "Triggered RC build on branch \`${BRANCH}\`" >> $GITHUB_STEP_SUMMARY
+10 -10
View File
@@ -4,10 +4,10 @@
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: mokoplatform.Maintenance
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokoplatform
# INGROUP: MokoStandards.Maintenance
# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards
# PATH: /.gitea/workflows/cleanup.yml
# VERSION: 09.23.00
# VERSION: 01.00.00
# BRIEF: Scheduled cleanup — delete merged branches and old workflow runs
name: "Universal: Repository Cleanup"
@@ -33,17 +33,17 @@ jobs:
uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.MOKOGITEA_TOKEN }}
token: ${{ secrets.GA_TOKEN }}
- name: Delete merged branches
env:
GA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
GA_TOKEN: ${{ secrets.GA_TOKEN }}
run: |
echo "=== Merged Branch Cleanup ==="
API="${GITEA_URL}/api/v1/repos/${{ github.repository }}"
# List branches via API
BRANCHES=$(curl -sS -H "Authorization: token ${GITEA_TOKEN}" \
BRANCHES=$(curl -sS -H "Authorization: token ${GA_TOKEN}" \
"${API}/branches?limit=50" | jq -r '.[].name')
DELETED=0
@@ -56,7 +56,7 @@ jobs:
# Check if branch is merged into main
if git merge-base --is-ancestor "origin/${BRANCH}" origin/main 2>/dev/null; then
echo " Deleting merged branch: ${BRANCH}"
curl -sS -X DELETE -H "Authorization: token ${GITEA_TOKEN}" \
curl -sS -X DELETE -H "Authorization: token ${GA_TOKEN}" \
"${API}/branches/${BRANCH}" 2>/dev/null || true
DELETED=$((DELETED + 1))
fi
@@ -66,20 +66,20 @@ jobs:
- name: Clean old workflow runs
env:
GA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
GA_TOKEN: ${{ secrets.GA_TOKEN }}
run: |
echo "=== Workflow Run Cleanup ==="
API="${GITEA_URL}/api/v1/repos/${{ github.repository }}"
CUTOFF=$(date -d "30 days ago" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -v-30d +%Y-%m-%dT%H:%M:%SZ)
# Get old completed runs
RUNS=$(curl -sS -H "Authorization: token ${GITEA_TOKEN}" \
RUNS=$(curl -sS -H "Authorization: token ${GA_TOKEN}" \
"${API}/actions/runs?status=completed&limit=50" | \
jq -r ".workflow_runs[] | select(.created_at < \"${CUTOFF}\") | .id" 2>/dev/null)
DELETED=0
for RUN_ID in $RUNS; do
curl -sS -X DELETE -H "Authorization: token ${GITEA_TOKEN}" \
curl -sS -X DELETE -H "Authorization: token ${GA_TOKEN}" \
"${API}/actions/runs/${RUN_ID}" 2>/dev/null || true
DELETED=$((DELETED + 1))
done
+126
View File
@@ -0,0 +1,126 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: MokoStandards.Deploy
# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards-API
# PATH: /templates/workflows/joomla/deploy-manual.yml.template
# VERSION: 04.07.00
# BRIEF: Manual SFTP deploy to dev server for Joomla repos
name: "Universal: Deploy to Dev (Manual)"
on:
workflow_dispatch:
inputs:
clear_remote:
description: 'Delete all remote files before uploading'
required: false
default: 'false'
type: boolean
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
permissions:
contents: read
jobs:
deploy:
name: SFTP Deploy to Dev
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Setup PHP
run: |
php -v && composer --version
- name: Setup MokoStandards tools
env:
GA_TOKEN: ${{ secrets.GA_TOKEN || secrets.GA_TOKEN || github.token }}
MOKO_CLONE_TOKEN: ${{ secrets.GA_TOKEN || secrets.GA_TOKEN || github.token }}
MOKO_CLONE_HOST: ${{ secrets.GA_TOKEN && 'git.mokoconsulting.tech/MokoConsulting' || 'github.com/mokoconsulting-tech' }}
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GA_TOKEN || github.token }}"}}'
run: |
git clone --depth 1 --branch main --quiet \
"https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/MokoStandards-API.git" \
/tmp/mokostandards-api 2>/dev/null || true
if [ -d "/tmp/mokostandards-api" ] && [ -f "/tmp/mokostandards-api/composer.json" ]; then
cd /tmp/mokostandards-api && composer install --no-dev --no-interaction --quiet 2>/dev/null || true
fi
- name: Check FTP configuration
id: check
env:
HOST: ${{ vars.DEV_FTP_HOST }}
PATH_VAR: ${{ vars.DEV_FTP_PATH }}
PORT: ${{ vars.DEV_FTP_PORT }}
run: |
if [ -z "$HOST" ] || [ -z "$PATH_VAR" ]; then
echo "DEV_FTP_HOST or DEV_FTP_PATH not configured -- cannot deploy"
echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "skip=false" >> "$GITHUB_OUTPUT"
echo "host=$HOST" >> "$GITHUB_OUTPUT"
REMOTE="${PATH_VAR%/}"
echo "remote=$REMOTE" >> "$GITHUB_OUTPUT"
[ -z "$PORT" ] && PORT="22"
echo "port=$PORT" >> "$GITHUB_OUTPUT"
- name: Deploy via SFTP
if: steps.check.outputs.skip != 'true'
env:
SFTP_KEY: ${{ secrets.DEV_FTP_KEY }}
SFTP_PASS: ${{ secrets.DEV_FTP_PASSWORD }}
SFTP_USER: ${{ vars.DEV_FTP_USERNAME }}
run: |
SOURCE_DIR="src"
[ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs"
[ ! -d "$SOURCE_DIR" ] && { echo "No src/ or htdocs/ -- nothing to deploy"; exit 0; }
printf '{"host":"%s","port":%s,"username":"%s","remotePath":"%s"' \
"${{ steps.check.outputs.host }}" "${{ steps.check.outputs.port }}" "$SFTP_USER" "${{ steps.check.outputs.remote }}" \
> /tmp/sftp-config.json
if [ -n "$SFTP_KEY" ]; then
echo "$SFTP_KEY" > /tmp/deploy_key
chmod 600 /tmp/deploy_key
printf ',"privateKeyPath":"/tmp/deploy_key"}' >> /tmp/sftp-config.json
else
printf ',"password":"%s"}' "$SFTP_PASS" >> /tmp/sftp-config.json
fi
DEPLOY_ARGS=(--path . --src-dir "$SOURCE_DIR" --config /tmp/sftp-config.json)
[ "${{ inputs.clear_remote }}" = "true" ] && DEPLOY_ARGS+=(--clear-remote)
PLATFORM=$(php /tmp/mokostandards-api/cli/platform_detect.php --path . 2>/dev/null || true)
if [ "$PLATFORM" = "waas-component" ] && [ -f "/tmp/mokostandards-api/deploy/deploy-joomla.php" ]; then
php /tmp/mokostandards-api/deploy/deploy-joomla.php "${DEPLOY_ARGS[@]}"
else
php /tmp/mokostandards-api/deploy/deploy-sftp.php "${DEPLOY_ARGS[@]}"
fi
rm -f /tmp/deploy_key /tmp/sftp-config.json
- name: Summary
if: always()
run: |
if [ "${{ steps.check.outputs.skip }}" = "true" ]; then
echo "### Deploy Skipped -- FTP not configured" >> $GITHUB_STEP_SUMMARY
else
echo "### Manual Dev Deploy Complete" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "| Field | Value |" >> $GITHUB_STEP_SUMMARY
echo "|-------|-------|" >> $GITHUB_STEP_SUMMARY
echo "| Host | \`${{ steps.check.outputs.host }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| Remote | \`${{ steps.check.outputs.remote }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| Clear | ${{ inputs.clear_remote }} |" >> $GITHUB_STEP_SUMMARY
fi
+3 -3
View File
@@ -4,10 +4,10 @@
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: mokoplatform.Security
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/mokoplatform
# INGROUP: MokoStandards.Security
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/MokoStandards-API
# PATH: /templates/workflows/gitleaks.yml.template
# VERSION: 09.23.00
# VERSION: 01.00.00
# BRIEF: Secret scanning — detect leaked credentials, API keys, and tokens
#
# +========================================================================+
+3 -3
View File
@@ -4,8 +4,8 @@
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: mokoplatform.Automation
# VERSION: 01.05.00
# INGROUP: moko-platform.Automation
# VERSION: 01.00.00
# BRIEF: Auto-create feature branch when an issue is opened
name: "Universal: Issue Branch"
@@ -28,7 +28,7 @@ jobs:
steps:
- name: Create branch and comment
run: |
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
TOKEN="${{ secrets.GA_TOKEN }}"
API="${GITEA_URL}/api/v1/repos/${{ github.repository }}"
ISSUE_NUM="${{ github.event.issue.number }}"
ISSUE_TITLE="${{ github.event.issue.title }}"
+3 -3
View File
@@ -4,10 +4,10 @@
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: mokoplatform.Notifications
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokoplatform
# INGROUP: MokoStandards.Notifications
# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards
# PATH: /.gitea/workflows/notify.yml
# VERSION: 09.23.00
# VERSION: 01.00.00
# BRIEF: Push notifications via ntfy on release success or workflow failure
name: "Universal: Notifications"
File diff suppressed because it is too large Load Diff
+3 -235
View File
@@ -4,240 +4,8 @@
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: mokoplatform.Release
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokoplatform
# INGROUP: moko-platform.Release
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
# PATH: /templates/workflows/universal/pre-release.yml.template
# VERSION: 05.01.00
# BRIEF: Manual pre-release -- builds dev/alpha/beta/rc packages from any branch
name: "Universal: Pre-Release"
on:
pull_request:
types: [closed]
branches:
- dev
pull_request_target:
types: [synchronize, opened, reopened]
branches:
- main
workflow_dispatch:
inputs:
stability:
description: 'Pre-release channel'
required: true
type: choice
options:
- development
- alpha
- beta
- release-candidate
permissions:
contents: write
env:
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
GITEA_ORG: ${{ vars.GITEA_ORG || github.repository_owner }}
GITEA_REPO: ${{ vars.GITEA_REPO || github.event.repository.name }}
jobs:
build:
name: "Build Pre-Release (${{ inputs.stability || 'development' }})"
runs-on: release
if: >-
github.event_name == 'workflow_dispatch' ||
(github.event_name == 'pull_request' && github.event.pull_request.merged == true && github.event.pull_request.base.ref == 'dev') ||
(github.event_name == 'pull_request_target' && github.event.pull_request.base.ref == 'main')
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.MOKOGITEA_TOKEN }}
ref: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.sha || '' }}
- name: Setup mokoplatform tools
env:
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
run: |
# Use pre-installed /opt/mokoplatform if available (updated by cron every 6h)
if [ -f /opt/mokoplatform/cli/version_bump.php ] && [ -f /opt/mokoplatform/cli/manifest_element.php ] && [ -f /opt/mokoplatform/vendor/autoload.php ]; then
echo Using pre-installed /opt/mokoplatform
echo MOKO_CLI=/opt/mokoplatform/cli >> $GITHUB_ENV
else
echo Falling back to fresh clone
if ! command -v composer > /dev/null 2>&1; then
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer > /dev/null 2>&1
fi
rm -rf /tmp/mokoplatform-api
CLONE_URL=https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/mokoplatform.git
git clone --depth 1 --branch main --quiet $CLONE_URL /tmp/mokoplatform-api
cd /tmp/mokoplatform-api && composer install --no-dev --no-interaction --quiet
echo MOKO_CLI=/tmp/mokoplatform-api/cli >> $GITHUB_ENV
fi
- name: Detect platform
id: platform
run: |
php ${MOKO_CLI}/manifest_read.php --path . --github-output
- name: Resolve metadata and bump version
id: meta
run: |
# Auto-detect stability: RC for PRs targeting main, else use input or default to development
if [ "${{ github.event_name }}" = "pull_request_target" ] && [ "${{ github.event.pull_request.base.ref }}" = "main" ]; then
STABILITY="release-candidate"
else
STABILITY="${{ inputs.stability || 'development' }}"
fi
case "$STABILITY" in
development) SUFFIX="-dev"; TAG="development" ;;
alpha) SUFFIX="-alpha"; TAG="alpha" ;;
beta) SUFFIX="-beta"; TAG="beta" ;;
release-candidate) SUFFIX="-rc"; TAG="release-candidate" ;;
esac
# Bump version via CLI: patch for dev/alpha/beta, minor for RC
case "$STABILITY" in
release-candidate) BUMP="minor" ;;
*) BUMP="patch" ;;
esac
php ${MOKO_CLI}/version_bump.php --path . $([ "$BUMP" = "minor" ] && echo "--minor") 2>/dev/null || true
# Set stability suffix and verify consistency
VERSION=$(php ${MOKO_CLI}/version_read.php --path . 2>/dev/null || echo "00.00.01")
VERSION=$(echo "$VERSION" | sed 's/-\(dev\|alpha\|beta\|rc\)$//')
php ${MOKO_CLI}/version_set_platform.php \
--path . --version "$VERSION" --branch "${{ github.ref_name }}" --stability "$STABILITY" 2>/dev/null || true
php ${MOKO_CLI}/version_check.php --path . --fix 2>/dev/null || true
# Ensure licensing tags (updateservers, dlid) if enabled in manifest.xml
php ${MOKO_CLI}/manifest_licensing.php --path . --fix 2>/dev/null || true
# Append suffix for output
if [ -n "$SUFFIX" ]; then
VERSION="${VERSION}${SUFFIX}"
fi
# Commit version bump
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
git config --local user.name "gitea-actions[bot]"
git remote set-url origin "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
git add -A
git diff --cached --quiet || {
git commit -m "chore(version): pre-release bump to ${VERSION} [skip ci]"
git push origin HEAD 2>&1
}
# Auto-detect element via manifest_element.php
php ${MOKO_CLI}/manifest_element.php \
--path . --version "$VERSION" --stability "$STABILITY" \
--repo "${GITEA_REPO}" --github-output
# Read back element outputs
EXT_ELEMENT=$(grep '^ext_element=' "$GITHUB_OUTPUT" | tail -1 | cut -d= -f2)
ZIP_NAME=$(grep '^zip_name=' "$GITHUB_OUTPUT" | tail -1 | cut -d= -f2)
[ -z "$EXT_ELEMENT" ] && EXT_ELEMENT=$(echo "${GITEA_REPO}" | tr '[:upper:]' '[:lower:]' | tr -d ' -')
[ -z "$ZIP_NAME" ] && ZIP_NAME="${EXT_ELEMENT}-${VERSION}.zip"
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "stability=${STABILITY}" >> "$GITHUB_OUTPUT"
echo "suffix=${SUFFIX}" >> "$GITHUB_OUTPUT"
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
echo "zip_name=${ZIP_NAME}" >> "$GITHUB_OUTPUT"
echo "ext_element=${EXT_ELEMENT}" >> "$GITHUB_OUTPUT"
echo "=== Pre-Release: ${EXT_ELEMENT} ${VERSION}${SUFFIX} ==="
- name: Create release
id: release
run: |
TAG="${{ steps.meta.outputs.tag }}"
VERSION="${{ steps.meta.outputs.version }}"
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
php ${MOKO_CLI}/release_create.php \
--path . --version "$VERSION" --tag "$TAG" \
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \
--repo "${GITEA_REPO}" --branch dev --prerelease
- name: Update release notes from CHANGELOG.md
run: |
TAG="${{ steps.meta.outputs.tag }}"
VERSION="${{ steps.meta.outputs.version }}"
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
# Extract [Unreleased] section from changelog (everything between [Unreleased] and next ## heading)
if [ -f "CHANGELOG.md" ]; then
NOTES=$(awk '/^## \[Unreleased\]/{found=1; next} /^## \[/{if(found) exit} found{print}' CHANGELOG.md)
[ -z "$NOTES" ] && NOTES="Release ${VERSION}"
else
NOTES="Release ${VERSION}"
fi
# Update release body via API
RELEASE_ID=$(curl -sf -H "Authorization: token ${{ secrets.MOKOGITEA_TOKEN }}" \
"${API_BASE}/releases/tags/${TAG}" | python3 -c "import json,sys; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)
if [ -n "$RELEASE_ID" ]; then
python3 -c "
import json, urllib.request
body = open('/dev/stdin').read()
payload = json.dumps({'body': body}).encode()
req = urllib.request.Request(
'${API_BASE}/releases/${RELEASE_ID}',
data=payload, method='PATCH',
headers={
'Authorization': 'token ${{ secrets.MOKOGITEA_TOKEN }}',
'Content-Type': 'application/json'
})
urllib.request.urlopen(req)
" <<< "$NOTES"
echo "Release notes updated from CHANGELOG.md"
fi
- name: Build package and upload
id: package
run: |
VERSION="${{ steps.meta.outputs.version }}"
TAG="${{ steps.meta.outputs.tag }}"
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
php ${MOKO_CLI}/release_package.php \
--path . --version "$VERSION" --tag "$TAG" \
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \
--repo "${GITEA_REPO}" --output /tmp || true
# updates.xml is generated dynamically by MokoGitea license server
# No need to build, commit, or sync updates.xml from workflows
- name: "Delete lesser pre-release channels (cascade)"
continue-on-error: true
run: |
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
php ${MOKO_CLI}/release_cascade.php \
--stability "${{ steps.meta.outputs.stability }}" \
--token "${TOKEN}" \
--api-base "${API_BASE}"
- name: Summary
if: always()
run: |
VERSION="${{ steps.meta.outputs.version }}"
STABILITY="${{ steps.meta.outputs.stability }}"
ZIP_NAME="${{ steps.meta.outputs.zip_name }}"
SHA256="${{ steps.package.outputs.sha256_zip }}"
echo "## Pre-Release Complete" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "| Field | Value |" >> $GITHUB_STEP_SUMMARY
echo "|-------|-------|" >> $GITHUB_STEP_SUMMARY
echo "| Version | \`${VERSION}\` |" >> $GITHUB_STEP_SUMMARY
echo "| Channel | ${STABILITY} |" >> $GITHUB_STEP_SUMMARY
echo "| Package | \`${ZIP_NAME}\` |" >> $GITHUB_STEP_SUMMARY
echo "| SHA-256 | \`${SHA256:-n/a}\` |" >> $GITHUB_STEP_SUMMARY
# BRIEF: Auto pre-release on push to dev/alpha/beta/rc branches
File diff suppressed because it is too large Load Diff
+3 -19
View File
@@ -4,10 +4,10 @@
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: mokoplatform.Security
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokoplatform
# INGROUP: MokoStandards.Security
# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards
# PATH: /.gitea/workflows/security-audit.yml
# VERSION: 09.23.00
# VERSION: 01.00.00
# BRIEF: Dependency vulnerability scanning for composer and npm packages
name: "Universal: Security Audit"
@@ -80,19 +80,3 @@ jobs:
-H "Priority: high" \
-d "Security audit found vulnerabilities. Review dependency updates." \
"${NTFY_URL}/${NTFY_TOPIC}" || true
- name: Joomla version audit
if: always()
run: |
if [ -f "monitoring/joomla-version-audit.php" ] && [ -n "$JOOMLA_SITES" ]; then
echo "$JOOMLA_SITES" > /tmp/sites.json
php monitoring/joomla-version-audit.php --sites /tmp/sites.json || true
echo "### Joomla Version Audit" >> $GITHUB_STEP_SUMMARY
rm -f /tmp/sites.json
else
echo "Joomla audit skipped (no script or JOOMLA_SITES_JSON not configured)"
fi
env:
JOOMLA_SITES: ${{ vars.JOOMLA_SITES_JSON }}
+6 -63
View File
@@ -1,6 +1,12 @@
# Changelog
## [Unreleased]
## [01.08.00] --- 2026-06-07
## [01.07.00] --- 2026-06-07
## [01.06.00] --- 2026-06-07
## [01.05.00] --- 2026-06-07
@@ -23,66 +29,3 @@
- Dashboard submenu language key added to `.sys.ini` files (en-GB, en-US)
## [01.04.00] --- 2026-06-07
## [01.03.00] --- 2026-06-07
## [01.02.00] --- 2026-06-07
### Added
- Joomla-styled standalone installer (MokoRestore) with 7-step wizard, admin password reset, and client provisioning
- Web cron trigger for shared hosting without crontab — URL-based backup with secret word, IP whitelist
- Placeholder support for backup directories and archive filenames ([host], [date], [site_name], [profile_name], etc.)
- FolderPicker JS placeholder resolution — resolves [site_name]/[host] when browsing, reverse-replaces on selection for portable profiles
- Archive Name Format field on backup profiles with customizable filename templates
- Interactive directory tree browser for exclude filters (replaces plain text input)
- Backup log viewer modal in backup records list and inline in detail view
- Clickable dashboard status tiles linking to backup records, detail views, and scheduled tasks
- Table exclusion now supports separate Data and Structure checkboxes (backward compatible)
- Tar.gz archive format support
- User group notifications for backup events
- Folder picker field with live server directory browsing
- Default directory dashboard warning when backups are stored inside web root
- Backup log files written alongside archives (.log)
- Backup detail view with checksum, file path, DB size, and embedded log
- Browser beforeunload warning during backup progress
### Changed
- Renamed all extension elements from mokobackup to mokojoombackup (pkg, com, all plugins, DB tables, namespaces, language keys)
- Renamed source directory from src/ to source/ per MokoStandards convention
- Dashboard health check shows actual resolved backup directory path from profiles
- Update site post-install notice links to filtered list view (avoids Joomla core bug)
- License warning suppressed when download key is already configured
- Download key preserved across package updates via preflight/postflight backup
### Fixed
- Download ERR_INVALID_RESPONSE — flush output buffers before sending file headers
- Backup directory path resolution for absolute paths outside web root
- Schema migrations consolidated to version within extension range
- PSR-4 class file naming (MokoBackup*.php → MokoJoomBackup*.php)
- Nested package directories from rename flattened
- INSERT IGNORE for default profile prevents duplicate key on update
- ActionlogsHelper::getIp() replaced — method does not exist in Joomla 5
- Console plugin namespace and quickicon translation keys
- CLI exit codes and SQL schema defaults
- Component Options page (added config.xml)
- Placeholder-aware directory checks in FolderPicker and dashboard health
## 01.01 — 2026-06-04
### Added
- Admin dashboard view as default landing page with status cards, quick actions, and system health checklist (#28)
- Console plugin (plg_console_mokojoombackup) — CLI commands: run, list, profiles, restore, cleanup (#29)
- Content plugin (plg_content_mokojoombackup) — auto-backup before extension install/update (#30)
- Actionlog plugin (plg_actionlog_mokojoombackup) — logs backup and profile actions to User Action Logs (#31)
- BackupEngine dispatches onMokoJoomBackupAfterRun event for plugin listeners
- Update site notice on dashboard and post-install
### Changed
- Renamed Kickstart to MokoRestore throughout
### Fixed
- SQL update migration and error handling
- Removed orphaned scriptfile from component manifest
- Consolidated admin files into single files block
+1 -1
View File
@@ -1,6 +1,6 @@
# MokoJoomBackup
<!-- VERSION: 01.05.00 -->
<!-- VERSION: 01.08.00 -->
Full-site backup and restore for Joomla — database, files, and configuration.
@@ -8,7 +8,7 @@
-->
<extension type="component" method="upgrade">
<name>com_mokojoombackup</name>
<version>01.05.00-rc</version>
<version>01.08.00</version>
<creationDate>2026-06-02</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -18,6 +18,7 @@ defined('_JEXEC') or die;
use Joomla\CMS\MVC\Controller\BaseController;
use Joomla\CMS\Session\Session;
use Joomla\Component\MokoJoomBackup\Administrator\Engine\SteppedBackupEngine;
use Joomla\Component\MokoJoomBackup\Administrator\Utility\BackupDirectory;
class AjaxController extends BaseController
{
@@ -196,7 +197,7 @@ class AjaxController extends BaseController
}
// Try to load log from file alongside the archive
$logPath = preg_replace('/\.(zip|tar\.gz)$/i', '.log', $record->absolute_path);
$logPath = BackupDirectory::logPathFromArchive($record->absolute_path);
$logContent = '';
if (is_file($logPath)) {
@@ -239,17 +240,9 @@ class AjaxController extends BaseController
return;
}
// Resolve [DEFAULT_DIR] placeholder
$defaultDir = JPATH_ADMINISTRATOR . '/components/com_mokojoombackup/backups';
$resolved = str_replace('[DEFAULT_DIR]', $defaultDir, $rawPath);
$resolved = BackupDirectory::resolve($rawPath);
// Resolve relative paths from JPATH_ROOT
if ($resolved !== '' && $resolved[0] !== '/' && !preg_match('#^[A-Za-z]:[/\\\\]#', $resolved)) {
$resolved = JPATH_ROOT . '/' . $resolved;
}
// Skip check if unresolved placeholders remain
if (preg_match('/\[.+\]/', $resolved)) {
if (BackupDirectory::hasPlaceholders($resolved)) {
$this->sendJson([
'error' => false,
'exists' => null,
@@ -30,6 +30,7 @@ namespace Joomla\Component\MokoJoomBackup\Administrator\Engine;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\Component\MokoJoomBackup\Administrator\Utility\BackupDirectory;
class AkeebaImporter
{
@@ -484,7 +485,7 @@ class AkeebaImporter
$dir = $config['akeeba.basic.output_directory'] ?? '';
if (empty($dir) || $dir === '[DEFAULT_OUTPUT]') {
return 'administrator/components/com_mokojoombackup/backups';
return BackupDirectory::DEFAULT_RELATIVE;
}
// Convert absolute path to relative
@@ -492,7 +493,7 @@ class AkeebaImporter
$dir = ltrim(substr($dir, strlen(JPATH_ROOT)), '/\\');
}
return $dir ?: 'administrator/components/com_mokojoombackup/backups';
return $dir ?: BackupDirectory::DEFAULT_RELATIVE;
}
private function mapRemoteStorage(array $config): string
@@ -13,6 +13,7 @@ namespace Joomla\Component\MokoJoomBackup\Administrator\Engine;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\Component\MokoJoomBackup\Administrator\Utility\BackupDirectory;
use Joomla\Event\Event;
class BackupEngine
@@ -56,24 +57,20 @@ class BackupEngine
}
// Read settings directly from profile columns
$excludeDirs = $this->parseNewlineList($profile->exclude_dirs ?? '');
$excludeFiles = $this->parseNewlineList($profile->exclude_files ?? '');
$excludeTables = $this->parseNewlineList($profile->exclude_tables ?? '');
$excludeDirs = BackupDirectory::parseNewlineList($profile->exclude_dirs ?? '');
$excludeFiles = BackupDirectory::parseNewlineList($profile->exclude_files ?? '');
$excludeTables = BackupDirectory::parseNewlineList($profile->exclude_tables ?? '');
// Resolve placeholders in directory and filename
$resolver = new PlaceholderResolver($profile);
$configuredDir = $profile->backup_dir ?: '[DEFAULT_DIR]';
$this->backupDir = $this->resolveBackupDir($resolver->resolve($configuredDir));
$configuredDir = $profile->backup_dir ?: BackupDirectory::PLACEHOLDER;
$this->backupDir = BackupDirectory::resolve($resolver->resolve($configuredDir));
if (!is_dir($this->backupDir)) {
if (!mkdir($this->backupDir, 0755, true)) {
return ['success' => false, 'message' => 'Cannot create backup directory: ' . $this->backupDir, 'record_id' => 0];
}
if (!BackupDirectory::ensureReady($this->backupDir)) {
return ['success' => false, 'message' => 'Cannot create backup directory: ' . $this->backupDir, 'record_id' => 0];
}
$this->protectBackupDir($this->backupDir);
// Create backup record
$now = date('Y-m-d H:i:s');
$tag = $resolver->getTag();
@@ -474,21 +471,6 @@ class BackupEngine
$zip->close();
}
/**
* Parse a newline-separated text field into an array of trimmed, non-empty strings.
*/
private function parseNewlineList(string $text): array
{
if (empty($text)) {
return [];
}
return array_values(array_filter(
array_map('trim', explode("\n", str_replace("\r", '', $text))),
fn($line) => $line !== ''
));
}
/**
* Dispatch the onMokoJoomBackupAfterRun event so plugins (actionlog, etc.) can react.
*/
@@ -512,38 +494,6 @@ class BackupEngine
}
}
/**
* Resolve a backup directory path. Absolute paths are used as-is,
* relative paths are resolved from JPATH_ROOT.
*/
private function resolveBackupDir(string $dir): string
{
if ($dir !== '' && ($dir[0] === '/' || preg_match('#^[A-Za-z]:[/\\\\]#', $dir))) {
return rtrim($dir, '/\\');
}
return JPATH_ROOT . '/' . $dir;
}
private function protectBackupDir(string $dir): void
{
$htaccess = $dir . '/.htaccess';
if (!is_file($htaccess)) {
if (@file_put_contents($htaccess, "# Apache 2.4+\n<IfModule mod_authz_core.c>\n Require all denied\n</IfModule>\n# Apache 2.2\n<IfModule !mod_authz_core.c>\n Order deny,allow\n Deny from all\n</IfModule>\n") === false) {
error_log('MokoJoomBackup: Could not create .htaccess in backup directory: ' . $dir);
}
}
$index = $dir . '/index.html';
if (!is_file($index)) {
if (@file_put_contents($index, '<!DOCTYPE html><title></title>') === false) {
error_log('MokoJoomBackup: Could not create index.html in backup directory: ' . $dir);
}
}
}
private function log(string $message): void
{
$this->log[] = '[' . date('H:i:s') . '] ' . $message;
@@ -16,6 +16,7 @@ namespace Joomla\Component\MokoJoomBackup\Administrator\Engine;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\Component\MokoJoomBackup\Administrator\Utility\BackupDirectory;
class PlaceholderResolver
{
@@ -38,7 +39,7 @@ class PlaceholderResolver
'[site_name]' => 'Joomla site name (sanitized)',
'[type]' => 'Backup type (full, database, files, differential)',
'[random]' => 'Random 6-character hex string',
'[DEFAULT_DIR]' => 'Default backup directory (administrator/components/com_mokojoombackup/backups)',
'[DEFAULT_DIR]' => 'Default backup directory',
];
private array $replacements;
@@ -75,7 +76,7 @@ class PlaceholderResolver
'[site_name]' => $this->sanitize($siteName ?: 'joomla'),
'[type]' => $profile->backup_type ?? 'full',
'[random]' => bin2hex(random_bytes(3)),
'[DEFAULT_DIR]' => JPATH_ADMINISTRATOR . '/components/com_mokojoombackup/backups',
'[DEFAULT_DIR]' => BackupDirectory::getDefaultAbsolute(),
];
}
@@ -21,6 +21,7 @@ namespace Joomla\Component\MokoJoomBackup\Administrator\Engine;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\Component\MokoJoomBackup\Administrator\Utility\BackupDirectory;
class SteppedBackupEngine
{
@@ -52,26 +53,22 @@ class SteppedBackupEngine
$session->backupType = $profile->backup_type;
// Parse profile settings
$session->excludeDirs = $this->parseNewlineList($profile->exclude_dirs ?? '');
$session->excludeFiles = $this->parseNewlineList($profile->exclude_files ?? '');
$session->excludeTables = $this->parseNewlineList($profile->exclude_tables ?? '');
$session->backupDir = $profile->backup_dir ?: '[DEFAULT_DIR]';
$session->excludeDirs = BackupDirectory::parseNewlineList($profile->exclude_dirs ?? '');
$session->excludeFiles = BackupDirectory::parseNewlineList($profile->exclude_files ?? '');
$session->excludeTables = BackupDirectory::parseNewlineList($profile->exclude_tables ?? '');
$session->backupDir = $profile->backup_dir ?: BackupDirectory::PLACEHOLDER;
$session->remoteStorage = $profile->remote_storage ?? 'none';
$session->includeMokoRestore = (bool) ($profile->include_mokorestore ?? false);
$session->remoteKeepLocal = (bool) ($profile->remote_keep_local ?? true);
// Resolve placeholders in directory and filename
$resolver = new PlaceholderResolver($profile);
$backupDir = $this->resolveBackupDir($resolver->resolve($session->backupDir));
$backupDir = BackupDirectory::resolve($resolver->resolve($session->backupDir));
if (!is_dir($backupDir)) {
if (!mkdir($backupDir, 0755, true)) {
return ['error' => true, 'message' => 'Cannot create backup directory: ' . $backupDir];
}
if (!BackupDirectory::ensureReady($backupDir)) {
return ['error' => true, 'message' => 'Cannot create backup directory: ' . $backupDir];
}
$this->protectBackupDir($backupDir);
$now = date('Y-m-d H:i:s');
$tag = $resolver->getTag();
$nameFormat = $profile->archive_name_format ?? '[host]_[datetime]_profile[profile_id]';
@@ -422,7 +419,7 @@ class SteppedBackupEngine
$logContent = implode("\n", $session->log);
// Write log file alongside the archive
$logPath = preg_replace('/\.(zip|tar\.gz)$/i', '.log', $session->archivePath);
$logPath = BackupDirectory::logPathFromArchive($session->archivePath);
if (@file_put_contents($logPath, $logContent) === false) {
error_log('MokoJoomBackup: Could not write log file: ' . $logPath);
}
@@ -554,47 +551,4 @@ class SteppedBackupEngine
return $tables;
}
/**
* Resolve a backup directory path. Absolute paths are used as-is,
* relative paths are resolved from JPATH_ROOT.
*/
private function resolveBackupDir(string $dir): string
{
if ($dir !== '' && ($dir[0] === '/' || preg_match('#^[A-Za-z]:[/\\\\]#', $dir))) {
return rtrim($dir, '/\\');
}
return JPATH_ROOT . '/' . $dir;
}
private function protectBackupDir(string $dir): void
{
$htaccess = $dir . '/.htaccess';
if (!is_file($htaccess)) {
if (@file_put_contents($htaccess, "# Apache 2.4+\n<IfModule mod_authz_core.c>\n Require all denied\n</IfModule>\n# Apache 2.2\n<IfModule !mod_authz_core.c>\n Order deny,allow\n Deny from all\n</IfModule>\n") === false) {
error_log('MokoJoomBackup: Could not create .htaccess in backup directory: ' . $dir);
}
}
$index = $dir . '/index.html';
if (!is_file($index)) {
if (@file_put_contents($index, '<!DOCTYPE html><title></title>') === false) {
error_log('MokoJoomBackup: Could not create index.html in backup directory: ' . $dir);
}
}
}
private function parseNewlineList(string $text): array
{
if (empty($text)) {
return [];
}
return array_values(array_filter(
array_map('trim', explode("\n", str_replace("\r", '', $text))),
fn($line) => $line !== ''
));
}
}
@@ -15,6 +15,7 @@ defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\Form\FormField;
use Joomla\CMS\Language\Text;
use Joomla\Component\MokoJoomBackup\Administrator\Utility\BackupDirectory;
class FolderPickerField extends FormField
{
@@ -49,7 +50,7 @@ class FolderPickerField extends FormField
$sanitizedSiteName = preg_replace('/[^a-zA-Z0-9._-]/', '', str_replace(' ', '-', trim($siteName)));
$placeholders = [
'[DEFAULT_DIR]' => JPATH_ADMINISTRATOR . '/components/com_mokojoombackup/backups',
'[DEFAULT_DIR]' => BackupDirectory::getDefaultAbsolute(),
'[host]' => $hostname,
'[site_name]' => $sanitizedSiteName ?: 'joomla',
'[profile_id]' => '1',
@@ -14,6 +14,7 @@ defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
use Joomla\Component\MokoJoomBackup\Administrator\Utility\BackupDirectory;
class DashboardModel extends BaseDatabaseModel
{
@@ -122,11 +123,9 @@ class DashboardModel extends BaseDatabaseModel
'detail' => $aesSupport ? 'Available' : 'Requires libzip 1.2.0+',
];
// Backup directory writable — check the default path
$defaultDir = JPATH_ADMINISTRATOR . '/components/com_mokojoombackup/backups';
$backupDir = $defaultDir;
// Backup directory writable — check the first published profile's dir
$backupDir = BackupDirectory::getDefaultAbsolute();
// If profiles use a custom directory, check that instead
$db2 = $this->getDatabase();
$qDir = $db2->getQuery(true)
->select($db2->quoteName('backup_dir'))
@@ -138,16 +137,10 @@ class DashboardModel extends BaseDatabaseModel
$profileDir = $db2->loadResult();
if ($profileDir) {
// Absolute paths used as-is, relative resolved from JPATH_ROOT
if ($profileDir[0] === '/' || preg_match('#^[A-Za-z]:[/\\\\]#', $profileDir)) {
$backupDir = rtrim($profileDir, '/\\');
} else {
$backupDir = JPATH_ROOT . '/' . $profileDir;
}
$backupDir = BackupDirectory::resolve($profileDir);
}
// Skip filesystem check if path contains placeholders (resolved at backup time)
if (preg_match('/\[.+\]/', $backupDir)) {
if (BackupDirectory::hasPlaceholders($backupDir)) {
$checks[] = (object) [
'label' => 'Backup Directory',
'status' => true,
@@ -182,14 +175,12 @@ class DashboardModel extends BaseDatabaseModel
public function isUsingDefaultBackupDir(): bool
{
$db = $this->getDatabase();
$default = 'administrator/components/com_mokojoombackup/backups';
$query = $db->getQuery(true)
->select('COUNT(*)')
->from($db->quoteName('#__mokojoombackup_profiles'))
->where($db->quoteName('published') . ' = 1')
->where('(' . $db->quoteName('backup_dir') . ' = ' . $db->quote($default)
. ' OR ' . $db->quoteName('backup_dir') . ' = ' . $db->quote('[DEFAULT_DIR]')
->where('(' . $db->quoteName('backup_dir') . ' = ' . $db->quote(BackupDirectory::DEFAULT_RELATIVE)
. ' OR ' . $db->quoteName('backup_dir') . ' = ' . $db->quote(BackupDirectory::PLACEHOLDER)
. ' OR ' . $db->quoteName('backup_dir') . ' = ' . $db->quote('')
. ' OR ' . $db->quoteName('backup_dir') . ' IS NULL)');
$db->setQuery($query);
@@ -13,6 +13,7 @@ namespace Joomla\Component\MokoJoomBackup\Administrator\Table;
defined('_JEXEC') or die;
use Joomla\CMS\Table\Table;
use Joomla\Component\MokoJoomBackup\Administrator\Utility\BackupDirectory;
use Joomla\Database\DatabaseDriver;
class ProfileTable extends Table
@@ -35,49 +36,17 @@ class ProfileTable extends Table
private function protectWebAccessibleDir(string $dir): void
{
// Resolve [DEFAULT_DIR] placeholder
$defaultDir = JPATH_ADMINISTRATOR . '/components/com_mokojoombackup/backups';
$resolved = str_replace('[DEFAULT_DIR]', $defaultDir, $dir);
$resolved = BackupDirectory::resolve($dir);
// Resolve relative paths from JPATH_ROOT
if ($resolved !== '' && $resolved[0] !== '/' && !preg_match('#^[A-Za-z]:[/\\\\]#', $resolved)) {
$resolved = JPATH_ROOT . '/' . $resolved;
}
// Skip if unresolved placeholders remain
if (preg_match('/\[.+\]/', $resolved)) {
if (BackupDirectory::hasPlaceholders($resolved)) {
return;
}
// Only protect directories under the web root
$jRoot = realpath(JPATH_ROOT) ?: JPATH_ROOT;
$realDir = realpath($resolved) ?: $resolved;
if (strpos($realDir, $jRoot) !== 0) {
if (!BackupDirectory::isWebAccessible($resolved)) {
return;
}
if (!is_dir($resolved)) {
@mkdir($resolved, 0755, true);
}
if (is_dir($resolved)) {
$htaccess = $resolved . '/.htaccess';
if (!is_file($htaccess)) {
if (@file_put_contents($htaccess, "# Apache 2.4+\n<IfModule mod_authz_core.c>\n Require all denied\n</IfModule>\n# Apache 2.2\n<IfModule !mod_authz_core.c>\n Order deny,allow\n Deny from all\n</IfModule>\n") === false) {
error_log('MokoJoomBackup: Could not create .htaccess in: ' . $resolved);
}
}
$index = $resolved . '/index.html';
if (!is_file($index)) {
if (@file_put_contents($index, '<!DOCTYPE html><title></title>') === false) {
error_log('MokoJoomBackup: Could not create index.html in: ' . $resolved);
}
}
}
BackupDirectory::ensureReady($resolved);
}
public function check(): bool
@@ -0,0 +1,153 @@
<?php
/**
* @package MokoJoomBackup
* @subpackage com_mokojoombackup
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Joomla\Component\MokoJoomBackup\Administrator\Utility;
defined('_JEXEC') or die;
class BackupDirectory
{
public const DEFAULT_RELATIVE = 'administrator/components/com_mokojoombackup/backups';
public const PLACEHOLDER = '[DEFAULT_DIR]';
private const HTACCESS_CONTENT = <<<'HTACCESS'
# Apache 2.4+
<IfModule mod_authz_core.c>
Require all denied
</IfModule>
# Apache 2.2
<IfModule !mod_authz_core.c>
Order deny,allow
Deny from all
</IfModule>
HTACCESS;
private const INDEX_CONTENT = '<!DOCTYPE html><title></title>';
/**
* Get the absolute default backup directory path.
*/
public static function getDefaultAbsolute(): string
{
return JPATH_ADMINISTRATOR . '/components/com_mokojoombackup/backups';
}
/**
* Resolve a backup directory path. Replaces [DEFAULT_DIR] placeholder,
* then resolves relative paths from JPATH_ROOT.
*
* @param string $dir Raw directory value from profile
*
* @return string Absolute path (may still contain other placeholders)
*/
public static function resolve(string $dir): string
{
if ($dir === '' || $dir === self::PLACEHOLDER) {
$dir = self::getDefaultAbsolute();
} else {
$dir = str_replace(self::PLACEHOLDER, self::getDefaultAbsolute(), $dir);
}
if ($dir !== '' && ($dir[0] === '/' || preg_match('#^[A-Za-z]:[/\\\\]#', $dir))) {
return rtrim($dir, '/\\');
}
return JPATH_ROOT . '/' . $dir;
}
/**
* Check whether a resolved path still contains unresolved placeholders.
*/
public static function hasPlaceholders(string $path): bool
{
return (bool) preg_match('/\[.+\]/', $path);
}
/**
* Check whether a resolved absolute path is inside the web root.
*/
public static function isWebAccessible(string $absolutePath): bool
{
$jRoot = realpath(JPATH_ROOT) ?: JPATH_ROOT;
$realDir = realpath($absolutePath) ?: $absolutePath;
return strpos($realDir, $jRoot) === 0;
}
/**
* Create .htaccess and index.html protection files in a directory.
* Only creates files if they don't already exist.
*/
public static function protect(string $dir): void
{
if (!is_dir($dir)) {
return;
}
$htaccess = $dir . '/.htaccess';
if (!is_file($htaccess)) {
if (@file_put_contents($htaccess, self::HTACCESS_CONTENT . "\n") === false) {
error_log('MokoJoomBackup: Could not create .htaccess in: ' . $dir);
}
}
$index = $dir . '/index.html';
if (!is_file($index)) {
if (@file_put_contents($index, self::INDEX_CONTENT) === false) {
error_log('MokoJoomBackup: Could not create index.html in: ' . $dir);
}
}
}
/**
* Ensure the backup directory exists, create it if needed,
* and apply web protection if it's inside the web root.
*
* @return bool True if directory exists and is usable
*/
public static function ensureReady(string $dir): bool
{
if (!is_dir($dir)) {
if (!@mkdir($dir, 0755, true)) {
return false;
}
}
self::protect($dir);
return true;
}
/**
* Parse a newline-separated text field into an array of trimmed, non-empty strings.
*/
public static function parseNewlineList(string $text): array
{
if (empty($text)) {
return [];
}
return array_values(array_filter(
array_map('trim', explode("\n", str_replace("\r", '', $text))),
fn($line) => $line !== ''
));
}
/**
* Derive the log file path from an archive path.
*/
public static function logPathFromArchive(string $archivePath): string
{
return preg_replace('/\.(zip|tar\.gz)$/i', '.log', $archivePath);
}
}
@@ -0,0 +1 @@
<!DOCTYPE html><title></title>
@@ -8,7 +8,7 @@
-->
<extension type="plugin" group="actionlog" method="upgrade">
<name>plg_actionlog_mokojoombackup</name>
<version>01.05.00-rc</version>
<version>01.08.00</version>
<creationDate>2026-06-04</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -8,7 +8,7 @@
-->
<extension type="plugin" group="console" method="upgrade">
<name>plg_console_mokojoombackup</name>
<version>01.05.00-rc</version>
<version>01.08.00</version>
<creationDate>2026-06-04</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -8,7 +8,7 @@
-->
<extension type="plugin" group="content" method="upgrade">
<name>plg_content_mokojoombackup</name>
<version>01.05.00-rc</version>
<version>01.08.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>plg_quickicon_mokojoombackup</name>
<version>01.05.00-rc</version>
<version>01.08.00</version>
<creationDate>2026-06-02</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -8,7 +8,7 @@
-->
<extension type="plugin" group="system" method="upgrade">
<name>plg_system_mokojoombackup</name>
<version>01.05.00-rc</version>
<version>01.08.00</version>
<creationDate>2026-06-02</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -8,7 +8,7 @@
-->
<extension type="plugin" group="task" method="upgrade">
<name>plg_task_mokojoombackup</name>
<version>01.05.00-rc</version>
<version>01.08.00</version>
<creationDate>2026-06-02</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -8,7 +8,7 @@
-->
<extension type="plugin" group="webservices" method="upgrade">
<name>plg_webservices_mokojoombackup</name>
<version>01.05.00-rc</version>
<version>01.08.00</version>
<creationDate>2026-06-02</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
+1 -1
View File
@@ -8,7 +8,7 @@
<extension type="package" method="upgrade">
<name>Package - MokoJoomBackup</name>
<packagename>mokojoombackup</packagename>
<version>01.05.00-rc</version>
<version>01.08.00</version>
<creationDate>2026-06-02</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
+129 -8
View File
@@ -191,24 +191,145 @@ class Pkg_MokoJoomBackupInstallerScript
$db->setQuery($query);
$db->execute();
// Create default backup directory
// Create and protect default backup directory
$backupDir = JPATH_ADMINISTRATOR . '/components/com_mokojoombackup/backups';
if (!is_dir($backupDir)) {
mkdir($backupDir, 0755, true);
// Protect backup directory with .htaccess
file_put_contents($backupDir . '/.htaccess', "# Apache 2.4+\n<IfModule mod_authz_core.c>\n Require all denied\n</IfModule>\n# Apache 2.2\n<IfModule !mod_authz_core.c>\n Order deny,allow\n Deny from all\n</IfModule>\n");
file_put_contents($backupDir . '/index.html', '<!DOCTYPE html><title></title>');
}
if (is_dir($backupDir)) {
$htaccess = $backupDir . '/.htaccess';
if (!is_file($htaccess)) {
file_put_contents($htaccess, "# Apache 2.4+\n<IfModule mod_authz_core.c>\n Require all denied\n</IfModule>\n# Apache 2.2\n<IfModule !mod_authz_core.c>\n Order deny,allow\n Deny from all\n</IfModule>\n");
}
$index = $backupDir . '/index.html';
if (!is_file($index)) {
file_put_contents($index, '<!DOCTYPE html><title></title>');
}
}
// Create default scheduled task — every 30 days, profile 1
$this->createDefaultScheduledTask();
}
if ($type === 'uninstall') {
return;
}
// Sync submenu icons in #__menu (Joomla doesn't update icons on upgrades)
$this->syncMenuIcons();
// Warn if no license key configured (skip on uninstall)
if ($type !== 'uninstall') {
$this->warnMissingLicenseKey();
// Warn if no license key configured
$this->warnMissingLicenseKey();
// Warn if any profile still uses the default backup directory
$this->warnDefaultBackupDir();
// Remind user to review backup profile settings
if ($type === 'install') {
$profileUrl = Route::_('index.php?option=com_mokojoombackup&view=profiles');
Factory::getApplication()->enqueueMessage(
'<strong>Review Your Backup Settings</strong> — '
. 'A default backup profile has been created. Review the profile settings to configure '
. 'backup type, schedule, storage location, and notifications. '
. '<a href="' . $profileUrl . '" class="btn btn-sm btn-primary ms-2">Review Profiles</a>',
'info'
);
}
}
private function warnDefaultBackupDir(): void
{
try {
$db = Factory::getDbo();
$query = $db->getQuery(true)
->select('COUNT(*)')
->from($db->quoteName('#__mokojoombackup_profiles'))
->where($db->quoteName('published') . ' = 1')
->where('(' . $db->quoteName('backup_dir') . ' = ' . $db->quote('administrator/components/com_mokojoombackup/backups')
. ' OR ' . $db->quoteName('backup_dir') . ' = ' . $db->quote('[DEFAULT_DIR]')
. ' OR ' . $db->quoteName('backup_dir') . ' = ' . $db->quote('')
. ' OR ' . $db->quoteName('backup_dir') . ' IS NULL)');
$db->setQuery($query);
if ((int) $db->loadResult() > 0) {
$profileUrl = Route::_('index.php?option=com_mokojoombackup&view=profiles');
Factory::getApplication()->enqueueMessage(
'<strong>Backup Directory Warning</strong> — '
. 'One or more profiles store backups in the default directory inside the web root. '
. 'For better security, configure a backup directory outside the web root. '
. '<a href="' . $profileUrl . '" class="btn btn-sm btn-warning ms-2">Edit Profiles</a>',
'warning'
);
}
} catch (\Throwable $e) {
error_log('MokoJoomBackup: warnDefaultBackupDir() failed: ' . $e->getMessage());
}
}
private function createDefaultScheduledTask(): void
{
try {
$db = Factory::getDbo();
// Check if a MokoJoomBackup task already exists
$query = $db->getQuery(true)
->select('COUNT(*)')
->from($db->quoteName('#__scheduler_tasks'))
->where($db->quoteName('type') . ' = ' . $db->quote('mokojoombackup.run_profile'));
$db->setQuery($query);
if ((int) $db->loadResult() > 0) {
return;
}
$now = date('Y-m-d H:i:s');
$task = (object) [
'title' => 'MokoJoomBackup — Monthly Full Backup',
'type' => 'mokojoombackup.run_profile',
'execution_rules' => json_encode([
'rule-type' => 'interval-days',
'interval-days' => '30',
'exec-day' => '1',
'exec-time' => '03:00:00',
]),
'cron_rules' => json_encode([
'type' => 'interval',
'exp' => 'P30D',
]),
'state' => 1,
'params' => json_encode([
'profile_id' => 1,
'individual_log' => true,
'log_file' => '',
'notifications' => [
'success_mail' => '0',
'failure_mail' => '1',
'notification_failure_groups' => ['8'],
'fatal_failure_mail' => '1',
'notification_fatal_groups' => ['8'],
'orphan_mail' => '0',
],
]),
'priority' => 0,
'ordering' => 0,
'cli_exclusive' => 0,
'note' => '',
'created' => $now,
'created_by' => Factory::getApplication()->getIdentity()->id ?? 0,
'next_execution' => date('Y-m-d 03:00:00', strtotime('+1 day')),
];
$db->insertObject('#__scheduler_tasks', $task);
} catch (\Throwable $e) {
error_log('MokoJoomBackup: createDefaultScheduledTask() failed: ' . $e->getMessage());
}
}