Compare commits

..

1 Commits

Author SHA1 Message Date
gitea-actions[bot] 4aec41d21f chore(version): pre-release bump to 02.54.01-dev [skip ci] 2026-07-04 20:26:30 +00:00
38 changed files with 1150 additions and 333 deletions
+7 -35
View File
@@ -95,7 +95,7 @@ jobs:
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/mokocli
CLONE_URL=https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/MokoCLI.git
CLONE_URL=https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/mokocli.git
git clone --depth 1 --branch main --quiet $CLONE_URL /tmp/mokocli
cd /tmp/mokocli
composer install --no-dev --no-interaction --quiet
@@ -104,39 +104,11 @@ jobs:
- name: Rename branch to rc
run: |
API_BASE="${MOKOGITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
AUTH="Authorization: token ${{ secrets.MOKOGITEA_TOKEN }}"
FROM="${{ github.event.pull_request.head.ref || 'dev' }}"
PR="${{ github.event.pull_request.number }}"
# Resolve the source branch HEAD commit.
SRC_JSON=$(curl -sf -H "$AUTH" "${API_BASE}/branches/${FROM}") \
|| { echo "::error::Source branch ${FROM} not found"; exit 1; }
SRC_SHA=$(printf '%s' "$SRC_JSON" | python3 -c "import sys, json; print(json.load(sys.stdin)['commit']['id'])" 2>/dev/null || true)
[ -n "$SRC_SHA" ] || { echo "::error::Could not resolve HEAD of ${FROM}"; exit 1; }
# Point rc at the source commit. If rc already exists (a protected branch that
# cannot be deleted), force-update its ref in place instead of delete+recreate:
# deleting a protected branch fails, which then makes the recreate return HTTP 409.
if curl -sf -o /dev/null -H "$AUTH" "${API_BASE}/branches/rc"; then
echo "rc exists - force-updating to ${FROM} (${SRC_SHA})"
curl -sf -X PATCH -H "$AUTH" -H "Content-Type: application/json" \
"${API_BASE}/git/refs/heads/rc" -d "{\"sha\":\"${SRC_SHA}\",\"force\":true}" \
|| { echo "::error::Failed to force-update rc (CI token needs force-push on the protected rc branch)"; exit 1; }
else
echo "Creating rc from ${FROM}"
curl -sf -X POST -H "$AUTH" -H "Content-Type: application/json" \
"${API_BASE}/branches" -d "{\"new_branch_name\":\"rc\",\"old_branch_name\":\"${FROM}\"}" \
|| { echo "::error::Failed to create rc from ${FROM}"; exit 1; }
fi
# Repoint the PR at rc, then delete the old source branch (non-fatal).
if [ -n "$PR" ]; then
curl -s -X PATCH -H "$AUTH" -H "Content-Type: application/json" \
"${API_BASE}/pulls/${PR}" -d '{"head":"rc"}' >/dev/null || true
fi
curl -s -X DELETE -H "$AUTH" "${API_BASE}/branches/${FROM}" >/dev/null || true
echo "Renamed ${FROM} -> rc"
php ${MOKO_CLI}/branch_rename.php \
--from "${{ github.event.pull_request.head.ref || 'dev' }}" --to rc \
--token "${{ secrets.MOKOGITEA_TOKEN }}" \
--api-base "${MOKOGITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" \
--pr "${{ github.event.pull_request.number }}"
- name: Checkout rc and configure git
run: |
@@ -246,7 +218,7 @@ jobs:
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/mokocli
CLONE_URL=https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/MokoCLI.git
CLONE_URL=https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/mokocli.git
git clone --depth 1 --branch main --quiet $CLONE_URL /tmp/mokocli
cd /tmp/mokocli
composer install --no-dev --no-interaction --quiet
+1 -1
View File
@@ -50,7 +50,7 @@ jobs:
for BRANCH in $BRANCHES; do
# Skip protected branches
case "$BRANCH" in
main|master|dev|develop|rc|beta|alpha|release|release/*|production|stable|staging|hotfix/*|version/*) continue ;;
main|master|develop|release/*|hotfix/*) continue ;;
esac
# Check if branch is merged into main
+1 -1
View File
@@ -5,7 +5,7 @@
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: mokocli.Automation
# VERSION: 01.00.00
# VERSION: 02.54.01
# BRIEF: Auto-create feature branch when an issue is opened
name: "Universal: Issue Branch"
+3 -3
View File
@@ -15,9 +15,9 @@ name: "Universal: Notifications"
on:
workflow_run:
workflows:
- "Universal: Build & Release"
- "Joomla: Extension CI"
- "Generic: Project CI"
- "Joomla Build & Release"
- "Joomla Extension CI"
- "Deploy"
types:
- completed
+8 -10
View File
@@ -47,15 +47,15 @@ jobs:
fi
;;
fix/*|bugfix/*)
if [ "$BASE" != "dev" ] && [ "$BASE" != "main" ]; then
if [ "$BASE" != "dev" ]; then
ALLOWED=false
REASON="Fix branches must target 'dev' or 'main', not '${BASE}'"
REASON="Fix branches must target 'dev', not '${BASE}'"
fi
;;
patch/*)
if [ "$BASE" != "dev" ] && [ "$BASE" != "rc" ] && [ "$BASE" != "main" ]; then
if [ "$BASE" != "dev" ] && [ "$BASE" != "rc" ]; then
ALLOWED=false
REASON="Patch branches must target 'dev', 'rc', or 'main', not '${BASE}'"
REASON="Patch branches must target 'dev' or 'rc', not '${BASE}'"
fi
;;
hotfix/*)
@@ -86,8 +86,7 @@ jobs:
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Allowed merge paths:" >> $GITHUB_STEP_SUMMARY
echo "- \`feature/*\` → \`dev\`" >> $GITHUB_STEP_SUMMARY
echo "- \`fix/*\` → \`dev\` or \`main\`" >> $GITHUB_STEP_SUMMARY
echo "- \`patch/*\` → \`dev\`, \`rc\`, or \`main\`" >> $GITHUB_STEP_SUMMARY
echo "- \`fix/*\` → \`dev\`" >> $GITHUB_STEP_SUMMARY
echo "- \`hotfix/*\` → \`dev\` or \`main\`" >> $GITHUB_STEP_SUMMARY
echo "- \`dev\` → \`main\`" >> $GITHUB_STEP_SUMMARY
echo "- \`rc/*\` → \`main\`" >> $GITHUB_STEP_SUMMARY
@@ -150,12 +149,11 @@ jobs:
- name: Detect platform
id: platform
run: |
# Platform comes from the MokoGitea metadata API (public GET); manifest.xml is no longer used.
API="${GITHUB_SERVER_URL:-https://git.mokoconsulting.tech}/api/v1/repos/${GITHUB_REPOSITORY}/metadata"
PLATFORM="$(curl -sf "$API" 2>/dev/null | python3 -c "import sys, json; print(json.load(sys.stdin).get('platform') or '')" 2>/dev/null || true)"
# Read platform from XML manifest (<platform> tag) or plain text fallback
PLATFORM=$(sed -n 's/.*<platform>\([^<]*\)<\/platform>.*/\1/p' .mokogitea/manifest.xml 2>/dev/null | head -1)
[ -z "$PLATFORM" ] && PLATFORM=$(cat .mokogitea/manifest.xml 2>/dev/null | tr -d '[:space:]')
[ -z "$PLATFORM" ] && PLATFORM="generic"
echo "platform=$PLATFORM" >> "$GITHUB_OUTPUT"
echo "Detected platform: $PLATFORM"
- name: Setup PHP
if: steps.platform.outputs.platform == 'joomla' || steps.platform.outputs.platform == 'dolibarr'
+6 -13
View File
@@ -1,20 +1,13 @@
# Changelog
## [Unreleased]
## [02.56.05] --- 2026-07-05
## [02.54.00] --- 2026-07-04
## [02.56.05] --- 2026-07-05
## [02.53.00] --- 2026-07-04
## [02.56.00] --- 2026-07-04
## [02.53.00] --- 2026-07-04
## [02.52.24] --- 2026-06-30
## [02.56.00] --- 2026-07-04
## [02.56.00] --- 2026-07-04
All notable changes to this project are documented in this file. The format is
based on [Keep a Changelog](https://keepachangelog.com/); versions use
zero-padded `MAJOR.MINOR.PATCH`.
## [02.56.00] --- 2026-07-04
## [02.52.24] --- 2026-06-30
+1 -1
View File
@@ -23,7 +23,7 @@ DEFGROUP: Template-Joomla
INGROUP: Template-Joomla.Documentation
REPO: https://git.mokoconsulting.tech/MokoConsulting/Template-Joomla
PATH: /SECURITY.md
VERSION: 02.56.05
VERSION: 02.54.01
BRIEF: Security vulnerability reporting and handling policy
-->
+37
View File
@@ -0,0 +1,37 @@
<?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;
use Joomla\CMS\Extension\PluginInterface;
use Joomla\CMS\Factory;
use Joomla\CMS\Plugin\PluginHelper;
use Joomla\DI\Container;
use Joomla\DI\ServiceProviderInterface;
use Joomla\Event\DispatcherInterface;
use Joomla\Plugin\WebServices\MokoSuiteBackup\Extension\MokoSuiteBackupWebServices;
return new class () implements ServiceProviderInterface {
public function register(Container $container): void
{
$container->set(
PluginInterface::class,
function (Container $container) {
$plugin = new MokoSuiteBackupWebServices(
$container->get(DispatcherInterface::class),
(array) PluginHelper::getPlugin('webservices', 'mokosuitebackup')
);
$plugin->setApplication(Factory::getApplication());
return $plugin;
}
);
}
};
@@ -206,6 +206,25 @@
</fieldset>
<fieldset name="remote" label="COM_MOKOJOOMBACKUP_FIELDSET_REMOTE">
<field
name="remote_legacy_note"
type="note"
label=""
description="COM_MOKOJOOMBACKUP_REMOTE_LEGACY_NOTE"
class="alert alert-info small"
/>
<field
name="remote_storage"
type="list"
label="COM_MOKOJOOMBACKUP_FIELD_REMOTE_STORAGE"
description="COM_MOKOJOOMBACKUP_FIELD_REMOTE_STORAGE_DESC"
default="none"
>
<option value="none">COM_MOKOJOOMBACKUP_REMOTE_NONE</option>
<option value="sftp">COM_MOKOJOOMBACKUP_REMOTE_SFTP</option>
<option value="google_drive">COM_MOKOJOOMBACKUP_REMOTE_GDRIVE</option>
<option value="s3">COM_MOKOJOOMBACKUP_REMOTE_S3</option>
</field>
<field
name="remote_keep_local"
type="radio"
@@ -217,6 +236,81 @@
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
<!-- SFTP fields (shown when remote_storage = sftp) -->
<field
name="sftp_host"
type="text"
label="COM_MOKOJOOMBACKUP_FIELD_SFTP_HOST"
description="COM_MOKOJOOMBACKUP_FIELD_SFTP_HOST_DESC"
maxlength="255"
showon="remote_storage:sftp"
/>
<field
name="sftp_port"
type="number"
label="COM_MOKOJOOMBACKUP_FIELD_SFTP_PORT"
description="COM_MOKOJOOMBACKUP_FIELD_SFTP_PORT_DESC"
default="22"
min="1"
max="65535"
showon="remote_storage:sftp"
/>
<field
name="sftp_username"
type="text"
label="COM_MOKOJOOMBACKUP_FIELD_SFTP_USERNAME"
description="COM_MOKOJOOMBACKUP_FIELD_SFTP_USERNAME_DESC"
maxlength="255"
showon="remote_storage:sftp"
/>
<field
name="sftp_auth_type"
type="list"
label="COM_MOKOJOOMBACKUP_FIELD_SFTP_AUTH_TYPE"
description="COM_MOKOJOOMBACKUP_FIELD_SFTP_AUTH_TYPE_DESC"
default="key"
showon="remote_storage:sftp"
>
<option value="password">COM_MOKOJOOMBACKUP_SFTP_AUTH_PASSWORD</option>
<option value="key">COM_MOKOJOOMBACKUP_SFTP_AUTH_KEY</option>
<option value="key_passphrase">COM_MOKOJOOMBACKUP_SFTP_AUTH_KEY_PASSPHRASE</option>
</field>
<field
name="sftp_password"
type="password"
label="COM_MOKOJOOMBACKUP_FIELD_SFTP_PASSWORD"
description="COM_MOKOJOOMBACKUP_FIELD_SFTP_PASSWORD_DESC"
maxlength="255"
showon="remote_storage:sftp[AND]sftp_auth_type:password"
/>
<field
name="sftp_key_data"
type="SshKey"
label="COM_MOKOJOOMBACKUP_FIELD_SFTP_KEY"
description="COM_MOKOJOOMBACKUP_FIELD_SFTP_KEY_DESC"
filter="raw"
showon="remote_storage:sftp[AND]sftp_auth_type:key,key_passphrase"
addfieldprefix="Joomla\Component\MokoSuiteBackup\Administrator\Field"
/>
<field
name="sftp_passphrase"
type="password"
label="COM_MOKOJOOMBACKUP_FIELD_SFTP_PASSPHRASE"
description="COM_MOKOJOOMBACKUP_FIELD_SFTP_PASSPHRASE_DESC"
maxlength="255"
showon="remote_storage:sftp[AND]sftp_auth_type:key_passphrase"
/>
<field
name="sftp_path"
type="SftpPath"
label="COM_MOKOJOOMBACKUP_FIELD_SFTP_PATH"
description="COM_MOKOJOOMBACKUP_FIELD_SFTP_PATH_DESC"
default="/backups"
maxlength="512"
showon="remote_storage:sftp"
addfieldprefix="Joomla\Component\MokoSuiteBackup\Administrator\Field"
/>
</fieldset>
<fieldset name="retention" label="COM_MOKOJOOMBACKUP_FIELDSET_RETENTION">
@@ -314,4 +408,157 @@
/>
</fieldset>
<fieldset name="ftp" label="COM_MOKOJOOMBACKUP_FIELDSET_FTP">
<field
name="ftp_host"
type="text"
label="COM_MOKOJOOMBACKUP_FIELD_FTP_HOST"
description="COM_MOKOJOOMBACKUP_FIELD_FTP_HOST_DESC"
maxlength="255"
showon="remote_storage:ftp"
/>
<field
name="ftp_port"
type="number"
label="COM_MOKOJOOMBACKUP_FIELD_FTP_PORT"
description="COM_MOKOJOOMBACKUP_FIELD_FTP_PORT_DESC"
default="21"
min="1"
max="65535"
showon="remote_storage:ftp"
/>
<field
name="ftp_username"
type="text"
label="COM_MOKOJOOMBACKUP_FIELD_FTP_USERNAME"
maxlength="255"
showon="remote_storage:ftp"
/>
<field
name="ftp_password"
type="password"
label="COM_MOKOJOOMBACKUP_FIELD_FTP_PASSWORD"
maxlength="255"
showon="remote_storage:ftp"
/>
<field
name="ftp_path"
type="text"
label="COM_MOKOJOOMBACKUP_FIELD_FTP_PATH"
description="COM_MOKOJOOMBACKUP_FIELD_FTP_PATH_DESC"
default="/backups"
maxlength="512"
showon="remote_storage:ftp"
/>
<field
name="ftp_passive"
type="radio"
label="COM_MOKOJOOMBACKUP_FIELD_FTP_PASSIVE"
description="COM_MOKOJOOMBACKUP_FIELD_FTP_PASSIVE_DESC"
default="1"
class="btn-group"
showon="remote_storage:ftp"
>
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
<field
name="ftp_ssl"
type="radio"
label="COM_MOKOJOOMBACKUP_FIELD_FTP_SSL"
description="COM_MOKOJOOMBACKUP_FIELD_FTP_SSL_DESC"
default="0"
class="btn-group"
showon="remote_storage:ftp"
>
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
</fieldset>
<fieldset name="google_drive" label="COM_MOKOJOOMBACKUP_FIELDSET_GDRIVE">
<field
name="gdrive_client_id"
type="text"
label="COM_MOKOJOOMBACKUP_FIELD_GDRIVE_CLIENT_ID"
description="COM_MOKOJOOMBACKUP_FIELD_GDRIVE_CLIENT_ID_DESC"
maxlength="255"
showon="remote_storage:google_drive"
/>
<field
name="gdrive_client_secret"
type="password"
label="COM_MOKOJOOMBACKUP_FIELD_GDRIVE_CLIENT_SECRET"
maxlength="255"
showon="remote_storage:google_drive"
/>
<field
name="gdrive_refresh_token"
type="text"
label="COM_MOKOJOOMBACKUP_FIELD_GDRIVE_REFRESH_TOKEN"
description="COM_MOKOJOOMBACKUP_FIELD_GDRIVE_REFRESH_TOKEN_DESC"
maxlength="512"
showon="remote_storage:google_drive"
/>
<field
name="gdrive_folder_id"
type="text"
label="COM_MOKOJOOMBACKUP_FIELD_GDRIVE_FOLDER_ID"
description="COM_MOKOJOOMBACKUP_FIELD_GDRIVE_FOLDER_ID_DESC"
maxlength="255"
showon="remote_storage:google_drive"
/>
</fieldset>
<fieldset name="s3" label="COM_MOKOJOOMBACKUP_FIELDSET_S3">
<field
name="s3_endpoint"
type="text"
label="COM_MOKOJOOMBACKUP_FIELD_S3_ENDPOINT"
description="COM_MOKOJOOMBACKUP_FIELD_S3_ENDPOINT_DESC"
maxlength="512"
hint="https://s3.amazonaws.com"
showon="remote_storage:s3"
/>
<field
name="s3_region"
type="text"
label="COM_MOKOJOOMBACKUP_FIELD_S3_REGION"
description="COM_MOKOJOOMBACKUP_FIELD_S3_REGION_DESC"
default="us-east-1"
maxlength="50"
showon="remote_storage:s3"
/>
<field
name="s3_access_key"
type="text"
label="COM_MOKOJOOMBACKUP_FIELD_S3_ACCESS_KEY"
maxlength="255"
showon="remote_storage:s3"
/>
<field
name="s3_secret_key"
type="password"
label="COM_MOKOJOOMBACKUP_FIELD_S3_SECRET_KEY"
maxlength="255"
showon="remote_storage:s3"
/>
<field
name="s3_bucket"
type="text"
label="COM_MOKOJOOMBACKUP_FIELD_S3_BUCKET"
description="COM_MOKOJOOMBACKUP_FIELD_S3_BUCKET_DESC"
maxlength="255"
showon="remote_storage:s3"
/>
<field
name="s3_path"
type="text"
label="COM_MOKOJOOMBACKUP_FIELD_S3_PATH"
description="COM_MOKOJOOMBACKUP_FIELD_S3_PATH_DESC"
default="/backups"
maxlength="512"
showon="remote_storage:s3"
/>
</fieldset>
</form>
@@ -127,10 +127,6 @@ COM_MOKOJOOMBACKUP_CANCEL_SUCCESS="%d stalled backup(s) cancelled."
; Backup status
COM_MOKOJOOMBACKUP_STATUS_WARNING="Warning"
; Delete feedback
COM_MOKOJOOMBACKUP_BACKUPS_N_ITEMS_DELETED="%d backup records deleted."
COM_MOKOJOOMBACKUP_BACKUPS_N_ITEMS_DELETED_1="%d backup record deleted."
; ACL - Cancel
COM_MOKOSUITEBACKUP_ACTION_BACKUP_CANCEL="Cancel Stalled Backup"
COM_MOKOSUITEBACKUP_ACTION_BACKUP_CANCEL_DESC="Allows users to cancel backup records stuck in running status and clean up partial archive files."
@@ -7,7 +7,7 @@
-->
<extension type="component" method="upgrade">
<name>Component - MokoSuiteBackup</name>
<version>02.56.05</version>
<version>02.54.01</version>
<creationDate>2026-06-02</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -11,6 +11,32 @@ CREATE TABLE IF NOT EXISTS `#__mokosuitebackup_profiles` (
`exclude_dirs` TEXT NOT NULL COMMENT 'Newline-separated directory paths to exclude',
`exclude_files` TEXT NOT NULL COMMENT 'Newline-separated filename patterns to exclude',
`exclude_tables` TEXT NOT NULL COMMENT 'Newline-separated table names to exclude',
`remote_storage` VARCHAR(20) NOT NULL DEFAULT 'none' COMMENT 'none, ftp, google_drive, s3',
`ftp_host` VARCHAR(255) NOT NULL DEFAULT '',
`ftp_port` INT(5) UNSIGNED NOT NULL DEFAULT 21,
`ftp_username` VARCHAR(255) NOT NULL DEFAULT '',
`ftp_password` VARCHAR(255) NOT NULL DEFAULT '',
`ftp_path` VARCHAR(512) NOT NULL DEFAULT '/backups',
`ftp_passive` TINYINT(1) NOT NULL DEFAULT 1,
`ftp_ssl` TINYINT(1) NOT NULL DEFAULT 0,
`sftp_host` VARCHAR(255) NOT NULL DEFAULT '',
`sftp_port` INT(5) UNSIGNED NOT NULL DEFAULT 22,
`sftp_username` VARCHAR(255) NOT NULL DEFAULT '',
`sftp_auth_type` VARCHAR(20) NOT NULL DEFAULT 'key',
`sftp_password` VARCHAR(255) NOT NULL DEFAULT '',
`sftp_key_data` MEDIUMTEXT,
`sftp_passphrase` VARCHAR(255) NOT NULL DEFAULT '',
`sftp_path` VARCHAR(512) NOT NULL DEFAULT '/backups',
`gdrive_client_id` VARCHAR(255) NOT NULL DEFAULT '',
`gdrive_client_secret` VARCHAR(255) NOT NULL DEFAULT '',
`gdrive_refresh_token` VARCHAR(512) NOT NULL DEFAULT '',
`gdrive_folder_id` VARCHAR(255) NOT NULL DEFAULT '',
`s3_endpoint` VARCHAR(512) NOT NULL DEFAULT '' COMMENT 'S3 endpoint URL (blank = AWS default)',
`s3_region` VARCHAR(50) NOT NULL DEFAULT 'us-east-1',
`s3_access_key` VARCHAR(255) NOT NULL DEFAULT '',
`s3_secret_key` VARCHAR(255) NOT NULL DEFAULT '',
`s3_bucket` VARCHAR(255) NOT NULL DEFAULT '',
`s3_path` VARCHAR(512) NOT NULL DEFAULT '/backups',
`remote_keep_local` TINYINT(1) NOT NULL DEFAULT 1 COMMENT 'Keep local copy after upload',
`encryption_password` VARCHAR(255) NOT NULL DEFAULT '' COMMENT 'AES-256 archive encryption password (blank = no encryption)',
`include_mokorestore` VARCHAR(20) NOT NULL DEFAULT '0' COMMENT 'MokoRestore mode: 0=none, 1=wrapped, standalone',
@@ -1,31 +0,0 @@
-- Remove legacy single-remote storage columns (superseded by #__mokosuitebackup_remotes).
-- Plain DROP COLUMN (no IF EXISTS): all columns are created by install.mysql.sql and
-- earlier updates, so they always exist here. `DROP COLUMN IF EXISTS` is a MariaDB-only
-- extension and errors on Oracle MySQL 8.x, which Joomla also supports.
ALTER TABLE `#__mokosuitebackup_profiles`
DROP COLUMN `remote_storage`,
DROP COLUMN `ftp_host`,
DROP COLUMN `ftp_port`,
DROP COLUMN `ftp_username`,
DROP COLUMN `ftp_password`,
DROP COLUMN `ftp_path`,
DROP COLUMN `ftp_passive`,
DROP COLUMN `ftp_ssl`,
DROP COLUMN `sftp_host`,
DROP COLUMN `sftp_port`,
DROP COLUMN `sftp_username`,
DROP COLUMN `sftp_auth_type`,
DROP COLUMN `sftp_password`,
DROP COLUMN `sftp_key_data`,
DROP COLUMN `sftp_passphrase`,
DROP COLUMN `sftp_path`,
DROP COLUMN `gdrive_client_id`,
DROP COLUMN `gdrive_client_secret`,
DROP COLUMN `gdrive_refresh_token`,
DROP COLUMN `gdrive_folder_id`,
DROP COLUMN `s3_endpoint`,
DROP COLUMN `s3_region`,
DROP COLUMN `s3_access_key`,
DROP COLUMN `s3_secret_key`,
DROP COLUMN `s3_bucket`,
DROP COLUMN `s3_path`;
@@ -0,0 +1 @@
/* 02.54.01 — no schema changes */
@@ -1 +0,0 @@
/* 02.55.00 — no schema changes */
@@ -1 +0,0 @@
/* 02.55.03 — no schema changes */
@@ -1 +0,0 @@
/* 02.56.00 — no schema changes */
@@ -1,32 +0,0 @@
-- Purge legacy single-remote storage columns from installs where they are still present.
--
-- Background: 02.52.25.sql originally used `DROP COLUMN IF EXISTS`, which is a
-- MariaDB-only extension and errors on Oracle MySQL 8.x. On MySQL 8 installs the
-- migration failed but Joomla still recorded the schema as applied, leaving all 26
-- legacy remote_storage/ftp_*/sftp_*/gdrive_*/s3_* columns stranded on the profiles
-- table. This migration removes them portably.
--
-- It must be safe on BOTH engines AND on installs where the columns are already gone
-- (MariaDB, or anyone who ran the corrected 02.52.25). Plain `DROP COLUMN` errors when
-- a column is absent, and `DROP COLUMN IF EXISTS` errors on MySQL 8 — so neither works
-- unconditionally. We gate the drop on INFORMATION_SCHEMA and build the ALTER via a
-- prepared statement, which runs on MySQL 8 and MariaDB alike. All 26 columns were
-- created and dropped together, so the presence of `remote_storage` gates the whole set.
-- When the columns are absent this is a no-op (`DO 0`).
SET @moko_has_legacy_remote := (
SELECT COUNT(*)
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = '#__mokosuitebackup_profiles'
AND COLUMN_NAME = 'remote_storage'
);
SET @moko_drop_legacy_remote := IF(@moko_has_legacy_remote > 0,
'ALTER TABLE `#__mokosuitebackup_profiles` DROP COLUMN `remote_storage`, DROP COLUMN `ftp_host`, DROP COLUMN `ftp_port`, DROP COLUMN `ftp_username`, DROP COLUMN `ftp_password`, DROP COLUMN `ftp_path`, DROP COLUMN `ftp_passive`, DROP COLUMN `ftp_ssl`, DROP COLUMN `sftp_host`, DROP COLUMN `sftp_port`, DROP COLUMN `sftp_username`, DROP COLUMN `sftp_auth_type`, DROP COLUMN `sftp_password`, DROP COLUMN `sftp_key_data`, DROP COLUMN `sftp_passphrase`, DROP COLUMN `sftp_path`, DROP COLUMN `gdrive_client_id`, DROP COLUMN `gdrive_client_secret`, DROP COLUMN `gdrive_refresh_token`, DROP COLUMN `gdrive_folder_id`, DROP COLUMN `s3_endpoint`, DROP COLUMN `s3_region`, DROP COLUMN `s3_access_key`, DROP COLUMN `s3_secret_key`, DROP COLUMN `s3_bucket`, DROP COLUMN `s3_path`',
'DO 0'
);
PREPARE moko_stmt FROM @moko_drop_legacy_remote;
EXECUTE moko_stmt;
DEALLOCATE PREPARE moko_stmt;
@@ -1 +0,0 @@
/* 02.56.05 — no schema changes */
@@ -1265,6 +1265,184 @@ class AjaxController extends BaseController
return $config;
}
/**
* Browse directories on a remote SFTP server for the path picker.
* POST: task=ajax.browseSftpDir&profile_id=1&path=/some/path
*/
public function browseSftpDir(): void
{
if (!Session::checkToken('get') && !Session::checkToken('post')) {
$this->sendJson(['error' => true, 'message' => 'Invalid token'], 403);
return;
}
if (!$this->app->getIdentity()->authorise('core.manage', 'com_mokosuitebackup')) {
$this->sendJson(['error' => true, 'message' => 'Access denied'], 403);
return;
}
$profileId = $this->input->getInt('profile_id', 0);
if (!$profileId) {
$this->sendJson(['error' => true, 'message' => 'Missing profile_id']);
return;
}
/* Load the profile to get SFTP credentials */
try {
$db = Factory::getDbo();
$query = $db->getQuery(true)
->select('*')
->from($db->quoteName('#__mokosuitebackup_profiles'))
->where($db->quoteName('id') . ' = ' . $profileId);
$db->setQuery($query);
$profile = $db->loadObject();
} catch (\Exception $e) {
$this->sendJson(['error' => true, 'message' => 'Failed to load profile'], 500);
return;
}
if (!$profile) {
$this->sendJson(['error' => true, 'message' => 'Profile not found'], 404);
return;
}
$host = $profile->sftp_host ?? '';
$port = (int) ($profile->sftp_port ?? 22);
$username = $profile->sftp_username ?? '';
$keyData = $profile->sftp_key_data ?? '';
$password = $profile->sftp_password ?? '';
if (empty($host) || empty($username)) {
$this->sendJson(['error' => true, 'message' => 'SFTP host and username must be configured and saved before browsing']);
return;
}
if (empty($keyData) && empty($password)) {
$this->sendJson(['error' => true, 'message' => 'SFTP credentials (key or password) must be configured and saved before browsing']);
return;
}
$requestPath = $this->input->getString('path', '/');
/* Sanitize: must start with / and not contain shell meta-characters */
$requestPath = '/' . ltrim($requestPath, '/');
if (preg_match('/[;&|`$<>]/', $requestPath)) {
$this->sendJson(['error' => true, 'message' => 'Invalid path characters']);
return;
}
$keyFile = null;
try {
/* Write temp key if using key auth (same pattern as SftpUploader) */
if (!empty($keyData)) {
$keyContent = base64_decode($keyData, true);
if ($keyContent === false) {
$keyContent = $keyData;
}
$keyFile = sys_get_temp_dir() . '/mokobackup-sftp-browse-' . bin2hex(random_bytes(8)) . '.key';
if (file_put_contents($keyFile, $keyContent) === false) {
throw new \RuntimeException('Cannot write temporary SSH key file');
}
chmod($keyFile, 0600);
}
/* Build SSH command to list directories */
$escapedPath = escapeshellarg($requestPath);
$remoteCmd = 'ls -1pa ' . $escapedPath . ' 2>/dev/null | grep "/$"';
$parts = ['ssh', '-o', 'StrictHostKeyChecking=no', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=10'];
if ($port !== 22) {
$parts[] = '-p';
$parts[] = (string) $port;
}
if ($keyFile !== null) {
$parts[] = '-i';
$parts[] = escapeshellarg($keyFile);
}
$parts[] = escapeshellarg($username . '@' . $host);
$parts[] = escapeshellarg($remoteCmd);
$cmd = implode(' ', $parts);
$output = [];
$exitCode = 0;
exec($cmd . ' 2>&1', $output, $exitCode);
/* exitCode 1 from grep means no matches (empty dir), which is OK */
if ($exitCode !== 0 && $exitCode !== 1) {
throw new \RuntimeException('SSH command failed (exit ' . $exitCode . '): ' . implode(' ', $output));
}
/* Parse output: each line is a directory name ending with / */
$dirs = [];
foreach ($output as $line) {
$line = trim($line);
if ($line === '' || $line === './' || $line === '../') {
continue;
}
$dirName = rtrim($line, '/');
if ($dirName === '' || $dirName === '.' || $dirName === '..') {
continue;
}
$fullPath = rtrim($requestPath, '/') . '/' . $dirName;
$dirs[] = [
'name' => $dirName,
'path' => $fullPath,
];
}
usort($dirs, fn($a, $b) => strcasecmp($a['name'], $b['name']));
/* Parent path */
$parent = null;
if ($requestPath !== '/') {
$parent = \dirname($requestPath);
if ($parent === '') {
$parent = '/';
}
}
$this->sendJson([
'error' => false,
'current' => $requestPath,
'parent' => $parent,
'dirs' => $dirs,
]);
} catch (\Throwable $e) {
$this->sendJson(['error' => true, 'message' => 'SFTP browse failed: ' . $e->getMessage()]);
} finally {
if ($keyFile !== null && is_file($keyFile)) {
unlink($keyFile);
}
}
}
/**
* Send a JSON response and close the application.
*/
@@ -228,9 +228,24 @@ class AkeebaImporter
'exclude_dirs' => implode("\n", $filters['exclude_dirs']),
'exclude_files' => implode("\n", $filters['exclude_files']),
'exclude_tables' => implode("\n", $filters['exclude_tables']),
// Remote storage is no longer stored on the profile — it lives in
// #__mokosuitebackup_remotes. Akeeba remote settings are not imported;
// re-add remote destinations on the profile's Remote tab after import.
'remote_storage' => $this->mapRemoteStorage($config),
'ftp_host' => $config['engine.postproc.ftp.host'] ?? '',
'ftp_port' => (int) ($config['engine.postproc.ftp.port'] ?? 21),
'ftp_username' => $config['engine.postproc.ftp.user'] ?? '',
'ftp_password' => $config['engine.postproc.ftp.pass'] ?? '',
'ftp_path' => $config['engine.postproc.ftp.initial_directory'] ?? '/backups',
'ftp_passive' => (int) ($config['engine.postproc.ftp.passive_mode'] ?? 1),
'ftp_ssl' => (int) ($config['engine.postproc.ftp.ftps'] ?? 0),
'gdrive_client_id' => $config['engine.postproc.googledrive.client_id'] ?? '',
'gdrive_client_secret' => $config['engine.postproc.googledrive.client_secret'] ?? '',
'gdrive_refresh_token' => $config['engine.postproc.googledrive.refresh_token'] ?? '',
'gdrive_folder_id' => $config['engine.postproc.googledrive.directory'] ?? '',
's3_endpoint' => $config['engine.postproc.s3.custom_endpoint'] ?? '',
's3_region' => $config['engine.postproc.s3.region'] ?? 'us-east-1',
's3_access_key' => $config['engine.postproc.s3.access_key'] ?? ($config['engine.postproc.s3.accesskey'] ?? ''),
's3_secret_key' => $config['engine.postproc.s3.secret_key'] ?? ($config['engine.postproc.s3.secretkey'] ?? ''),
's3_bucket' => $config['engine.postproc.s3.bucket'] ?? '',
's3_path' => $config['engine.postproc.s3.directory'] ?? '/backups',
'remote_keep_local' => 1,
'include_mokorestore' => (int) (($config['akeeba.advanced.embedded_installer'] ?? 'none') !== 'none'),
'published' => 1,
@@ -321,6 +321,48 @@ class BackupEngine
@unlink($archivePath);
$this->log('Local copy removed (remote_keep_local = off)');
}
} else {
/* Backward-compat: fall back to legacy single-remote column */
$remoteStorage = $profile->remote_storage ?? 'none';
if ($remoteStorage !== 'none') {
try {
$this->log('Starting remote upload (' . $remoteStorage . ')...');
$uploader = $this->createUploader($remoteStorage, $profile);
$uploadResult = $uploader->upload($archivePath, $archiveName);
if ($uploadResult['success']) {
$remoteFilename = $uploadResult['remote_path'] ?? $archiveName;
$this->log('Remote upload complete: ' . $uploadResult['message']);
if (!empty($restoreScriptPath) && is_file($restoreScriptPath)) {
$restoreBasename = basename($restoreScriptPath);
$this->log('Uploading standalone ' . $restoreBasename . '...');
$restoreUpload = $uploader->upload($restoreScriptPath, $restoreBasename);
if ($restoreUpload['success']) {
$this->log('Standalone ' . $restoreBasename . ' uploaded');
} else {
$this->log('WARNING: ' . $restoreBasename . ' upload failed: ' . $restoreUpload['message']);
}
}
// Delete local copy if configured
if (empty($profile->remote_keep_local) && is_file($archivePath)) {
@unlink($archivePath);
$this->log('Local copy removed (remote_keep_local = off)');
}
} else {
$uploadFailed = true;
$this->log('WARNING: Remote upload failed: ' . $uploadResult['message']);
$this->log('Local backup is preserved.');
}
} catch (\Throwable $e) {
$uploadFailed = true;
$this->log('WARNING: Remote upload threw an exception: ' . $e->getMessage());
$this->log('Local backup is preserved.');
}
}
}
// Write log file alongside the archive
@@ -488,7 +530,23 @@ class BackupEngine
}
/**
* Create a remote uploader from JSON params.
* Create the appropriate remote uploader based on the storage type.
* Legacy method — used by backward-compat fallback when remotes table
* does not exist.
*/
private function createUploader(string $type, object $profile): RemoteUploaderInterface
{
return match ($type) {
'ftp' => new FtpUploader($profile),
'sftp' => new SftpUploader($profile),
'google_drive' => new GoogleDriveUploader($profile),
's3' => new S3Uploader($profile),
default => throw new \InvalidArgumentException('Unknown remote storage type: ' . $type),
};
}
/**
* Create a remote uploader from JSON params (multi-remote destinations).
*
* Builds a fake profile-like object from the params array so the existing
* uploader constructors work without modification.
@@ -522,18 +580,31 @@ class BackupEngine
/**
* Load enabled remote destinations for a profile from the remotes table.
*
* Returns an empty array when the table does not exist (pre-migration)
* so the caller can fall back to the legacy single-remote column.
*
* @param object $db Database driver
* @param int $profileId Profile ID
*
* @return object[] Array of remote destination rows
*/
private function loadRemoteDestinations(object $db, int $profileId): array
{
$query = $db->getQuery(true)
->select('*')
->from($db->quoteName('#__mokosuitebackup_remotes'))
->where($db->quoteName('profile_id') . ' = ' . (int) $profileId)
->where($db->quoteName('enabled') . ' = 1')
->order($db->quoteName('ordering') . ' ASC');
$db->setQuery($query);
try {
$query = $db->getQuery(true)
->select('*')
->from($db->quoteName('#__mokosuitebackup_remotes'))
->where($db->quoteName('profile_id') . ' = ' . (int) $profileId)
->where($db->quoteName('enabled') . ' = 1')
->order($db->quoteName('ordering') . ' ASC');
$db->setQuery($query);
return $db->loadObjectList() ?: [];
return $db->loadObjectList() ?: [];
} catch (\Throwable $e) {
// Table does not exist yet (pre-migration) — fall back to legacy
return [];
}
}
/**
@@ -77,7 +77,7 @@ class PreflightCheck
$this->checkDiskSpace($profile, $db);
$this->checkRunningBackup($profile, $db);
$this->checkExcludedTables($profile, $db);
$this->checkRemoteCredentials($profile, $db);
$this->checkRemoteCredentials($profile);
return $this->result();
}
@@ -102,8 +102,12 @@ class PreflightCheck
}
}
if (!empty($profile->ntfy_topic) && !extension_loaded('curl')) {
$this->warnings[] = 'ext-curl is not loaded — ntfy notifications will not work';
// 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';
}
}
@@ -276,76 +280,65 @@ class PreflightCheck
}
/**
* Check that remote destination credentials are minimally configured.
* Check that remote storage credentials are minimally configured.
* Does not test the actual connection (too slow for preflight).
*/
private function checkRemoteCredentials(object $profile, object $db): void
private function checkRemoteCredentials(object $profile): void
{
$query = $db->getQuery(true)
->select('*')
->from($db->quoteName('#__mokosuitebackup_remotes'))
->where($db->quoteName('profile_id') . ' = ' . (int) $profile->id)
->where($db->quoteName('enabled') . ' = 1');
$db->setQuery($query);
$remotes = $db->loadObjectList();
$remote = $profile->remote_storage ?? 'none';
if (empty($remotes)) {
if ($remote === 'none') {
return;
}
foreach ($remotes as $remote) {
$params = json_decode($remote->params, true) ?: [];
$label = $remote->title ?: ('Remote #' . $remote->id);
switch ($remote) {
case 'ftp':
if (empty($profile->ftp_host)) {
$this->warnings[] = 'FTP host is not configured — remote upload will fail';
}
switch ($remote->type) {
case 'ftp':
if (empty($params['host'])) {
$this->warnings[] = $label . ': FTP host is not configured — upload will fail';
}
if (empty($profile->ftp_username)) {
$this->warnings[] = 'FTP username is not configured — remote upload will fail';
}
if (empty($params['username'])) {
$this->warnings[] = $label . ': FTP username is not configured — upload will fail';
}
break;
break;
case 's3':
if (empty($profile->s3_bucket)) {
$this->warnings[] = 'S3 bucket is not configured — remote upload will fail';
}
case 's3':
if (empty($params['bucket'])) {
$this->warnings[] = $label . ': S3 bucket is not configured — 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';
}
if (empty($params['access_key']) || empty($params['secret_key'])) {
$this->warnings[] = $label . ': S3 credentials are not configured — upload will fail';
}
break;
break;
case 'sftp':
if (empty($profile->sftp_host)) {
$this->warnings[] = 'SFTP host is not configured — remote upload will fail';
}
case 'sftp':
if (empty($params['host'])) {
$this->warnings[] = $label . ': SFTP host is not configured — upload will fail';
}
if (empty($profile->sftp_username)) {
$this->warnings[] = 'SFTP username is not configured — remote upload will fail';
}
if (empty($params['username'])) {
$this->warnings[] = $label . ': SFTP username is not configured — upload will fail';
}
if (empty($profile->sftp_key_data) && empty($profile->sftp_password)) {
$this->warnings[] = 'SFTP requires either a private key or password — remote upload will fail';
}
if (empty($params['key_data']) && empty($params['password'])) {
$this->warnings[] = $label . ': SFTP requires either a private key or password — upload will fail';
}
break;
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';
}
case 'google_drive':
if (empty($params['client_id']) || empty($params['client_secret'])) {
$this->warnings[] = $label . ': Google Drive OAuth credentials are not configured — upload will fail';
}
if (empty($profile->gdrive_refresh_token)) {
$this->warnings[] = 'Google Drive refresh token is missing — remote upload will fail';
}
if (empty($params['refresh_token'])) {
$this->warnings[] = $label . ': Google Drive refresh token is missing — upload will fail';
}
break;
}
break;
}
}
@@ -69,6 +69,7 @@ class SteppedBackupEngine
$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 = $profile->include_mokorestore ?? '0';
$session->restoreScriptName = $profile->restore_script_name ?? 'restore.php';
$session->remoteKeepLocal = (bool) ($profile->remote_keep_local ?? true);
@@ -152,8 +153,15 @@ class SteppedBackupEngine
$totalSteps += 1; // finalize step
// Determine upload step count: one step per remote destination,
// or one step for legacy single-remote, or zero if no remotes.
$remoteCount = count($session->remoteDestinations);
$totalSteps += $remoteCount;
if ($remoteCount > 0) {
$totalSteps += $remoteCount;
} elseif ($session->remoteStorage !== 'none') {
$totalSteps += 1;
}
$session->totalSteps = $totalSteps;
$session->currentStep = 1;
@@ -413,7 +421,11 @@ class SteppedBackupEngine
$session->currentStep++;
if (!empty($session->remoteDestinations)) {
// Determine next phase: multi-remote, legacy single-remote, or complete
$hasMultiRemote = !empty($session->remoteDestinations);
$hasLegacyRemote = $session->remoteStorage !== 'none';
if ($hasMultiRemote || $hasLegacyRemote) {
$session->phase = 'upload';
} else {
$session->phase = 'complete';
@@ -428,7 +440,11 @@ class SteppedBackupEngine
}
/**
* Upload phase: send archive to one remote destination per call.
* Upload phase: send archive to remote storage.
*
* When multi-remote destinations are configured, each call uploads to
* one destination (one step per remote). When only the legacy
* single-remote column is set, uploads in a single step.
*/
private function stepUpload(SteppedSession $session): void
{
@@ -436,65 +452,133 @@ class SteppedBackupEngine
$remoteFilename = '';
$uploadFailed = false;
$index = $session->remoteIndex;
if (!empty($session->remoteDestinations)) {
// ── Multi-remote path ──────────────────────────────────
$index = $session->remoteIndex;
if ($index >= count($session->remoteDestinations)) {
$session->phase = 'complete';
$session->statusMessage = 'All remote uploads finished';
$this->completeRecord($session);
if ($index >= count($session->remoteDestinations)) {
// All remotes processed — move to complete
$session->phase = 'complete';
$session->statusMessage = 'All remote uploads finished';
$this->completeRecord($session);
return;
}
return;
}
$remote = (object) $session->remoteDestinations[$index];
$remote = (object) $session->remoteDestinations[$index];
try {
$title = $remote->title ?? ('Remote #' . ($index + 1));
$type = $remote->type ?? 'unknown';
$params = json_decode($remote->params ?? '{}', true) ?: [];
try {
$title = $remote->title ?? ('Remote #' . ($index + 1));
$type = $remote->type ?? 'unknown';
$params = json_decode($remote->params ?? '{}', true) ?: [];
$session->log('Uploading to: ' . $title . ' (' . $type . ')...');
$uploader = $this->createUploaderFromParams($type, $params);
$result = $uploader->upload($session->archivePath, $session->archiveName);
$session->log('Uploading to: ' . $title . ' (' . $type . ')...');
$uploader = $this->createUploaderFromParams($type, $params);
$result = $uploader->upload($session->archivePath, $session->archiveName);
if ($result['success']) {
$remoteFilename = $result['remote_path'] ?? $session->archiveName;
$session->log(' Upload complete: ' . $result['message']);
if ($result['success']) {
$remoteFilename = $result['remote_path'] ?? $session->archiveName;
$session->log(' Upload complete: ' . $result['message']);
if (!empty($session->restoreScriptPath) && is_file($session->restoreScriptPath)) {
$uploader->upload($session->restoreScriptPath, basename($session->restoreScriptPath));
if (!empty($session->restoreScriptPath) && is_file($session->restoreScriptPath)) {
$uploader->upload($session->restoreScriptPath, basename($session->restoreScriptPath));
}
} else {
$uploadFailed = true;
$session->log(' WARNING: Upload failed: ' . $result['message']);
}
} else {
} catch (\Throwable $e) {
$uploadFailed = true;
$session->log(' WARNING: Upload failed: ' . $result['message']);
}
} catch (\Throwable $e) {
$uploadFailed = true;
$session->log(' WARNING: Upload exception: ' . $e->getMessage());
}
$session->remoteIndex++;
$session->currentStep++;
$remaining = count($session->remoteDestinations) - $session->remoteIndex;
$session->statusMessage = 'Uploaded to ' . ($remote->title ?? 'remote') . ($remaining > 0 ? ' (' . $remaining . ' remaining)' : '');
if ($session->remoteIndex >= count($session->remoteDestinations)) {
if (!$uploadFailed && !$session->remoteKeepLocal && is_file($session->archivePath)) {
@unlink($session->archivePath);
$session->log('Local copy removed (remote_keep_local = off)');
$session->log(' WARNING: Upload exception: ' . $e->getMessage());
}
$session->remoteIndex++;
$session->currentStep++;
$remaining = count($session->remoteDestinations) - $session->remoteIndex;
$session->statusMessage = 'Uploaded to ' . ($remote->title ?? 'remote') . ($remaining > 0 ? ' (' . $remaining . ' remaining)' : '');
if ($session->remoteIndex >= count($session->remoteDestinations)) {
// All remotes done — delete local if configured and no failures
if (!$uploadFailed && !$session->remoteKeepLocal && is_file($session->archivePath)) {
@unlink($session->archivePath);
$session->log('Local copy removed (remote_keep_local = off)');
}
// Update record with remote filename
$update = (object) [
'id' => $session->recordId,
'remote_filename' => $remoteFilename,
'filesexist' => is_file($session->archivePath) ? 1 : 0,
];
$db->updateObject('#__mokosuitebackup_records', $update, 'id');
$session->phase = 'complete';
$session->statusMessage = $uploadFailed
? 'Backup complete (some remote uploads failed — local archive preserved)'
: 'Backup complete';
$this->completeRecord($session, $uploadFailed);
}
} else {
// ── Legacy single-remote fallback ──────────────────────
try {
// Reload profile for remote settings
$query = $db->getQuery(true)
->select('*')
->from($db->quoteName('#__mokosuitebackup_profiles'))
->where($db->quoteName('id') . ' = ' . $session->profileId);
$db->setQuery($query);
$profile = $db->loadObject();
$uploader = match ($session->remoteStorage) {
'ftp' => new FtpUploader($profile),
'sftp' => new SftpUploader($profile),
'google_drive' => new GoogleDriveUploader($profile),
's3' => new S3Uploader($profile),
default => throw new \InvalidArgumentException('Unknown storage: ' . $session->remoteStorage),
};
$session->log('Starting remote upload (' . $session->remoteStorage . ')...');
$result = $uploader->upload($session->archivePath, $session->archiveName);
if ($result['success']) {
$remoteFilename = $result['remote_path'] ?? $session->archiveName;
$session->log('Remote upload complete: ' . $result['message']);
if (!empty($session->restoreScriptPath) && is_file($session->restoreScriptPath)) {
$restoreBasename = basename($session->restoreScriptPath);
$session->log('Uploading standalone ' . $restoreBasename . '...');
$uploader->upload($session->restoreScriptPath, $restoreBasename);
}
if (!$session->remoteKeepLocal && is_file($session->archivePath)) {
@unlink($session->archivePath);
$session->log('Local copy removed');
}
} else {
$uploadFailed = true;
$session->log('WARNING: Remote upload failed: ' . $result['message']);
$session->log('Local backup is preserved.');
}
} catch (\Throwable $e) {
$uploadFailed = true;
$session->log('WARNING: Remote upload threw an exception: ' . $e->getMessage());
$session->log('Local backup is preserved.');
}
// Update record with remote filename
$update = (object) [
'id' => $session->recordId,
'remote_filename' => $remoteFilename,
'filesexist' => is_file($session->archivePath) ? 1 : 0,
];
$db->updateObject('#__mokosuitebackup_records', $update, 'id');
$session->currentStep++;
$session->phase = 'complete';
$session->statusMessage = $uploadFailed
? 'Backup complete (some remote uploads failed — local archive preserved)'
? 'Backup complete (remote upload failed — local archive preserved)'
: 'Backup complete';
$this->completeRecord($session, $uploadFailed);
}
@@ -782,15 +866,21 @@ class SteppedBackupEngine
*/
private function loadRemoteDestinations(object $db, int $profileId): array
{
$query = $db->getQuery(true)
->select('*')
->from($db->quoteName('#__mokosuitebackup_remotes'))
->where($db->quoteName('profile_id') . ' = ' . (int) $profileId)
->where($db->quoteName('enabled') . ' = 1')
->order($db->quoteName('ordering') . ' ASC');
$db->setQuery($query);
try {
$query = $db->getQuery(true)
->select('*')
->from($db->quoteName('#__mokosuitebackup_remotes'))
->where($db->quoteName('profile_id') . ' = ' . (int) $profileId)
->where($db->quoteName('enabled') . ' = 1')
->order($db->quoteName('ordering') . ' ASC');
$db->setQuery($query);
return $db->loadAssocList() ?: [];
// Use loadAssocList so the data survives JSON serialization in SteppedSession
return $db->loadAssocList() ?: [];
} catch (\Throwable $e) {
// Table does not exist yet (pre-migration) — fall back to legacy
return [];
}
}
/**
@@ -50,6 +50,7 @@ class SteppedSession
public array $excludeDirs = [];
public array $excludeFiles = [];
public array $excludeTables = [];
public string $remoteStorage = 'none';
public string $includeMokoRestore = '0';
public string $restoreScriptName = 'restore.php';
public string $restoreScriptPath = '';
@@ -0,0 +1,253 @@
<?php
/**
* @package MokoSuiteBackup
* @subpackage com_mokosuitebackup
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*
* SFTP remote path field with Browse Remote button and modal directory browser.
*/
namespace Joomla\Component\MokoSuiteBackup\Administrator\Field;
defined('_JEXEC') or die;
use Joomla\CMS\Form\FormField;
class SftpPathField extends FormField
{
protected $type = 'SftpPath';
protected function getInput(): string
{
$value = htmlspecialchars($this->value ?: $this->default, ENT_QUOTES, 'UTF-8');
$id = htmlspecialchars($this->id, ENT_QUOTES, 'UTF-8');
$name = htmlspecialchars($this->name, ENT_QUOTES, 'UTF-8');
return <<<HTML
<div class="input-group">
<input type="text" name="{$name}" id="{$id}" value="{$value}"
class="form-control" maxlength="512"
placeholder="/backups" />
<button type="button" class="btn btn-outline-secondary" id="{$id}_browseBtn"
title="Browse directories on the remote SFTP server">
<span class="icon-folder-open" aria-hidden="true"></span>
Browse Remote
</button>
</div>
<div class="modal fade" id="{$id}_sftpModal" tabindex="-1" aria-labelledby="{$id}_sftpModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="{$id}_sftpModalLabel">
<span class="icon-folder-open" aria-hidden="true"></span>
Browse Remote SFTP Directory
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div id="{$id}_sftpStatus" class="mb-2">
<small class="text-muted">Click "Browse Remote" to connect...</small>
</div>
<div id="{$id}_sftpCurrent" class="mb-2 p-2 bg-light border rounded" style="font-family:monospace; font-size:0.85rem;">
/
</div>
<div id="{$id}_sftpTree" class="border rounded" style="max-height:350px; overflow-y:auto;">
</div>
<div class="mt-2">
<small class="text-muted">
Click a directory to navigate into it. Click "Select This Directory" to use the current path.
<br>SFTP credentials must be saved in the profile before browsing.
</small>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" id="{$id}_sftpSelect">
<span class="icon-checkmark" aria-hidden="true"></span>
Select This Directory
</button>
</div>
</div>
</div>
</div>
<script>
(function() {
var fieldId = '{$id}';
var input = document.getElementById(fieldId);
var browseBtn = document.getElementById(fieldId + '_browseBtn');
var modalEl = document.getElementById(fieldId + '_sftpModal');
var treeEl = document.getElementById(fieldId + '_sftpTree');
var statusEl = document.getElementById(fieldId + '_sftpStatus');
var currentEl = document.getElementById(fieldId + '_sftpCurrent');
var selectBtn = document.getElementById(fieldId + '_sftpSelect');
var currentPath = '/';
function getProfileId() {
var el = document.getElementById('jform_id');
return el ? parseInt(el.value, 10) || 0 : 0;
}
function showModal() {
if (typeof bootstrap !== 'undefined' && bootstrap.Modal) {
var modal = bootstrap.Modal.getOrCreateInstance(modalEl);
modal.show();
}
}
function hideModal() {
if (typeof bootstrap !== 'undefined' && bootstrap.Modal) {
var modal = bootstrap.Modal.getInstance(modalEl);
if (modal) modal.hide();
}
}
/**
* Set the status message using safe DOM methods (no innerHTML).
* @param {string} cssClass - CSS class for the small element
* @param {string} iconClass - Icon CSS class (e.g. 'icon-spinner icon-spin'), or empty
* @param {string} text - Plain text message
*/
function setStatus(cssClass, iconClass, text) {
while (statusEl.firstChild) statusEl.removeChild(statusEl.firstChild);
var small = document.createElement('small');
small.className = cssClass;
if (iconClass) {
var icon = document.createElement('span');
icon.className = iconClass;
icon.setAttribute('aria-hidden', 'true');
small.appendChild(icon);
small.appendChild(document.createTextNode(' '));
}
small.appendChild(document.createTextNode(text));
statusEl.appendChild(small);
}
function loadSftpDir(path) {
currentPath = path;
currentEl.textContent = path;
while (treeEl.firstChild) treeEl.removeChild(treeEl.firstChild);
setStatus('text-muted', 'icon-spinner icon-spin', 'Connecting to remote server...');
var profileId = getProfileId();
if (!profileId) {
setStatus('text-danger', '', 'Please save the profile first so SFTP credentials are available.');
return;
}
var form = new URLSearchParams();
form.append('task', 'ajax.browseSftpDir');
form.append('profile_id', profileId);
form.append('path', path);
var tokenName = Joomla.getOptions('csrf.token') || '';
if (tokenName) form.append(tokenName, '1');
fetch('index.php?option=com_mokosuitebackup&format=json', {
method: 'POST',
body: form,
headers: { 'X-Requested-With': 'XMLHttpRequest' }
})
.then(function(r) {
if (!r.ok) throw new Error('Server error (HTTP ' + r.status + ')');
return r.json();
})
.then(function(data) {
if (data.error) {
setStatus('text-danger', 'icon-warning', data.message || 'Error');
return;
}
var count = data.dirs ? data.dirs.length : 0;
setStatus('text-success', 'icon-publish', 'Connected \u2014 ' + count + ' subdirectories');
currentPath = data.current || path;
currentEl.textContent = currentPath;
renderSftpTree(data);
})
.catch(function(err) {
setStatus('text-danger', 'icon-warning', err.message);
});
}
function renderSftpTree(data) {
while (treeEl.firstChild) treeEl.removeChild(treeEl.firstChild);
var list = document.createElement('div');
list.className = 'list-group list-group-flush';
/* Parent / back button */
if (data.parent !== null && data.parent !== undefined) {
var up = document.createElement('a');
up.href = '#';
up.className = 'list-group-item list-group-item-action py-1';
var upIcon = document.createElement('span');
upIcon.className = 'icon-arrow-up-4';
upIcon.setAttribute('aria-hidden', 'true');
up.appendChild(upIcon);
up.appendChild(document.createTextNode(' .. (parent directory)'));
up.addEventListener('click', function(e) {
e.preventDefault();
loadSftpDir(data.parent);
});
list.appendChild(up);
}
/* Directory entries */
var dirs = data.dirs || [];
dirs.forEach(function(dir) {
var item = document.createElement('a');
item.href = '#';
item.className = 'list-group-item list-group-item-action py-1';
var folderIcon = document.createElement('span');
folderIcon.className = 'icon-folder';
folderIcon.setAttribute('aria-hidden', 'true');
item.appendChild(folderIcon);
item.appendChild(document.createTextNode(' ' + dir.name));
item.addEventListener('click', function(e) {
e.preventDefault();
loadSftpDir(dir.path);
});
/* Double-click to select and close */
item.addEventListener('dblclick', function(e) {
e.preventDefault();
input.value = dir.path;
input.dispatchEvent(new Event('change', { bubbles: true }));
hideModal();
});
list.appendChild(item);
});
if (dirs.length === 0) {
var empty = document.createElement('div');
empty.className = 'list-group-item text-muted py-2';
empty.textContent = '(no subdirectories)';
list.appendChild(empty);
}
treeEl.appendChild(list);
}
/* Browse button click */
browseBtn.addEventListener('click', function(e) {
e.preventDefault();
var startPath = input.value.trim() || '/';
showModal();
loadSftpDir(startPath);
});
/* Select button — use the current directory */
selectBtn.addEventListener('click', function(e) {
e.preventDefault();
input.value = currentPath;
input.dispatchEvent(new Event('change', { bubbles: true }));
hideModal();
});
})();
</script>
HTML;
}
}
@@ -17,7 +17,6 @@ use Joomla\CMS\Session\Session;
HTMLHelper::_('behavior.formvalidator');
HTMLHelper::_('behavior.keepalive');
HTMLHelper::_('bootstrap.modal');
$profileId = (int) $this->item->id;
$token = Session::getFormToken();
@@ -67,6 +66,7 @@ $token = Session::getFormToken();
<?php echo HTMLHelper::_('uitab.addTab', 'profileTab', 'remote', Text::_('COM_MOKOJOOMBACKUP_TAB_REMOTE')); ?>
<div class="row">
<div class="col-lg-12">
<?php // ---- Remote Destinations (multi-remote) ---- ?>
<?php if ($profileId): ?>
<div id="mokoRemoteDestinations" class="mb-4">
<div class="d-flex justify-content-between align-items-center mb-3">
@@ -98,13 +98,20 @@ $token = Session::getFormToken();
<?php echo Text::_('COM_MOKOJOOMBACKUP_REMOTE_NONE_CONFIGURED'); ?>
</p>
</div>
<?php else: ?>
<div class="alert alert-info">
<?php echo Text::_('COM_MOKOJOOMBACKUP_REMOTE_SAVE_FIRST'); ?>
</div>
<hr>
<?php endif; ?>
<?php echo $this->form->renderFieldset('remote'); ?>
<?php // ---- Legacy single-remote fields ---- ?>
<div id="legacyRemoteFields">
<div class="alert alert-info small" id="legacyRemoteNote" style="display:none;">
<span class="icon-info-circle" aria-hidden="true"></span>
<?php echo Text::_('COM_MOKOJOOMBACKUP_REMOTE_LEGACY_NOTE'); ?>
</div>
<?php echo $this->form->renderFieldset('remote'); ?>
<?php echo $this->form->renderFieldset('ftp'); ?>
<?php echo $this->form->renderFieldset('google_drive'); ?>
<?php echo $this->form->renderFieldset('s3'); ?>
</div>
</div>
</div>
<?php echo HTMLHelper::_('uitab.endTab'); ?>
@@ -274,12 +281,9 @@ document.addEventListener('DOMContentLoaded', function() {
const tbody = document.getElementById('remoteDestBody');
const emptyMsg = document.getElementById('remoteDestEmpty');
const loadingTr = document.getElementById('remoteDestLoading');
const modalEl = document.getElementById('remoteModal');
// Lazy: resolve the Bootstrap modal at click-time. Bootstrap loads as a
// deferred ES module, so `bootstrap` is not defined yet at DOMContentLoaded;
// referencing it here would throw and abort the whole handler (leaving the
// table stuck at "Loading…" and the Add button unbound).
const getModal = () => bootstrap.Modal.getOrCreateInstance(modalEl);
const legacy = document.getElementById('legacyRemoteFields');
const legacyNote = document.getElementById('legacyRemoteNote');
const modal = new bootstrap.Modal(document.getElementById('remoteModal'));
// Type badge colours
const typeBadge = {sftp: 'bg-primary', s3: 'bg-warning text-dark', google_drive: 'bg-success'};
@@ -333,10 +337,14 @@ document.addEventListener('DOMContentLoaded', function() {
if (!remotesData.length) {
emptyMsg.style.display = '';
legacy.style.display = '';
legacyNote.style.display = 'none';
return;
}
emptyMsg.style.display = 'none';
legacy.style.display = 'none';
legacyNote.style.display = 'block';
remotesData.forEach(function(item) {
const tr = document.createElement('tr');
@@ -499,8 +507,8 @@ document.addEventListener('DOMContentLoaded', function() {
const fields = configFields[item.type] || [];
fields.forEach(function(f) {
const el = document.getElementById('remoteCfg_' + prefix + f);
if (el && item.params && item.params[f] !== undefined) {
el.value = item.params[f];
if (el && item.config && item.config[f] !== undefined) {
el.value = item.config[f];
}
});
}
@@ -512,7 +520,7 @@ document.addEventListener('DOMContentLoaded', function() {
}
updateTypeFields();
getModal().show();
modal.show();
}
// ---- Type selector toggles field visibility ----
@@ -592,7 +600,7 @@ document.addEventListener('DOMContentLoaded', function() {
return;
}
getModal().hide();
modal.hide();
loadRemotes();
})
.catch(() => {
@@ -8,7 +8,7 @@
-->
<extension type="module" client="administrator" method="upgrade">
<name>Module - MokoSuiteBackup - cPanel</name>
<version>02.56.05</version>
<version>02.54.01</version>
<creationDate>2026-06-23</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -7,7 +7,7 @@
-->
<extension type="plugin" group="actionlog" method="upgrade">
<name>Action Log - MokoSuiteBackup</name>
<version>02.56.05</version>
<version>02.54.01</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>02.56.05</version>
<version>02.54.01</version>
<creationDate>2026-06-04</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -7,7 +7,7 @@
-->
<extension type="plugin" group="content" method="upgrade">
<name>Content - MokoSuiteBackup</name>
<version>02.56.05</version>
<version>02.54.01</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>02.56.05</version>
<version>02.54.01</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>02.56.05</version>
<version>02.54.01</version>
<creationDate>2026-06-02</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -7,7 +7,7 @@
-->
<extension type="plugin" group="task" method="upgrade">
<name>Task - MokoSuiteBackup</name>
<version>02.56.05</version>
<version>02.54.01</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>02.56.05</version>
<version>02.54.01</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 - MokoSuiteBackup</name>
<packagename>mokosuitebackup</packagename>
<version>02.56.05</version>
<version>02.54.01</version>
<creationDate>2026-06-02</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
+60 -55
View File
@@ -12,7 +12,6 @@ defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\Installer\InstallerAdapter;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Log\Log;
use Joomla\CMS\Router\Route;
class Pkg_MokoSuiteBackupInstallerScript
@@ -74,25 +73,22 @@ class Pkg_MokoSuiteBackupInstallerScript
/* Save download key before Joomla re-registers the update site */
if ($type === 'update') {
$this->backupDownloadKey();
$this->preflight_saveKey();
}
return true;
}
/**
* The download key cached during preflight so it survives an update.
* Called before install/update to preserve the download key.
*
* Joomla re-registers update sites from the manifest on every update,
* which can reset the extra_query (download key). We save it here
* and restore it in postflight.
*/
private ?string $savedDownloadKey = null;
/**
* Cache the existing download key from the update sites table before update runs.
*
* Joomla re-registers update sites from the manifest on every update, which
* can reset the extra_query (download key). We save it here and restore it
* in postflight.
*/
private function backupDownloadKey(): void
public function preflight_saveKey(): void
{
try {
$db = Factory::getDbo();
@@ -112,16 +108,19 @@ class Pkg_MokoSuiteBackupInstallerScript
->where($db->quoteName('e.element') . ' = ' . $db->quote('pkg_mokosuitebackup'))
->where($db->quoteName('e.type') . ' = ' . $db->quote('package'))
->setLimit(1);
$db->setQuery($query);
$extraQuery = (string) $db->loadResult();
$key = $db->loadResult();
if (!empty($extraQuery)) {
parse_str($extraQuery, $output);
$this->savedDownloadKey = $output['dlid'] ?? $extraQuery;
if (!empty($key)) {
$this->savedDownloadKey = $key;
}
} catch (\Exception $e) {
Log::add('MokoSuiteBackup: Could not backup download key: ' . $e->getMessage(), Log::WARNING, 'jerror');
error_log('MokoSuiteBackup: Could not save download key: ' . $e->getMessage());
Factory::getApplication()->enqueueMessage(
'MokoSuiteBackup could not preserve your download/license key before the update. '
. 'Please verify your license key is still configured in System &rarr; Update Sites after this update completes.',
'warning'
);
}
}
@@ -139,8 +138,8 @@ class Pkg_MokoSuiteBackupInstallerScript
return;
}
/* Restore the download key preserved before the update re-registered the site */
if ($type === 'update') {
/* Restore download key if it was saved before update */
if ($this->savedDownloadKey !== null) {
$this->restoreDownloadKey();
}
@@ -169,17 +168,14 @@ class Pkg_MokoSuiteBackupInstallerScript
/* Sync submenu icons in #__menu (Joomla doesn't update icons on upgrades) */
$this->syncMenuIcons();
/* Warn if no license key configured */
$this->warnMissingLicenseKey();
/* Migrate profiles with old default backup_dir values to [DEFAULT_DIR] placeholder */
$this->migrateDefaultBackupDir();
/* Install completion notice (install and update) */
$this->installSuccessful();
/* Remind user to review backup profile settings */
if ($type === 'install') {
/* Fresh install never carries a download key — prompt for one */
$this->warnMissingLicenseKey();
/* Remind user to review backup profile settings */
$profileUrl = Route::_('index.php?option=com_mokosuitebackup&view=profiles');
Factory::getApplication()->enqueueMessage(
@@ -644,57 +640,66 @@ class Pkg_MokoSuiteBackupInstallerScript
->where($db->quoteName('e.element') . ' = ' . $db->quote('pkg_mokosuitebackup'))
->where($db->quoteName('e.type') . ' = ' . $db->quote('package'))
->setLimit(1);
$db->setQuery($query);
$updateSiteId = (int) $db->loadResult();
if ($updateSiteId > 0 && !empty($this->savedDownloadKey)) {
if ($updateSiteId > 0) {
$query = $db->getQuery(true)
->update($db->quoteName('#__update_sites'))
->set($db->quoteName('extra_query') . ' = ' . $db->quote('dlid=' . $this->savedDownloadKey))
->set($db->quoteName('extra_query') . ' = ' . $db->quote($this->savedDownloadKey))
->where($db->quoteName('update_site_id') . ' = ' . $updateSiteId);
$db->setQuery($query);
$db->execute();
}
} catch (\Exception $e) {
Log::add('MokoSuiteBackup: Could not restore download key: ' . $e->getMessage(), Log::WARNING, 'jerror');
error_log('MokoSuiteBackup: Could not restore download key: ' . $e->getMessage());
Factory::getApplication()->enqueueMessage(
'<h4>MokoSuiteBackup</h4>'
. '<p>Your download/license key could not be preserved during the update.</p>'
. '<p>Please re-enter it in the <a class="btn btn-sm btn-warning ms-2" href="index.php?option=com_installer&view=updatesites&filter[search]=pkg_mokosuitebackup">Update Sites</a> manager to continue receiving updates.</p>',
'MokoSuiteBackup: Your download/license key could not be preserved during the update. '
. 'Please re-enter it in the Update Sites configuration to continue receiving updates.',
'warning'
);
}
}
/**
* Show post-install license key prompt.
*/
private function warnMissingLicenseKey(): void
{
try {
try
{
$db = Factory::getDbo();
$db->setQuery(
$db->getQuery(true)
->select([$db->quoteName('update_site_id'), $db->quoteName('extra_query')])
->from($db->quoteName('#__update_sites'))
->where('(' . $db->quoteName('name') . ' LIKE ' . $db->quote('%MokoSuiteBackup%') . ' OR ' . $db->quoteName('location') . ' LIKE ' . $db->quote('%MokoSuiteBackup%') . ')')
->setLimit(1)
);
$site = $db->loadObject();
if ($site)
{
$eq = (string) ($site->extra_query ?? '');
if (!empty($eq) && strpos($eq, 'dlid=') !== false) { parse_str($eq, $p); if (!empty($p['dlid'])) { return; } }
$editUrl = 'index.php?option=com_installer&task=updatesite.edit&update_site_id=' . (int) $site->update_site_id;
}
else
{
$editUrl = 'index.php?option=com_installer&view=updatesites';
}
Factory::getApplication()->enqueueMessage(
'<h4>MokoSuiteBackup License Key Required</h4>'
. '<p>A download/license key (DLID) is required to receive updates.</p>'
. '<p>Enter your key in the <a class="btn btn-sm btn-warning ms-2" href="index.php?option=com_installer&view=updatesites&filter[search]=pkg_mokosuitebackup">Update Sites</a> manager '
. 'or contact <a class="btn btn-sm btn-warning ms-2" href="https://mokoconsulting.tech/support" target="_blank" rel="noopener">Moko Consulting Support</a> to obtain one.</p>',
'<strong>Moko Consulting License Key Required</strong> — '
. 'No download key is configured. Updates will not be available until a valid license key is entered. '
. '<a href="' . $editUrl . '" class="btn btn-sm btn-warning ms-2">Enter License Key</a>',
'warning'
);
} catch (\Exception $e) {}
}
/**
* Show install successful prompt.
*/
private function installSuccessful(): void
{
try {
}
catch (\Exception $e) {
error_log('MokoSuiteBackup: License key check failed: ' . $e->getMessage());
Factory::getApplication()->enqueueMessage(
'<h4>MokoSuiteBackup installed successfully!</h4>',
'info'
'MokoSuiteBackup could not verify your license key status. '
. 'Please check System &rarr; Update Sites to ensure a valid license key is configured.',
'warning'
);
} catch (\Exception $e) {}
}
}
}