Compare commits

...

47 Commits

Author SHA1 Message Date
jmiller d780e10a2f chore: sync notify.yml from Template-Generic [skip ci] 2026-07-05 00:06:25 +00:00
jmiller b2770df80f chore: sync issue-branch.yml from Template-Generic [skip ci] 2026-07-05 00:06:23 +00:00
jmiller 1c643b097f chore: sync cleanup.yml from Template-Generic [skip ci] 2026-07-05 00:06:21 +00:00
gitea-actions[bot] ee293e8038 chore: promote changelog [Unreleased] → [02.56.05] 2026-07-05 00:03:28 +00:00
gitea-actions[bot] 8e3e473426 chore(release): build 02.56.05 [skip ci] 2026-07-05 00:03:11 +00:00
jmiller 2a7fb74ef9 Merge pull request 'fix(sql): purge stranded legacy remote-storage columns (MySQL 8 safe)' (#210) from fix/purge-legacy-remote-columns into main
Sync Workflows to Repos / sync (push) Failing after 3s
2026-07-05 00:02:28 +00:00
gitea-actions[bot] c7a57116a7 chore(version): pre-release bump to 02.56.05-dev [skip ci]
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Branch Cleanup / Delete merged branch (pull_request) Successful in 3s
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Universal: Build & Release / Build & Release Pipeline (pull_request) Successful in 44s
Universal: Workflow Sync Trigger / Sync workflows to live repos (pull_request) Successful in 9m41s
2026-07-05 00:02:14 +00:00
jmiller 6a62868881 fix(sql): purge stranded legacy remote-storage columns (MySQL 8 safe)
Universal: PR Check / Branch Policy (pull_request) Successful in 2s
Joomla: Extension CI / Release Readiness Check (pull_request) Failing after 8s
Universal: PR Check / Secret Scan (pull_request) Successful in 9s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 34s
Universal: PR Check / Validate PR (pull_request) Failing after 15s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Generic: Repo Health / Access control (pull_request) Successful in 2s
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 15s
Universal: Build & Release / Promote to RC (pull_request) Failing after 17s
Universal: Build & Release / Build & Release Pipeline (pull_request) Has been skipped
Generic: Project CI / Lint & Validate (pull_request) Successful in 1m2s
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 1m6s
Generic: Project CI / Tests (pull_request) Has been cancelled
Joomla: Extension CI / Tests (PHP 8.2) (pull_request) Has been cancelled
Joomla: Extension CI / Tests (PHP 8.3) (pull_request) Has been cancelled
Joomla: Extension CI / PHPStan Analysis (pull_request) Has been cancelled
Joomla: Extension CI / Build RC Pre-Release (pull_request) Has been cancelled
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
Generic: Repo Health / Scripts governance (pull_request) Has been cancelled
Generic: Repo Health / Repository health (pull_request) Has been cancelled
Generic: Repo Health / Report: Scripts Governance (pull_request) Has been cancelled
Generic: Repo Health / Report: Repository Health (pull_request) Has been cancelled
02.52.25.sql used `DROP COLUMN IF EXISTS` (MariaDB-only), which errors on
Oracle MySQL 8.x — so on MySQL 8 installs the 26 legacy remote_storage/ftp_*/
sftp_*/gdrive_*/s3_* columns were never dropped, yet Joomla recorded the schema
as applied (confirmed on suite.dev: MySQL 8.0.41, all 26 columns still present,
0 profiles with legacy remote data).

New 02.56.01 migration removes them portably: an INFORMATION_SCHEMA-gated
prepared-statement ALTER that drops all 26 columns where present (plain
DROP COLUMN, valid on MySQL 8 + MariaDB) and is a no-op where already gone
(so it's safe on already-migrated installs too). Validated non-destructively
against suite.dev (gate=1, ALTER PREPAREs cleanly).

Claude-Session: https://claude.ai/code/session_01WbGBN9VyRK61zczYWcCQ2i
2026-07-04 19:01:50 -05:00
jmiller 0926e82465 chore: sync auto-release.yml from Template-Generic [skip ci] 2026-07-04 23:28:45 +00:00
gitea-actions[bot] e37a52df4e chore: promote changelog [Unreleased] → [02.56.00] 2026-07-04 22:01:21 +00:00
gitea-actions[bot] a7611d17d9 chore(release): build 02.56.00 [skip ci] 2026-07-04 22:01:12 +00:00
jmiller 183b2d2d6f Merge pull request 'fix: remote destinations tab broken (Add Destination + stuck loading)' (#208) from fix/remote-destinations-modal into main 2026-07-04 22:00:44 +00:00
jmiller 4ca9e23630 fix(profile): remote destinations tab broken — lazy-init Bootstrap modal
Joomla: Extension CI / Release Readiness Check (pull_request) Failing after 7s
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 11s
Universal: PR Check / Branch Policy (pull_request) Successful in 3s
Universal: PR Check / Secret Scan (pull_request) Successful in 10s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Universal: PR Check / Validate PR (pull_request) Failing after 14s
Generic: Repo Health / Access control (pull_request) Successful in 3s
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 14s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 39s
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Universal: Build & Release / Build & Release Pipeline (pull_request) Has been skipped
Generic: Project CI / Lint & Validate (pull_request) Successful in 45s
Generic: Project CI / Tests (pull_request) Has been cancelled
Joomla: Extension CI / Tests (PHP 8.2) (pull_request) Has been cancelled
Joomla: Extension CI / Tests (PHP 8.3) (pull_request) Has been cancelled
Joomla: Extension CI / PHPStan Analysis (pull_request) Has been cancelled
Joomla: Extension CI / Build RC Pre-Release (pull_request) Has been cancelled
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
Generic: Repo Health / Scripts governance (pull_request) Has been cancelled
Generic: Repo Health / Repository health (pull_request) Has been cancelled
Generic: Repo Health / Report: Scripts Governance (pull_request) Has been cancelled
Generic: Repo Health / Report: Repository Health (pull_request) Has been cancelled
The remote destinations table stayed stuck at "Loading…" and the "Add
Destination" button did nothing. Root cause: the profile-edit DOMContentLoaded
handler instantiated the Bootstrap modal eagerly at the top
(`bootstrap.Modal.getOrCreateInstance`). In Joomla 6, Bootstrap loads as a
deferred ES module, so `bootstrap` is undefined at DOMContentLoaded — the
reference threw a ReferenceError that aborted the entire handler, so
loadRemotes() never ran and the Add button was never bound. (The purge and
stepped-backup modals worked because they resolve the modal lazily inside
click handlers.)

- Resolve the modal lazily via getModal() at click-time (matches the working modals)
- Explicitly load the bootstrap.modal web asset so window.bootstrap.Modal is registered

Claude-Session: https://claude.ai/code/session_01WbGBN9VyRK61zczYWcCQ2i
2026-07-04 17:00:15 -05:00
jmiller d0ce09abf0 chore: sync issue-branch.yml from Template-Generic [skip ci] 2026-07-04 21:52:23 +00:00
gitea-actions[bot] 1dde736225 chore: promote changelog [Unreleased] → [02.56.00] 2026-07-04 21:51:30 +00:00
gitea-actions[bot] 6074aa55ae chore(release): build 02.56.00 [skip ci] 2026-07-04 21:51:20 +00:00
jmiller 939964bff9 Merge pull request 'chore: remove stray root services/provider.php' (#207) from chore/remove-stray-provider into main
Sync Workflows to Repos / sync (push) Failing after 3s
2026-07-04 21:50:47 +00:00
gitea-actions[bot] 5363a85b92 chore(version): pre-release bump to 02.55.03-dev [skip ci]
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Branch Cleanup / Delete merged branch (pull_request) Successful in 2s
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Universal: Build & Release / Build & Release Pipeline (pull_request) Successful in 30s
Universal: Workflow Sync Trigger / Sync workflows to live repos (pull_request) Successful in 4m44s
2026-07-04 21:50:36 +00:00
jmiller c12d207a96 chore: remove stray root services/provider.php
Universal: PR Check / Branch Policy (pull_request) Successful in 3s
Joomla: Extension CI / Release Readiness Check (pull_request) Failing after 6s
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 12s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Generic: Repo Health / Access control (pull_request) Successful in 3s
Universal: PR Check / Secret Scan (pull_request) Successful in 11s
Generic: Project CI / Lint & Validate (pull_request) Successful in 18s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 34s
Universal: Build & Release / Promote to RC (pull_request) Failing after 12s
Universal: Build & Release / Build & Release Pipeline (pull_request) Has been skipped
Universal: PR Check / Validate PR (pull_request) Failing after 41s
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 45s
Generic: Project CI / Tests (pull_request) Has been cancelled
Joomla: Extension CI / Tests (PHP 8.2) (pull_request) Has been cancelled
Joomla: Extension CI / Tests (PHP 8.3) (pull_request) Has been cancelled
Joomla: Extension CI / PHPStan Analysis (pull_request) Has been cancelled
Joomla: Extension CI / Build RC Pre-Release (pull_request) Has been cancelled
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
Generic: Repo Health / Scripts governance (pull_request) Has been cancelled
Generic: Repo Health / Repository health (pull_request) Has been cancelled
Generic: Repo Health / Report: Scripts Governance (pull_request) Has been cancelled
Generic: Repo Health / Report: Repository Health (pull_request) Has been cancelled
The canonical webservices provider lives in
source/packages/plg_webservices_mokosuitebackup/services/provider.php.
This root-level copy is an orphaned duplicate — nothing references it and
it is not part of any packaged extension. Recovered from a stash of
previously-set-aside cleanup.

Claude-Session: https://claude.ai/code/session_01WbGBN9VyRK61zczYWcCQ2i
2026-07-04 16:50:11 -05:00
jmiller ee11ec8ef7 Merge pull request 'docs(changelog): document 02.53.00–02.55.00 releases' (#205) from docs/changelog-update into main 2026-07-04 20:54:58 +00:00
jmiller 0c9d9a6336 docs(changelog): document 02.53.00–02.55.00 and pending remote-storage removal [skip ci]
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Branch Cleanup / Delete merged branch (pull_request) Successful in 3s
Universal: Workflow Sync Trigger / Sync workflows to live repos (pull_request) Successful in 3m24s
Populate the empty CI-generated version sections and dedupe headers:
- 02.53.00: short-name branding
- 02.54.00: per-profile retention enforcement, Purge button fix, manifest naming convention
- 02.55.00: installer licensing + install-completion pattern
- Unreleased: legacy single-remote storage removal (#204)

Claude-Session: https://claude.ai/code/session_01WbGBN9VyRK61zczYWcCQ2i
2026-07-04 15:54:17 -05:00
jmiller 882cb2c48c chore: sync issue-branch.yml from Template-Generic [skip ci] 2026-07-04 20:40:14 +00:00
gitea-actions[bot] 0578f2deed chore: promote changelog [Unreleased] → [02.55.00] 2026-07-04 20:37:49 +00:00
gitea-actions[bot] eecb8c8c72 chore(release): build 02.55.00 [skip ci] 2026-07-04 20:37:39 +00:00
jmiller f0fa645e51 Merge pull request 'Remove legacy single-remote storage in favor of remotes table' (#204) from fix/remote-storage-cleanup into main
Sync Workflows to Repos / sync (push) Failing after 4s
2026-07-04 20:36:31 +00:00
jmiller 6f7dc6766d Merge CI version bump (superseded by main's 02.55.00)
Universal: PR Check / Branch Policy (pull_request) Successful in 2s
Joomla: Extension CI / Release Readiness Check (pull_request) Failing after 7s
Universal: PR Check / Secret Scan (pull_request) Successful in 12s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 41s
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Generic: Repo Health / Access control (pull_request) Successful in 3s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Branch Cleanup / Delete merged branch (pull_request) Successful in 3s
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Generic: Project CI / Lint & Validate (pull_request) Successful in 1m11s
Universal: PR Check / Validate PR (pull_request) Failing after 1m5s
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 1m14s
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 1m8s
Universal: Build & Release / Build & Release Pipeline (pull_request) Successful in 41s
Universal: Workflow Sync Trigger / Sync workflows to live repos (pull_request) Successful in 8m24s
Generic: Project CI / Tests (pull_request) Has been cancelled
Joomla: Extension CI / Tests (PHP 8.2) (pull_request) Has been cancelled
Joomla: Extension CI / Tests (PHP 8.3) (pull_request) Has been cancelled
Joomla: Extension CI / PHPStan Analysis (pull_request) Has been cancelled
Joomla: Extension CI / Build RC Pre-Release (pull_request) Has been cancelled
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
Generic: Repo Health / Scripts governance (pull_request) Has been cancelled
Generic: Repo Health / Repository health (pull_request) Has been cancelled
Generic: Repo Health / Report: Scripts Governance (pull_request) Has been cancelled
Generic: Repo Health / Report: Repository Health (pull_request) Has been cancelled
2026-07-04 15:36:16 -05:00
jmiller 6cef8fa9f7 Merge remote-tracking branch 'origin/main' into fix/remote-storage-cleanup
# Conflicts:
#	SECURITY.md
#	source/packages/MokoSuiteClient
#	source/packages/com_mokosuitebackup/mokosuitebackup.xml
#	source/packages/mod_mokosuitebackup_cpanel/mod_mokosuitebackup_cpanel.xml
#	source/packages/plg_actionlog_mokosuitebackup/mokosuitebackup.xml
#	source/packages/plg_console_mokosuitebackup/mokosuitebackup.xml
#	source/packages/plg_content_mokosuitebackup/mokosuitebackup.xml
#	source/packages/plg_quickicon_mokosuitebackup/mokosuitebackup.xml
#	source/packages/plg_system_mokosuitebackup/mokosuitebackup.xml
#	source/packages/plg_task_mokosuitebackup/mokosuitebackup.xml
#	source/packages/plg_webservices_mokosuitebackup/mokosuitebackup.xml
#	source/pkg_mokosuitebackup.xml
#	source/script.php
2026-07-04 15:34:41 -05:00
jmiller 278c572e0f chore: sync issue-branch.yml from Template-Generic [skip ci] 2026-07-04 20:28:29 +00:00
gitea-actions[bot] 5615468042 chore: promote changelog [Unreleased] → [02.55.00] 2026-07-04 20:28:12 +00:00
gitea-actions[bot] 4dc8bc6912 chore(release): build 02.55.00 [skip ci] 2026-07-04 20:28:01 +00:00
jmiller 23fa4884bd Merge pull request 'Apply standard licensing + install-completion installer script pattern' (#202) from chore/installer-script-template into main 2026-07-04 20:27:26 +00:00
gitea-actions[bot] 772f2b75a1 chore(release): build 02.54.00 [skip ci] 2026-07-04 20:27:20 +00:00
jmiller 1758694893 fix(installer): apply standard licensing + install-completion script pattern
Universal: PR Check / Branch Policy (pull_request) Successful in 2s
Joomla: Extension CI / Release Readiness Check (pull_request) Failing after 6s
Universal: PR Check / Secret Scan (pull_request) Successful in 7s
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 11s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Failing after 6s
Generic: Repo Health / Access control (pull_request) Successful in 1s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Branch Cleanup / Delete merged branch (pull_request) Successful in 2s
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Generic: Project CI / Lint & Validate (pull_request) Successful in 45s
Universal: PR Check / Validate PR (pull_request) Failing after 40s
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 49s
Universal: Build & Release / Build & Release Pipeline (pull_request) Successful in 31s
Universal: Workflow Sync Trigger / Sync workflows to live repos (pull_request) Successful in 3m0s
Generic: Project CI / Tests (pull_request) Has been cancelled
Joomla: Extension CI / Tests (PHP 8.2) (pull_request) Has been cancelled
Joomla: Extension CI / Tests (PHP 8.3) (pull_request) Has been cancelled
Joomla: Extension CI / PHPStan Analysis (pull_request) Has been cancelled
Joomla: Extension CI / Build RC Pre-Release (pull_request) Has been cancelled
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
Generic: Repo Health / Scripts governance (pull_request) Has been cancelled
Generic: Repo Health / Repository health (pull_request) Has been cancelled
Generic: Repo Health / Report: Scripts Governance (pull_request) Has been cancelled
Generic: Repo Health / Report: Repository Health (pull_request) Has been cancelled
Adopts the Template-Joomla package-script pattern for download-key handling
and post-install messaging, while preserving all MokoSuiteBackup-specific
postflight steps (plugins, webcron secret, scheduled task, submenus,
client_id fix, icon sync, backup_dir migration).

- preflight (update): backupDownloadKey() caches the bare dlid value
- postflight (update): restoreDownloadKey() re-writes extra_query as dlid=<key>
- postflight (install): installSuccessful() + warnMissingLicenseKey()
- postflight (update): installSuccessful()
- License nag now runs on install only (a fresh install never has a key),
  removing the previous always-run "is a key present?" guard
- Licensing methods log via Joomla\CMS\Log\Log instead of error_log
2026-07-04 15:27:05 -05:00
jmiller 2de2c5f917 Merge pull request 'Enforce per-profile retention and repair Purge Old Backups button' (#203) from fix/backup-retention-purge into main 2026-07-04 20:26:20 +00:00
jmiller 5c5af532bb chore: sync pr-check.yml from Template-Generic [skip ci] 2026-07-04 20:26:05 +00:00
jmiller 03bf5dff7b chore: sync issue-branch.yml from Template-Generic [skip ci] 2026-07-04 20:26:04 +00:00
jmiller 10248b284a fix: enforce per-profile retention and repair Purge Old Backups button
Universal: PR Check / Branch Policy (pull_request) Failing after 2s
Joomla: Extension CI / Release Readiness Check (pull_request) Failing after 5s
Generic: Project CI / Lint & Validate (pull_request) Successful in 12s
Universal: PR Check / Secret Scan (pull_request) Successful in 6s
Universal: PR Check / Validate PR (pull_request) Failing after 4s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Generic: Repo Health / Access control (pull_request) Successful in 2s
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Branch Cleanup / Delete merged branch (pull_request) Successful in 2s
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 9s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 27s
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 39s
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Universal: Build & Release / Build & Release Pipeline (pull_request) Successful in 30s
Universal: Workflow Sync Trigger / Sync workflows to live repos (pull_request) Successful in 4m0s
Generic: Project CI / Tests (pull_request) Has been cancelled
Joomla: Extension CI / Tests (PHP 8.2) (pull_request) Has been cancelled
Joomla: Extension CI / Tests (PHP 8.3) (pull_request) Has been cancelled
Joomla: Extension CI / PHPStan Analysis (pull_request) Has been cancelled
Joomla: Extension CI / Build RC Pre-Release (pull_request) Has been cancelled
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
Generic: Repo Health / Scripts governance (pull_request) Has been cancelled
Generic: Repo Health / Repository health (pull_request) Has been cancelled
Generic: Repo Health / Report: Scripts Governance (pull_request) Has been cancelled
Generic: Repo Health / Report: Repository Health (pull_request) Has been cancelled
Two related backup-management fixes.

Retention (records/days to keep):
- The retention fieldset was defined in profile.xml but never rendered
  in the profile editor, so retention_days/retention_count were invisible.
  Render the retention fieldset on the Archive tab.
- retention_days/retention_count were read by nothing, so they pruned no
  backups. Add RetentionManager::prune(), called from completeRecord() in
  both BackupEngine and SteppedBackupEngine after a backup finishes.
  Policy: delete a completed/warning backup when EITHER it is older than
  retention_days OR it falls outside the newest retention_count copies
  (0 = unlimited for that rule). Deleting a record also removes its
  archive and log file.
- Correct misleading language/schema text ("use global default" — no such
  global backup-retention setting exists) to "0 = unlimited".

Purge Old Backups button:
- The modal only opened via a fragile selector match on the toolbar
  button's inline onclick, which Joomla 6's Atum toolbar does not render,
  so the button did nothing. Wrap Joomla.submitbutton to open the modal
  for the backups.purgeModal task, keeping the selector as a fallback.
2026-07-04 15:25:43 -05:00
gitea-actions[bot] 4b2bb8b655 chore: promote changelog [Unreleased] → [02.54.00] 2026-07-04 20:25:31 +00:00
gitea-actions[bot] 2e5b57fe5d chore(release): build 02.54.00 [skip ci] 2026-07-04 20:25:20 +00:00
jmiller bdbbe74021 Merge pull request 'Hardcode manifest names/descriptions and enforce Type - Name convention' (#201) from chore/manifest-naming-convention into main 2026-07-04 20:24:46 +00:00
jmiller 0df6008a3b chore: sync pr-check.yml from Template-Generic [skip ci] 2026-07-04 20:23:20 +00:00
jmiller 145155d06e chore: sync issue-branch.yml from Template-Generic [skip ci] 2026-07-04 20:23:18 +00:00
jmiller 84f37a8b51 chore: sync ci-generic.yml from Template-Generic [skip ci] 2026-07-04 20:23:16 +00:00
gitea-actions[bot] d3e3fd25e7 chore(version): pre-release bump to 02.52.30-dev [skip ci] 2026-07-04 20:18:21 +00:00
jmiller 46daabc34f fix(remote-cleanup): resolve dropped-column references found in review
Universal: PR Check / Branch Policy (pull_request) Failing after 1s
Joomla: Extension CI / Release Readiness Check (pull_request) Failing after 6s
Universal: PR Check / Validate PR (pull_request) Failing after 6s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Generic: Repo Health / Access control (pull_request) Successful in 2s
Universal: PR Check / Secret Scan (pull_request) Successful in 10s
Generic: Project CI / Lint & Validate (pull_request) Successful in 13s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 27s
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Universal: Build & Release / Build & Release Pipeline (pull_request) Has been skipped
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 40s
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 41s
Generic: Project CI / Tests (pull_request) Has been cancelled
Joomla: Extension CI / Tests (PHP 8.2) (pull_request) Has been cancelled
Joomla: Extension CI / Tests (PHP 8.3) (pull_request) Has been cancelled
Joomla: Extension CI / PHPStan Analysis (pull_request) Has been cancelled
Joomla: Extension CI / Build RC Pre-Release (pull_request) Has been cancelled
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
Generic: Repo Health / Scripts governance (pull_request) Has been cancelled
Generic: Repo Health / Repository health (pull_request) Has been cancelled
Generic: Repo Health / Report: Scripts Governance (pull_request) Has been cancelled
Generic: Repo Health / Report: Repository Health (pull_request) Has been cancelled
Follow-up to the legacy remote-storage removal — three consumers still
referenced columns this branch drops:

- 02.52.25.sql: use plain DROP COLUMN instead of DROP COLUMN IF EXISTS.
  IF EXISTS on DROP COLUMN is a MariaDB-only extension and errors on
  Oracle MySQL 8.x (which Joomla also supports); the columns always exist
  here, so the guard is unnecessary and the migration is now portable.
- AkeebaImporter::mapToMokoProfile(): stop inserting the 19 dropped
  remote_storage/ftp_*/gdrive_*/s3_* columns (would fatal with "Unknown
  column" on Akeeba import). Remote settings now live in the remotes
  table and are re-added on the profile Remote tab after import.
- AjaxController::browseSftpDir() + SftpPathField: remove. These were the
  legacy single-SFTP path picker, orphaned when the SftpPath form field
  was removed; they read now-dropped sftp_* columns.

Claude-Session: https://claude.ai/code/session_01WbGBN9VyRK61zczYWcCQ2i
2026-07-04 15:17:54 -05:00
gitea-actions[bot] 5d4662342f chore(version): pre-release bump to 02.52.25-dev [skip ci] 2026-07-04 18:23:15 +00:00
jmiller 68605ffc05 refactor(remote): remove legacy single-remote storage in favor of remotes table
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 25s
Drops the per-profile remote_storage column and all legacy FTP/SFTP/S3/
Google Drive credential columns. Remote destinations are now sourced
exclusively from #__mokosuitebackup_remotes (multi-remote), which is
created at install time — so the backward-compat fallback branches in
BackupEngine, SteppedBackupEngine and loadRemoteDestinations are removed.

- sql: drop 26 legacy columns (install.mysql.sql + 02.52.25.sql migration)
- forms/profile.xml: remove legacy remote fields and ftp/gdrive/s3 fieldsets
- tmpl/profile/edit.php: drop legacy UI, add save-first prompt, use
  getOrCreateInstance for the modal, read item.params (was item.config)
- PreflightCheck: validate credentials from the remotes table; curl
  warning now applies to ntfy only
- SteppedSession: drop remoteStorage property
- language: add backup-record delete-count strings
- script.php: simplify postflight license-key prompt
2026-07-04 13:22:44 -05:00
42 changed files with 510 additions and 1178 deletions
+35 -7
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,11 +104,39 @@ jobs:
- name: Rename branch to rc
run: |
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 }}"
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"
- name: Checkout rc and configure git
run: |
@@ -218,7 +246,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
+3 -2
View File
@@ -132,8 +132,9 @@ jobs:
name: Tests
runs-on: ubuntu-latest
needs: lint
# Skip on template repos (Template-*) — see lint job.
if: ${{ !startsWith(github.event.repository.name, 'Template-') }}
# Run only when lint succeeded; always() forces evaluation so a skipped
# lint (e.g. template repos) skips this job cleanly instead of hanging.
if: ${{ always() && needs.lint.result == 'success' }}
steps:
- name: Checkout
+1 -1
View File
@@ -50,7 +50,7 @@ jobs:
for BRANCH in $BRANCHES; do
# Skip protected branches
case "$BRANCH" in
main|master|develop|release/*|hotfix/*) continue ;;
main|master|dev|develop|rc|beta|alpha|release|release/*|production|stable|staging|hotfix/*|version/*) 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: 02.53.00
# VERSION: 01.00.00
# 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:
- "Joomla Build & Release"
- "Joomla Extension CI"
- "Deploy"
- "Universal: Build & Release"
- "Joomla: Extension CI"
- "Generic: Project CI"
types:
- completed
+13 -8
View File
@@ -47,15 +47,15 @@ jobs:
fi
;;
fix/*|bugfix/*)
if [ "$BASE" != "dev" ]; then
if [ "$BASE" != "dev" ] && [ "$BASE" != "main" ]; then
ALLOWED=false
REASON="Fix branches must target 'dev', not '${BASE}'"
REASON="Fix branches must target 'dev' or 'main', not '${BASE}'"
fi
;;
patch/*)
if [ "$BASE" != "dev" ] && [ "$BASE" != "rc" ]; then
if [ "$BASE" != "dev" ] && [ "$BASE" != "rc" ] && [ "$BASE" != "main" ]; then
ALLOWED=false
REASON="Patch branches must target 'dev' or 'rc', not '${BASE}'"
REASON="Patch branches must target 'dev', 'rc', or 'main', not '${BASE}'"
fi
;;
hotfix/*)
@@ -86,7 +86,8 @@ jobs:
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Allowed merge paths:" >> $GITHUB_STEP_SUMMARY
echo "- \`feature/*\` → \`dev\`" >> $GITHUB_STEP_SUMMARY
echo "- \`fix/*\` → \`dev\`" >> $GITHUB_STEP_SUMMARY
echo "- \`fix/*\` → \`dev\` or \`main\`" >> $GITHUB_STEP_SUMMARY
echo "- \`patch/*\` → \`dev\`, \`rc\`, or \`main\`" >> $GITHUB_STEP_SUMMARY
echo "- \`hotfix/*\` → \`dev\` or \`main\`" >> $GITHUB_STEP_SUMMARY
echo "- \`dev\` → \`main\`" >> $GITHUB_STEP_SUMMARY
echo "- \`rc/*\` → \`main\`" >> $GITHUB_STEP_SUMMARY
@@ -149,11 +150,12 @@ jobs:
- name: Detect platform
id: platform
run: |
# 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:]')
# 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)"
[ -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'
@@ -494,6 +496,9 @@ jobs:
name: Build RC Package
runs-on: ubuntu-latest
needs: [branch-policy, validate]
# Run only when both gates succeeded; always() forces evaluation so a skipped
# validate (e.g. template repos) skips this job cleanly instead of hanging.
if: ${{ always() && needs.branch-policy.result == 'success' && needs.validate.result == 'success' }}
steps:
- name: Trigger RC pre-release
+9 -21
View File
@@ -1,32 +1,20 @@
# Changelog
## [Unreleased]
## [02.53.00] --- 2026-07-04
## [02.56.05] --- 2026-07-05
## [02.53.00] --- 2026-07-04
## [02.56.05] --- 2026-07-05
## [02.52.24] --- 2026-06-30
## [02.56.00] --- 2026-07-04
## [02.52.24] --- 2026-06-30
## [02.56.00] --- 2026-07-04
## [02.52.22] --- 2026-06-30
## [02.56.00] --- 2026-07-04
### Added
- Cancel Stalled toolbar button on Backup Records view to cancel backups stuck in "running" status
- New ACL permission `mokosuitebackup.backup.cancel` for cancel stalled action
- AJAX endpoint `ajax.cancelBackup` for programmatic/API cancel
- Auto-timeout failsafe: preflight auto-cancels "running" backups older than 30 minutes
- Pre-extension-update backup progress modal (Bootstrap 5 modal with stepped AJAX progress bar)
- New `warning` backup status for records where archive succeeded but remote upload failed
- Warning-status records are downloadable, browsable, restorable, and purgeable
- Warning status filter option in Backup Records dropdown
- Yellow "Warning" badge in backup list, detail view, and cpanel module
### Fixed
- Pre-update backup ran synchronously with no browser feedback — page hung until complete
- Stalled backups permanently blocked future backups for the same profile
- Preflight error message now directs users to Cancel Stalled action
- Backups with failed remote uploads were marked as "complete", hiding the upload failure
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.52.18] --- 2026-06-30
## [02.56.00] --- 2026-07-04
+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.53.00
VERSION: 02.56.05
BRIEF: Security vulnerability reporting and handling policy
-->
-37
View File
@@ -1,37 +0,0 @@
<?php
/**
* @package MokoSuiteBackup
* @subpackage plg_webservices_mokosuitebackup
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
defined('_JEXEC') or die;
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,25 +206,6 @@
</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"
@@ -236,81 +217,6 @@
<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">
@@ -408,157 +314,4 @@
/>
</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>
@@ -251,9 +251,9 @@ COM_MOKOJOOMBACKUP_FIELD_NOTIFY_FAILURE_DESC="Send an email when a backup fails.
; Retention
COM_MOKOJOOMBACKUP_FIELDSET_RETENTION="Retention"
COM_MOKOJOOMBACKUP_FIELD_RETENTION_DAYS="Keep Backups (days)"
COM_MOKOJOOMBACKUP_FIELD_RETENTION_DAYS_DESC="Delete completed backups from this profile older than this many days. Set to 0 to use the global default from component options."
COM_MOKOJOOMBACKUP_FIELD_RETENTION_DAYS_DESC="Delete completed backups from this profile older than this many days. Set to 0 for unlimited (keep by age disabled)."
COM_MOKOJOOMBACKUP_FIELD_RETENTION_COUNT="Keep Backups (count)"
COM_MOKOJOOMBACKUP_FIELD_RETENTION_COUNT_DESC="Maximum number of completed backups to keep for this profile. Oldest are removed first. Set to 0 to use the global default from component options."
COM_MOKOJOOMBACKUP_FIELD_RETENTION_COUNT_DESC="Maximum number of completed backups to keep for this profile. Oldest are removed first. Set to 0 for unlimited (keep by count disabled)."
COM_MOKOJOOMBACKUP_FIELD_NTFY_SPACER_DESC="<strong>Push Notifications (ntfy)</strong> — Send instant push notifications to your phone or desktop via <a href='https://ntfy.sh' target='_blank'>ntfy.sh</a> or a self-hosted ntfy server."
COM_MOKOJOOMBACKUP_FIELD_NTFY_TOPIC="ntfy Topic"
@@ -127,6 +127,17 @@ 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."
; Retention (per-profile)
COM_MOKOJOOMBACKUP_FIELDSET_RETENTION="Retention"
COM_MOKOJOOMBACKUP_FIELD_RETENTION_DAYS="Keep Backups (days)"
COM_MOKOJOOMBACKUP_FIELD_RETENTION_DAYS_DESC="Delete completed backups from this profile older than this many days. Set to 0 for unlimited (keep by age disabled)."
COM_MOKOJOOMBACKUP_FIELD_RETENTION_COUNT="Keep Backups (count)"
COM_MOKOJOOMBACKUP_FIELD_RETENTION_COUNT_DESC="Maximum number of completed backups to keep for this profile. Oldest are removed first. Set to 0 for unlimited (keep by count disabled)."
@@ -7,7 +7,7 @@
-->
<extension type="component" method="upgrade">
<name>Component - MokoSuiteBackup</name>
<version>02.53.00</version>
<version>02.56.05</version>
<creationDate>2026-06-02</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -11,32 +11,6 @@ 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',
@@ -49,8 +23,8 @@ CREATE TABLE IF NOT EXISTS `#__mokosuitebackup_profiles` (
`notify_user_groups` VARCHAR(255) NOT NULL DEFAULT '' COMMENT 'Comma-separated Joomla user group IDs',
`notify_on_success` TINYINT(1) NOT NULL DEFAULT 0,
`notify_on_failure` TINYINT(1) NOT NULL DEFAULT 1,
`retention_days` INT(11) NOT NULL DEFAULT 0 COMMENT '0 = use global default',
`retention_count` INT(11) NOT NULL DEFAULT 0 COMMENT '0 = use global default',
`retention_days` INT(11) NOT NULL DEFAULT 0 COMMENT 'Delete backups older than N days; 0 = unlimited',
`retention_count` INT(11) NOT NULL DEFAULT 0 COMMENT 'Keep newest N backups; 0 = unlimited',
`ntfy_topic` VARCHAR(255) NOT NULL DEFAULT '' COMMENT 'ntfy topic name',
`ntfy_server` VARCHAR(512) NOT NULL DEFAULT 'https://ntfy.sh' COMMENT 'ntfy server URL',
`ntfy_token` VARCHAR(255) NOT NULL DEFAULT '' COMMENT 'ntfy access token (optional)',
@@ -0,0 +1,31 @@
-- 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.00 — no schema changes */
@@ -0,0 +1 @@
/* 02.55.00 — no schema changes */
@@ -0,0 +1 @@
/* 02.55.03 — no schema changes */
@@ -0,0 +1 @@
/* 02.56.00 — no schema changes */
@@ -0,0 +1,32 @@
-- 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;
@@ -0,0 +1 @@
/* 02.56.05 — no schema changes */
@@ -1265,184 +1265,6 @@ 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,24 +228,9 @@ 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' => $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 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_keep_local' => 1,
'include_mokorestore' => (int) (($config['akeeba.advanced.embedded_installer'] ?? 'none') !== 'none'),
'published' => 1,
@@ -321,48 +321,6 @@ 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
@@ -403,6 +361,17 @@ class BackupEngine
NotificationSender::send($profile, $update, false, "Remote upload failed — see backup log for details.\n\n" . implode("\n", $this->log));
}
// Enforce per-profile retention (age and/or copy count).
try {
$pruned = RetentionManager::prune($db, $profile);
if ($pruned > 0) {
$this->log('Retention: pruned ' . $pruned . ' old backup(s)');
}
} catch (\Throwable $e) {
error_log('MokoSuiteBackup: retention pass failed: ' . $e->getMessage());
}
// Dispatch event for actionlog and other listeners
$this->dispatchAfterRun(true, $recordId, $description, $profileId, $origin);
@@ -519,23 +488,7 @@ class BackupEngine
}
/**
* 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).
* Create a remote uploader from JSON params.
*
* Builds a fake profile-like object from the params array so the existing
* uploader constructors work without modification.
@@ -569,31 +522,18 @@ 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
{
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);
$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() ?: [];
} catch (\Throwable $e) {
// Table does not exist yet (pre-migration) — fall back to legacy
return [];
}
return $db->loadObjectList() ?: [];
}
/**
@@ -77,7 +77,7 @@ class PreflightCheck
$this->checkDiskSpace($profile, $db);
$this->checkRunningBackup($profile, $db);
$this->checkExcludedTables($profile, $db);
$this->checkRemoteCredentials($profile);
$this->checkRemoteCredentials($profile, $db);
return $this->result();
}
@@ -102,12 +102,8 @@ class PreflightCheck
}
}
// 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';
if (!empty($profile->ntfy_topic) && !extension_loaded('curl')) {
$this->warnings[] = 'ext-curl is not loaded — ntfy notifications will not work';
}
}
@@ -280,65 +276,76 @@ class PreflightCheck
}
/**
* Check that remote storage credentials are minimally configured.
* Check that remote destination credentials are minimally configured.
* Does not test the actual connection (too slow for preflight).
*/
private function checkRemoteCredentials(object $profile): void
private function checkRemoteCredentials(object $profile, object $db): void
{
$remote = $profile->remote_storage ?? 'none';
$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();
if ($remote === 'none') {
if (empty($remotes)) {
return;
}
switch ($remote) {
case 'ftp':
if (empty($profile->ftp_host)) {
$this->warnings[] = 'FTP host is not configured — remote upload will fail';
}
foreach ($remotes as $remote) {
$params = json_decode($remote->params, true) ?: [];
$label = $remote->title ?: ('Remote #' . $remote->id);
if (empty($profile->ftp_username)) {
$this->warnings[] = 'FTP username 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';
}
break;
if (empty($params['username'])) {
$this->warnings[] = $label . ': FTP username is not configured — upload will fail';
}
case 's3':
if (empty($profile->s3_bucket)) {
$this->warnings[] = 'S3 bucket is not configured — remote upload will fail';
}
break;
if (empty($profile->s3_access_key) || empty($profile->s3_secret_key)) {
$this->warnings[] = 'S3 credentials are not configured — remote upload will fail';
}
case 's3':
if (empty($params['bucket'])) {
$this->warnings[] = $label . ': S3 bucket is not configured — upload will fail';
}
break;
if (empty($params['access_key']) || empty($params['secret_key'])) {
$this->warnings[] = $label . ': S3 credentials are not configured — upload will fail';
}
case 'sftp':
if (empty($profile->sftp_host)) {
$this->warnings[] = 'SFTP host is not configured — remote upload will fail';
}
break;
if (empty($profile->sftp_username)) {
$this->warnings[] = 'SFTP username 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_key_data) && empty($profile->sftp_password)) {
$this->warnings[] = 'SFTP requires either a private key or password — remote upload will fail';
}
if (empty($params['username'])) {
$this->warnings[] = $label . ': SFTP username is not configured — upload will fail';
}
break;
if (empty($params['key_data']) && empty($params['password'])) {
$this->warnings[] = $label . ': SFTP requires either a private key or password — upload will fail';
}
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';
}
break;
if (empty($profile->gdrive_refresh_token)) {
$this->warnings[] = 'Google Drive refresh token is missing — 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';
}
break;
if (empty($params['refresh_token'])) {
$this->warnings[] = $label . ': Google Drive refresh token is missing — upload will fail';
}
break;
}
}
}
@@ -0,0 +1,118 @@
<?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
*/
namespace Joomla\Component\MokoSuiteBackup\Administrator\Engine;
defined('_JEXEC') or die;
use Joomla\Component\MokoSuiteBackup\Administrator\Utility\BackupDirectory;
/**
* Enforces per-profile backup retention.
*
* A profile may cap retained backups by age (retention_days) and/or by
* number of copies (retention_count). A backup is pruned when EITHER rule
* matches: it is older than retention_days OR it falls outside the newest
* retention_count copies. Deleting a record also removes its archive and
* log file, mirroring the Backup table's delete().
*/
final class RetentionManager
{
/**
* Prune old backups for a profile according to its retention settings.
*
* Called after a backup completes. Only 'complete' and 'warning' records
* are considered — pending/running/failed records are never pruned here.
*
* @param object $db Database driver
* @param object $profile Profile row (needs id, retention_days, retention_count)
*
* @return int Number of backup records deleted
*/
public static function prune(object $db, object $profile): int
{
$days = (int) ($profile->retention_days ?? 0);
$count = (int) ($profile->retention_count ?? 0);
// No retention configured — nothing to do.
if ($days <= 0 && $count <= 0) {
return 0;
}
// Newest first, so the index is the copy's position from the top.
$query = $db->getQuery(true)
->select($db->quoteName(['id', 'absolute_path', 'backupstart']))
->from($db->quoteName('#__mokosuitebackup_records'))
->where($db->quoteName('profile_id') . ' = ' . (int) $profile->id)
->where($db->quoteName('status') . ' IN (' . implode(',', array_map([$db, 'quote'], ['complete', 'warning'])) . ')')
->order($db->quoteName('backupstart') . ' DESC');
$db->setQuery($query);
$records = $db->loadObjectList() ?: [];
if (empty($records)) {
return 0;
}
$cutoffTs = $days > 0 ? (time() - ($days * 86400)) : null;
$deleted = 0;
foreach ($records as $index => $record) {
$tooOld = $cutoffTs !== null && strtotime((string) $record->backupstart) < $cutoffTs;
$overCount = $count > 0 && $index >= $count;
// Delete-if-either: prune when age OR count rule is exceeded.
if (!$tooOld && !$overCount) {
continue;
}
if (self::deleteRecord($db, $record)) {
$deleted++;
}
}
return $deleted;
}
/**
* Delete a single backup record and its on-disk archive + log file.
*
* The DB row is removed first; the files are only unlinked if that
* succeeds, so a failed delete never orphans the record from its files.
*/
private static function deleteRecord(object $db, object $record): bool
{
$query = $db->getQuery(true)
->delete($db->quoteName('#__mokosuitebackup_records'))
->where($db->quoteName('id') . ' = ' . (int) $record->id);
$db->setQuery($query);
try {
$db->execute();
} catch (\Throwable $e) {
error_log('MokoSuiteBackup: retention could not delete record ' . $record->id . ': ' . $e->getMessage());
return false;
}
$archivePath = (string) ($record->absolute_path ?? '');
if ($archivePath !== '' && is_file($archivePath)) {
@unlink($archivePath);
$logPath = BackupDirectory::logPathFromArchive($archivePath);
if (is_file($logPath)) {
@unlink($logPath);
}
}
return true;
}
}
@@ -69,7 +69,6 @@ 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);
@@ -153,15 +152,8 @@ 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);
if ($remoteCount > 0) {
$totalSteps += $remoteCount;
} elseif ($session->remoteStorage !== 'none') {
$totalSteps += 1;
}
$totalSteps += $remoteCount;
$session->totalSteps = $totalSteps;
$session->currentStep = 1;
@@ -421,11 +413,7 @@ class SteppedBackupEngine
$session->currentStep++;
// Determine next phase: multi-remote, legacy single-remote, or complete
$hasMultiRemote = !empty($session->remoteDestinations);
$hasLegacyRemote = $session->remoteStorage !== 'none';
if ($hasMultiRemote || $hasLegacyRemote) {
if (!empty($session->remoteDestinations)) {
$session->phase = 'upload';
} else {
$session->phase = 'complete';
@@ -440,11 +428,7 @@ class SteppedBackupEngine
}
/**
* 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.
* Upload phase: send archive to one remote destination per call.
*/
private function stepUpload(SteppedSession $session): void
{
@@ -452,133 +436,65 @@ class SteppedBackupEngine
$remoteFilename = '';
$uploadFailed = false;
if (!empty($session->remoteDestinations)) {
// ── Multi-remote path ──────────────────────────────────
$index = $session->remoteIndex;
$index = $session->remoteIndex;
if ($index >= count($session->remoteDestinations)) {
// All remotes processed — move to complete
$session->phase = 'complete';
$session->statusMessage = 'All remote uploads finished';
$this->completeRecord($session);
if ($index >= count($session->remoteDestinations)) {
$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));
}
} else {
$uploadFailed = true;
$session->log(' WARNING: Upload failed: ' . $result['message']);
if (!empty($session->restoreScriptPath) && is_file($session->restoreScriptPath)) {
$uploader->upload($session->restoreScriptPath, basename($session->restoreScriptPath));
}
} catch (\Throwable $e) {
} else {
$uploadFailed = true;
$session->log(' WARNING: Upload exception: ' . $e->getMessage());
$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->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 (remote upload failed — local archive preserved)'
? 'Backup complete (some remote uploads failed — local archive preserved)'
: 'Backup complete';
$this->completeRecord($session, $uploadFailed);
}
@@ -686,6 +602,13 @@ class SteppedBackupEngine
if ($uploadFailed) {
NotificationSender::send($profile, $record, false, "Remote upload failed — see backup log for details.\n\n" . $logContent);
}
// Enforce per-profile retention (age and/or copy count).
$pruned = RetentionManager::prune($db, $profile);
if ($pruned > 0) {
$session->log('Retention: pruned ' . $pruned . ' old backup(s)');
}
}
} catch (\Throwable $e) {
error_log('MokoSuiteBackup: SteppedBackupEngine notification failed: ' . $e->getMessage());
@@ -859,21 +782,15 @@ class SteppedBackupEngine
*/
private function loadRemoteDestinations(object $db, int $profileId): array
{
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);
$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);
// 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 [];
}
return $db->loadAssocList() ?: [];
}
/**
@@ -50,7 +50,6 @@ 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 = '';
@@ -1,253 +0,0 @@
<?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;
}
}
@@ -684,19 +684,37 @@ $listDirn = $this->escape($this->state->get('list.direction'));
var PURGE_TOKEN = <?php echo json_encode($ajaxToken); ?>;
var purgeCountTimer = null;
// Intercept Purge toolbar button to show the modal
// Reset modal state and show it.
function openPurgeModal() {
document.getElementById('mb-purge-date').value = '';
document.getElementById('mb-purge-count-wrapper').style.display = 'none';
document.getElementById('mb-purge-none-wrapper').style.display = 'none';
document.getElementById('mb-purge-submit').disabled = true;
bootstrap.Modal.getOrCreateInstance(document.getElementById('mb-purge-modal')).show();
}
// Primary: wrap Joomla.submitbutton so the Purge toolbar button opens the
// modal instead of submitting the no-op backups.purgeModal task. This is
// resilient to how the Atum toolbar renders the button markup.
if (window.Joomla && typeof Joomla.submitbutton === 'function') {
var origSubmitbutton = Joomla.submitbutton;
Joomla.submitbutton = function(task) {
if (task === 'backups.purgeModal') {
openPurgeModal();
return false;
}
return origSubmitbutton.apply(this, arguments);
};
}
document.addEventListener('DOMContentLoaded', function() {
// Fallback: if the button still exposes an inline onclick, bind directly.
var purgeBtn = document.querySelector('[onclick*="backups.purgeModal"], .button-trash');
if (purgeBtn) {
purgeBtn.addEventListener('click', function(e) {
e.preventDefault();
e.stopPropagation();
// Reset modal state
document.getElementById('mb-purge-date').value = '';
document.getElementById('mb-purge-count-wrapper').style.display = 'none';
document.getElementById('mb-purge-none-wrapper').style.display = 'none';
document.getElementById('mb-purge-submit').disabled = true;
bootstrap.Modal.getOrCreateInstance(document.getElementById('mb-purge-modal')).show();
openPurgeModal();
return false;
}, true);
}
@@ -17,6 +17,7 @@ use Joomla\CMS\Session\Session;
HTMLHelper::_('behavior.formvalidator');
HTMLHelper::_('behavior.keepalive');
HTMLHelper::_('bootstrap.modal');
$profileId = (int) $this->item->id;
$token = Session::getFormToken();
@@ -42,6 +43,7 @@ $token = Session::getFormToken();
<div class="row">
<div class="col-lg-9">
<?php echo $this->form->renderFieldset('archive'); ?>
<?php echo $this->form->renderFieldset('retention'); ?>
</div>
</div>
<?php echo HTMLHelper::_('uitab.endTab'); ?>
@@ -65,7 +67,6 @@ $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">
@@ -97,20 +98,13 @@ $token = Session::getFormToken();
<?php echo Text::_('COM_MOKOJOOMBACKUP_REMOTE_NONE_CONFIGURED'); ?>
</p>
</div>
<hr>
<?php else: ?>
<div class="alert alert-info">
<?php echo Text::_('COM_MOKOJOOMBACKUP_REMOTE_SAVE_FIRST'); ?>
</div>
<?php endif; ?>
<?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>
<?php echo $this->form->renderFieldset('remote'); ?>
</div>
</div>
<?php echo HTMLHelper::_('uitab.endTab'); ?>
@@ -280,9 +274,12 @@ document.addEventListener('DOMContentLoaded', function() {
const tbody = document.getElementById('remoteDestBody');
const emptyMsg = document.getElementById('remoteDestEmpty');
const loadingTr = document.getElementById('remoteDestLoading');
const legacy = document.getElementById('legacyRemoteFields');
const legacyNote = document.getElementById('legacyRemoteNote');
const modal = new bootstrap.Modal(document.getElementById('remoteModal'));
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);
// Type badge colours
const typeBadge = {sftp: 'bg-primary', s3: 'bg-warning text-dark', google_drive: 'bg-success'};
@@ -336,14 +333,10 @@ 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');
@@ -506,8 +499,8 @@ document.addEventListener('DOMContentLoaded', function() {
const fields = configFields[item.type] || [];
fields.forEach(function(f) {
const el = document.getElementById('remoteCfg_' + prefix + f);
if (el && item.config && item.config[f] !== undefined) {
el.value = item.config[f];
if (el && item.params && item.params[f] !== undefined) {
el.value = item.params[f];
}
});
}
@@ -519,7 +512,7 @@ document.addEventListener('DOMContentLoaded', function() {
}
updateTypeFields();
modal.show();
getModal().show();
}
// ---- Type selector toggles field visibility ----
@@ -599,7 +592,7 @@ document.addEventListener('DOMContentLoaded', function() {
return;
}
modal.hide();
getModal().hide();
loadRemotes();
})
.catch(() => {
@@ -8,7 +8,7 @@
-->
<extension type="module" client="administrator" method="upgrade">
<name>Module - MokoSuiteBackup - cPanel</name>
<version>02.53.00</version>
<version>02.56.05</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.53.00</version>
<version>02.56.05</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.53.00</version>
<version>02.56.05</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.53.00</version>
<version>02.56.05</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.53.00</version>
<version>02.56.05</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.53.00</version>
<version>02.56.05</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.53.00</version>
<version>02.56.05</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.53.00</version>
<version>02.56.05</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.53.00</version>
<version>02.56.05</version>
<creationDate>2026-06-02</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
+56 -61
View File
@@ -12,6 +12,7 @@ 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
@@ -73,22 +74,25 @@ class Pkg_MokoSuiteBackupInstallerScript
/* Save download key before Joomla re-registers the update site */
if ($type === 'update') {
$this->preflight_saveKey();
$this->backupDownloadKey();
}
return true;
}
/**
* 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.
* The download key cached during preflight so it survives an update.
*/
private ?string $savedDownloadKey = null;
public function preflight_saveKey(): void
/**
* 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
{
try {
$db = Factory::getDbo();
@@ -108,19 +112,16 @@ 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);
$key = $db->loadResult();
if (!empty($key)) {
$this->savedDownloadKey = $key;
$db->setQuery($query);
$extraQuery = (string) $db->loadResult();
if (!empty($extraQuery)) {
parse_str($extraQuery, $output);
$this->savedDownloadKey = $output['dlid'] ?? $extraQuery;
}
} catch (\Exception $e) {
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'
);
Log::add('MokoSuiteBackup: Could not backup download key: ' . $e->getMessage(), Log::WARNING, 'jerror');
}
}
@@ -138,8 +139,8 @@ class Pkg_MokoSuiteBackupInstallerScript
return;
}
/* Restore download key if it was saved before update */
if ($this->savedDownloadKey !== null) {
/* Restore the download key preserved before the update re-registered the site */
if ($type === 'update') {
$this->restoreDownloadKey();
}
@@ -168,14 +169,17 @@ 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();
/* Remind user to review backup profile settings */
/* Install completion notice (install and update) */
$this->installSuccessful();
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(
@@ -640,66 +644,57 @@ 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) {
if ($updateSiteId > 0 && !empty($this->savedDownloadKey)) {
$query = $db->getQuery(true)
->update($db->quoteName('#__update_sites'))
->set($db->quoteName('extra_query') . ' = ' . $db->quote($this->savedDownloadKey))
->set($db->quoteName('extra_query') . ' = ' . $db->quote('dlid=' . $this->savedDownloadKey))
->where($db->quoteName('update_site_id') . ' = ' . $updateSiteId);
$db->setQuery($query);
$db->execute();
}
} catch (\Exception $e) {
error_log('MokoSuiteBackup: Could not restore download key: ' . $e->getMessage());
Log::add('MokoSuiteBackup: Could not restore download key: ' . $e->getMessage(), Log::WARNING, 'jerror');
Factory::getApplication()->enqueueMessage(
'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.',
'<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>',
'warning'
);
}
}
/**
* Show post-install license key prompt.
*/
private function warnMissingLicenseKey(): void
{
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';
}
try {
Factory::getApplication()->enqueueMessage(
'<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>',
'<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>',
'warning'
);
}
catch (\Exception $e) {
error_log('MokoSuiteBackup: License key check failed: ' . $e->getMessage());
} catch (\Exception $e) {}
}
/**
* Show install successful prompt.
*/
private function installSuccessful(): void
{
try {
Factory::getApplication()->enqueueMessage(
'MokoSuiteBackup could not verify your license key status. '
. 'Please check System &rarr; Update Sites to ensure a valid license key is configured.',
'warning'
'<h4>MokoSuiteBackup installed successfully!</h4>',
'info'
);
}
} catch (\Exception $e) {}
}
}