Compare commits

...

34 Commits

Author SHA1 Message Date
mokogitea-actions[bot] 38160e9ae9 chore(version): pre-release bump to 02.58.15-dev [skip ci]
Branch Cleanup / Delete merged branch (pull_request) Successful in 1s
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
2026-07-06 15:50:17 +00:00
jmiller 6b4f98869a fix: installer postflight honest about partial/failed install
Universal: PR Check / Require Docs Update (pull_request) Has been skipped
Universal: PR Check / Wiki Update Reminder (pull_request) Has been skipped
Universal: PR Check / Branch Policy (pull_request) Successful in 2s
Universal: PR Check / Secret Scan (pull_request) Successful in 8s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 37s
Generic: Project CI / Lint & Validate (pull_request) Successful in 33s
Universal: PR Check / Validate PR (pull_request) Failing after 28s
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 32s
Generic: Project CI / Tests (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
Joomla logs a failed child extension but still runs the package
postflight, so the 'installed successfully' message could appear on a
broken install. In postflight, before the success/next-steps messages:

- verify every bundled child declared in the package manifest actually
  registered in #__extensions (match element+type, and folder=group for
  plugins), and
- verify the component's schema tables (parsed dynamically from the
  installed install.mysql.sql) exist in the DB.

If anything is missing, enqueue an error listing it and RETURN before the
success message. Fail-open (try/catch -> nothing missing) so a transient
manifest/DB/IO glitch can't fail a good install. Unconditional
housekeeping (download-key restore, menu fixes) still runs regardless.
Names escaped with htmlspecialchars; table/child lists derived
dynamically, never hard-coded.

Claude-Session: https://claude.ai/code/session_01WbGBN9VyRK61zczYWcCQ2i
2026-07-06 10:49:49 -05:00
mokogitea-actions[bot] 0d832e60e9 chore(version): pre-release bump to 02.58.14-dev [skip ci] 2026-07-06 03:58:23 +00:00
jmiller 746629bf02 Merge pull request 'feat: full-screen backup screen for pre-update + Backup Now (Akeeba-style), fixes white screen' (#238) from feat/fullscreen-backup-screen into dev
Universal: Auto Version Bump / Version Bump (push) Has been skipped
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 17s
2026-07-06 03:58:05 +00:00
jmiller 4311ec8f99 fix(security): enforce scheme allow-list on runbackup return URL
Universal: PR Check / Branch Policy (pull_request) Successful in 2s
Universal: PR Check / Require Docs Update (pull_request) Has been skipped
Universal: PR Check / Wiki Update Reminder (pull_request) Has been skipped
Universal: PR Check / Secret Scan (pull_request) Successful in 9s
Generic: Project CI / Lint & Validate (pull_request) Successful in 14s
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Branch Cleanup / Delete merged branch (pull_request) Successful in 2s
Universal: PR Check / Validate PR (pull_request) Failing after 14s
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 1m8s
Generic: Project CI / Tests (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
The pre-update return URL is assigned to window.location.href, so a
javascript:/data: URI (empty host) would have passed the host-only check
and executed. Require an absolute http(s) same-host URL or a single-slash
root-relative path; reject javascript:/data:/vbscript: and protocol-relative
//host.

Claude-Session: https://claude.ai/code/session_01WbGBN9VyRK61zczYWcCQ2i
2026-07-05 22:57:40 -05:00
jmiller 8cfa46d985 feat: full-screen backup screen for pre-update + Backup Now (Akeeba-style)
Universal: PR Check / Wiki Update Reminder (pull_request) Has been skipped
Universal: PR Check / Require Docs Update (pull_request) Has been skipped
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Universal: PR Check / Secret Scan (pull_request) Successful in 7s
Generic: Project CI / Lint & Validate (pull_request) Successful in 12s
Universal: PR Check / Validate PR (pull_request) Failing after 13s
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 49s
Generic: Project CI / Tests (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
Replace the popup-modal pre-update approach with a dedicated full-page
backup screen, modelled on Akeeba Backup-on-Update, and fix the white
screen caused by running a backup synchronously inside the update request.

- New view=runbackup (View/Runbackup + tmpl/runbackup): full-screen
  progress page that auto-starts the stepped backup (ajax.init -> loop
  ajax.step), then redirects to a validated returnurl on completion or
  shows a completion/retry panel. Auto-resolved by MVCFactory.
- System plugin: on com_joomlaupdate task=update.install (when
  backup_before_update is on), redirect to the full-screen backup screen
  with returnurl back to update.install&is_backed_up=1; on return, arm the
  throttle so onExtensionBeforeUpdate does not run a duplicate backup. No
  synchronous backup in the update request.
- Dashboard 'Backup Now' navigates to the full-screen screen instead of
  the inline modal; the inline modal + stepped JS are removed.
- Retire the popup modal: delete media/js/update-backup.js, remove the
  plugin <media> element and the preupdate_auto_intercept param.
- Dashboard: warn when Global Config live_site is empty (CLI/scheduled
  backup naming + URL generation depend on it).

Refs #196

Claude-Session: https://claude.ai/code/session_01WbGBN9VyRK61zczYWcCQ2i
2026-07-05 22:55:28 -05:00
mokogitea-actions[bot] 85c16b4af4 chore(version): pre-release bump to 02.58.13-dev [skip ci] 2026-07-06 03:34:43 +00:00
jmiller 094787d26d Merge pull request 'fix: raise resource limits for synchronous pre-update backup (white screen)' (#236) from fix/preupdate-wsod-limits into dev
Universal: Auto Version Bump / Version Bump (push) Has been skipped
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 28s
2026-07-06 03:34:19 +00:00
mokogitea-actions[bot] 064963da9c chore(version): pre-release bump to 02.58.12-dev [skip ci]
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Branch Cleanup / Delete merged branch (pull_request) Successful in 1s
2026-07-06 03:34:09 +00:00
jmiller 49463eb747 fix: raise resource limits for synchronous pre-update backup (WSOD)
Universal: PR Check / Require Docs Update (pull_request) Has been skipped
Universal: PR Check / Wiki Update Reminder (pull_request) Has been skipped
Universal: PR Check / Branch Policy (pull_request) Successful in 2s
Universal: PR Check / Secret Scan (pull_request) Successful in 8s
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 36s
Generic: Project CI / Lint & Validate (pull_request) Successful in 54s
Universal: PR Check / Validate PR (pull_request) Failing after 52s
Generic: Project CI / Tests (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
The pre-update/uninstall backup runs synchronously inside the extension
update request. Without raising limits, a large site exceeds
max_execution_time/memory_limit mid-backup and the update page
white-screens. Raise set_time_limit(0)/memory_limit/ignore_user_abort
like the web-cron path.

Claude-Session: https://claude.ai/code/session_01WbGBN9VyRK61zczYWcCQ2i
2026-07-05 22:33:43 -05:00
mokogitea-actions[bot] 07cbe5de52 chore(version): pre-release bump to 02.58.11-dev [skip ci] 2026-07-06 03:04:04 +00:00
jmiller fbf97b2cdb Merge pull request 'fix: CLI backups no longer named joomla.invalid (host placeholder)' (#235) from fix/cli-host-placeholder into dev
Universal: Auto Version Bump / Version Bump (push) Has been skipped
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 36s
2026-07-06 03:03:33 +00:00
mokogitea-actions[bot] 7cb0b08cfe chore(version): pre-release bump to 02.58.10-dev [skip ci]
Branch Cleanup / Delete merged branch (pull_request) Successful in 3s
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
2026-07-06 03:03:24 +00:00
jmiller 5ffcbcc2f1 fix: CLI backups no longer named joomla.invalid (host placeholder)
Universal: PR Check / Require Docs Update (pull_request) Has been skipped
Universal: PR Check / Wiki Update Reminder (pull_request) Has been skipped
Universal: PR Check / Branch Policy (pull_request) Successful in 2s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 40s
Universal: PR Check / Secret Scan (pull_request) Successful in 9s
Generic: Project CI / Lint & Validate (pull_request) Successful in 17s
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 16s
Universal: PR Check / Validate PR (pull_request) Failing after 50s
Generic: Project CI / Tests (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
Joomla's console fills $_SERVER['HTTP_HOST'] with the reserved sentinel
'joomla.invalid', which the [HOST] placeholder resolver used verbatim
because its unusable-host check only knew about empty/localhost. Treat
'joomla.invalid' as unusable too, falling back to live_site then the
system hostname, so CLI/console/scheduled backups get a real name.

Claude-Session: https://claude.ai/code/session_01WbGBN9VyRK61zczYWcCQ2i
2026-07-05 22:02:46 -05:00
mokogitea-actions[bot] a47a9e5deb chore(version): pre-release bump to 02.58.09-dev [skip ci] 2026-07-06 02:14:34 +00:00
jmiller 7165da4999 Merge pull request 'feat: pre-update modal phase 2 — auto-run backup on Update click (#196)' (#233) from feat/preupdate-auto-intercept into dev
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 24s
2026-07-06 02:14:16 +00:00
jmiller c0ecf6ce73 feat: pre-update modal phase 2 — auto-run backup on Update click (#196)
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Universal: PR Check / Require Docs Update (pull_request) Has been skipped
Universal: PR Check / Wiki Update Reminder (pull_request) Has been skipped
Universal: PR Check / Secret Scan (pull_request) Successful in 4s
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Generic: Project CI / Lint & Validate (pull_request) Successful in 9s
Branch Cleanup / Delete merged branch (pull_request) Successful in 1s
Universal: PR Check / Validate PR (pull_request) Failing after 7s
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 31s
Generic: Project CI / Tests (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
Opt-in param preupdate_auto_intercept (off by default). When enabled the
injected update-page script wraps window.Joomla.submitbutton so clicking
the toolbar Update button first pops the progress modal, runs the
pre-update backup, and re-issues the original update task on success.

- Matches only update.update / update.install (not Find Updates /
  Clear Cache, which also start with 'update').
- Reuses the phase-1 overlay + ajax.preupdateAck throttle so the
  subsequent server-side onExtensionBeforeUpdate backup is skipped.
- The manual 'Back up now' notice remains a fallback for flows that
  don't route through submitbutton (e.g. some com_joomlaupdate paths).

Refs #196

Claude-Session: https://claude.ai/code/session_01WbGBN9VyRK61zczYWcCQ2i
2026-07-05 21:13:35 -05:00
mokogitea-actions[bot] 6060979ce8 chore(version): pre-release bump to 02.58.08-dev [skip ci] 2026-07-06 01:58:00 +00:00
jmiller da71057079 Merge pull request 'feat: pre-update backup notice + live-progress modal on update pages (#196)' (#232) from feat/preupdate-modal-notice into dev
Universal: Auto Version Bump / Version Bump (push) Has been skipped
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 31s
2026-07-06 01:57:41 +00:00
jmiller 8dc4eaa384 feat: pre-update backup notice + live-progress modal on update pages
Universal: PR Check / Wiki Update Reminder (pull_request) Has been skipped
Universal: PR Check / Branch Policy (pull_request) Successful in 2s
Universal: PR Check / Require Docs Update (pull_request) Has been skipped
Universal: PR Check / Secret Scan (pull_request) Successful in 10s
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Branch Cleanup / Delete merged branch (pull_request) Successful in 1s
Generic: Project CI / Lint & Validate (pull_request) Successful in 15s
Universal: PR Check / Validate PR (pull_request) Failing after 12s
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 48s
Generic: Project CI / Tests (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
The server-side onExtensionBeforeUpdate backup runs synchronously and
cannot drive a browser modal, so nothing ever 'popped'. Add a client
notice + on-demand live-progress modal on com_joomlaupdate and
com_installer(view=update):

- plg_system_mokosuitebackup gains onBeforeCompileHead, which injects a
  'back up before you update' notice + Back up now button on the update
  pages (gated by show_update_notice + backup_before_update), plus a new
  media JS asset that runs the same stepped backup as the dashboard
  (ajax.init then a loop of ajax.step) with a dependency-free progress
  overlay.
- New AjaxController::preupdateAck() arms the same 10-min throttle key
  runPreActionBackup() checks, so proceeding with Joomla's update after a
  modal backup does not run a duplicate server-side backup.
- Plugin manifest registers the new media/js folder; language keys added.

Phase-1 baseline (notice + on-demand). Auto-intercepting the Update
button is the planned phase-2 follow-up (#196).

Refs #196

Claude-Session: https://claude.ai/code/session_01WbGBN9VyRK61zczYWcCQ2i
2026-07-05 20:56:50 -05:00
mokogitea-actions[bot] 4027c727d9 chore(version): pre-release bump to 02.58.07-dev [skip ci] 2026-07-06 01:36:07 +00:00
jmiller b729aa5052 Merge pull request 'feat: prune remote backups on retention + consolidate backup helpers (#229, #230)' (#231) from fix/retention-remote-shared-helper into dev
Universal: Auto Version Bump / Version Bump (push) Has been skipped
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 32s
2026-07-06 01:35:45 +00:00
mokogitea-actions[bot] 7a829b6eca chore(version): pre-release bump to 02.58.06-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
2026-07-06 01:34:49 +00:00
jmiller 447f7b572e feat: prune remote backups on retention + consolidate backup helpers
Universal: PR Check / Require Docs Update (pull_request) Has been skipped
Universal: PR Check / Wiki Update Reminder (pull_request) Has been skipped
Universal: PR Check / Branch Policy (pull_request) Successful in 2s
Universal: PR Check / Secret Scan (pull_request) Successful in 10s
Universal: PR Check / Validate PR (pull_request) Failing after 14s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 40s
Generic: Project CI / Lint & Validate (pull_request) Successful in 52s
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 1m8s
Generic: Project CI / Tests (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
Retention (RetentionManager and the plugin's hourly cleanup) only ever
deleted local files; remote archives grew unbounded. Add an idempotent
delete() to RemoteUploaderInterface + all four uploaders and have
RetentionManager remove each pruned archive from the profile's enabled
remotes (best-effort; failures logged). The shared restore.php is left
in place since every backup overwrites it. (#229)

Consolidate duplicated plumbing (#230):
- New RemoteUploaderFactory replaces the createUploaderFromParams copy
  duplicated in BackupEngine and SteppedBackupEngine.
- RetentionManager becomes the single retention authority (global-default
  fallback + pruneOrphans()); the system plugin delegates to it and its
  duplicate doCleanup()/deleteBackupRecord() logic is removed.
- Backend controller, API controller and legacy cli/mokosuitebackup.php
  now run through the shared BackupRunner instead of BackupEngine directly.

Refs #229 #230

Claude-Session: https://claude.ai/code/session_01WbGBN9VyRK61zczYWcCQ2i
2026-07-05 20:34:04 -05:00
mokogitea-actions[bot] f56c1bfbd1 chore(version): pre-release bump to 02.58.05-dev [skip ci] 2026-07-06 01:17:22 +00:00
jmiller aeb9d18ccb Merge pull request 'fix: restore missing str_replace subject arg in MokoRestore standalone gen (#226)' (#228) from fix/mokorestore-str-replace-args into dev
Universal: Auto Version Bump / Version Bump (push) Has been skipped
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 29s
2026-07-06 01:17:01 +00:00
mokogitea-actions[bot] 0a51ed75da chore(version): pre-release bump to 02.58.04-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
2026-07-06 01:16:02 +00:00
jmiller 0ae9bfd93a fix: restore missing subject arg in MokoRestore str_replace (standalone gen)
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 30s
MokoRestore::generateStandaloneScript() rewrites the 'Backup Archive'
pre-check via str_replace(ORIG, REPL) but was missing the third
$php subject argument, so every standalone-mode backup fatally errored
with 'str_replace() expects at least 3 arguments, 2 given' at the
'Generating standalone restore.php...' step. The archive still finalized
and uploaded, but no restore.php was produced -- the underlying cause of
#226 (restore script missing on the remote).

Refs #226

Claude-Session: https://claude.ai/code/session_01WbGBN9VyRK61zczYWcCQ2i
2026-07-05 20:15:30 -05:00
mokogitea-actions[bot] 1c0a87ca4a chore(version): pre-release bump to 02.58.03-dev [skip ci] 2026-07-06 00:40:33 +00:00
jmiller 2cb8527c25 Merge pull request 'fix(engine): check + log restore-script remote upload (fixes #226)' (#227) from fix/restore-script-upload-result into dev
Universal: Auto Version Bump / Version Bump (push) Has been skipped
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 24s
2026-07-06 00:40:18 +00:00
mokogitea-actions[bot] 35febb8854 chore(version): pre-release bump to 02.58.02-dev [skip ci]
Branch Cleanup / Delete merged branch (pull_request) Successful in 1s
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
2026-07-06 00:39:57 +00:00
jmiller bf91611b97 fix(engine): check + log restore-script remote upload (no longer silent)
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 21s
The standalone restore script was uploaded to each remote with the result
discarded and nothing logged, so a failed second upload left the restore script
missing from the remote with no warning while the archive still recorded
'complete'. Capture the result: log success, and on failure set uploadFailed
(-> warning status) and log the reason.

Fixes #226

Claude-Session: https://claude.ai/code/session_01WbGBN9VyRK61zczYWcCQ2i
2026-07-05 19:39:37 -05:00
mokogitea-actions[bot] 9bc0fc8ad8 chore(version): pre-release bump to 02.58.01-dev [skip ci] 2026-07-06 00:33:03 +00:00
jmiller e75a58709b Merge pull request 'chore(sync): cascade main -> dev' (#225) from main into dev
Universal: Auto Version Bump / Version Bump (push) Has been skipped
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 43s
2026-07-06 00:32:12 +00:00
53 changed files with 1200 additions and 294 deletions
+1 -1
View File
@@ -5,7 +5,7 @@
# FILE INFORMATION
# DEFGROUP: MokoGitea.Workflow
# INGROUP: mokocli.Automation
# VERSION: 02.58.00
# VERSION: 02.58.15
# BRIEF: Auto-create feature branch when an issue is opened
name: "Universal: Issue Branch"
+16
View File
@@ -2,6 +2,22 @@
## [Unreleased]
### Added
- **Full-screen backup screen** (`view=runbackup`) for both pre-update and manual backups, modelled on Akeeba's Backup-on-Update — replaces the earlier popup-modal approach. Clicking Joomla's **Install the update** now redirects (server-side, Akeeba-style) to a dedicated full-page backup screen that runs the stepped backup with a real progress bar and then **automatically continues the update** via a validated `returnurl` (flagged `is_backed_up=1` so the backup isn't repeated). Crucially, **no backup runs synchronously inside the update request** — which is what white-screened large sites. The dashboard **Backup Now** now opens the same full-screen screen instead of an inline modal. (#196)
- Retention now prunes **remote** copies too: when a backup is pruned by age/count, its archive is deleted from every enabled remote destination (SFTP / FTP / S3 / Google Drive), not just the local copy. Each uploader gained an idempotent `delete()` method (already-absent file = success), and removal is best-effort — a failing destination is logged but never blocks local pruning. The shared standalone `restore.php` is intentionally left in place (every backup overwrites it, so newer backups still depend on it). (#229)
### Changed
- Consolidated backup plumbing into shared helpers (#230):
- New `RemoteUploaderFactory` replaces the `createUploaderFromParams()` copy that was duplicated in `BackupEngine` and `SteppedBackupEngine`.
- `RetentionManager` is now the single retention authority — it takes the global `max_age_days`/`max_backups` fallback and gained `pruneOrphans()`; the system plugin's hourly cleanup delegates to it and its duplicate `deleteBackupRecord()` logic is removed.
- The backend controller, Web Services API controller, and legacy `cli/mokosuitebackup.php` now run backups through the shared `BackupRunner` (gaining the normalized complete/warning/fail status) instead of instantiating `BackupEngine` directly.
### Fixed
- Package installer is now **honest about success**: the postflight no longer prints "installed successfully" / next-steps when the install actually failed or only partially completed. Joomla logs a failed child extension but still runs the package postflight, so the postflight now (a) verifies every bundled child declared in the manifest actually registered in `#__extensions` (matched by element + type, and folder/group for plugins) and (b) verifies the component's schema tables — derived dynamically from the installed `install.mysql.sql` — actually exist. If anything is missing it enqueues an error listing what's missing and stops before the success message. Fail-open: any manifest/DB/IO glitch is treated as "nothing missing" so a transient error never turns a good install into a false failure. Unconditional housekeeping (e.g. download-key restore) still runs regardless.
- Pre-update/uninstall backup no longer **white-screens** the update on large sites. The synchronous backup that runs inside the extension update/uninstall request now raises `max_execution_time`/`memory_limit` (and `ignore_user_abort`) like the web-cron path, so it can't exhaust the request's default limits mid-backup. (Core Joomla updates additionally get a full-screen backup screen — see below.)
- Archive names for **CLI/console-triggered backups** no longer come out as `joomla.invalid_…`. The `[HOST]` placeholder took `$_SERVER['HTTP_HOST']` verbatim, but Joomla's console fills that with the reserved sentinel host `joomla.invalid`; the resolver now treats that (like empty/`localhost`) as unusable and falls back to the configured `live_site` host, then the system hostname. (Set the site's *live_site* to get the exact domain in CLI-built names.)
- Standalone restore script generation no longer aborts backups with `str_replace() expects at least 3 arguments, 2 given`. `MokoRestore::generateStandaloneScript()` had a `str_replace()` call (the "Backup Archive" pre-check rewrite) that was missing its `$php` subject argument, so **every** standalone-mode backup fatally errored while "Generating standalone restore.php…" — the archive still finalized and uploaded, but no `restore.php` was ever produced (the true root cause behind #226). (#226)
- Remote upload: the standalone restore script upload is no longer silent — its result is now checked and logged, and a failed restore-script upload marks the backup as `warning` (previously the result was discarded, so a missing restore script on the remote went unreported while the archive still showed success). (#226)
## [02.58.00] --- 2026-07-06
+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.58.00
VERSION: 02.58.15
BRIEF: Security vulnerability reporting and handling policy
-->
@@ -13,7 +13,7 @@ namespace Joomla\Component\MokoSuiteBackup\Api\Controller;
defined('_JEXEC') or die;
use Joomla\CMS\MVC\Controller\ApiController;
use Joomla\Component\MokoSuiteBackup\Administrator\Engine\BackupEngine;
use Joomla\Component\MokoSuiteBackup\Administrator\Service\BackupRunner;
class BackupsController extends ApiController
{
@@ -38,8 +38,7 @@ class BackupsController extends ApiController
$profileId = (int) ($data['profile'] ?? 1);
$description = $data['description'] ?? 'API backup ' . date('Y-m-d H:i:s');
$engine = new BackupEngine();
$result = $engine->run($profileId, $description, 'api');
$result = (new BackupRunner())->run($profileId, $description, 'api');
if ($result['success']) {
$this->app->setHeader('status', 200);
@@ -30,7 +30,7 @@ if (!defined('JPATH_BASE')) {
require_once JPATH_BASE . '/includes/framework.php';
use Joomla\CMS\Factory;
use Joomla\Component\MokoSuiteBackup\Administrator\Engine\BackupEngine;
use Joomla\Component\MokoSuiteBackup\Administrator\Service\BackupRunner;
// Parse CLI arguments
$profileId = 1;
@@ -56,8 +56,7 @@ echo "Profile: {$profileId}\n";
echo "Description: {$description}\n";
echo "Starting backup...\n\n";
$engine = new BackupEngine();
$result = $engine->run($profileId, $description, 'cli');
$result = (new BackupRunner())->run($profileId, $description, 'cli');
if ($result['success']) {
echo "SUCCESS: " . $result['message'] . "\n";
@@ -331,9 +331,24 @@ COM_MOKOJOOMBACKUP_CONFIG_SHOW_UPDATE_NOTICE_DESC="Display the update site confi
COM_MOKOJOOMBACKUP_CONFIG_PREACTION="Pre-action Backups"
COM_MOKOJOOMBACKUP_CONFIG_BACKUP_BEFORE_UPDATE="Backup Before Extension Update"
COM_MOKOJOOMBACKUP_CONFIG_BACKUP_BEFORE_UPDATE_DESC="Automatically run a full backup before any extension is updated. Uses the default profile. Throttled to once per 10 minutes to prevent duplicate backups during batch updates."
COM_MOKOJOOMBACKUP_CONFIG_PREUPDATE_AUTO_INTERCEPT="Auto-run on Update click"
COM_MOKOJOOMBACKUP_CONFIG_PREUPDATE_AUTO_INTERCEPT_DESC="When enabled, clicking the Update button on the Joomla Update or Extensions update page first pops the backup progress modal, runs the pre-update backup, then automatically continues the update. When disabled, a notice with a manual 'Back up now' button is shown instead."
COM_MOKOJOOMBACKUP_CONFIG_BACKUP_BEFORE_UNINSTALL="Backup Before Extension Uninstall"
COM_MOKOJOOMBACKUP_CONFIG_BACKUP_BEFORE_UNINSTALL_DESC="Automatically run a full backup before any extension is uninstalled. Uses the default profile. Throttled to once per 10 minutes."
; Full-screen backup progress view (runbackup)
COM_MOKOJOOMBACKUP_RUNBACKUP_TITLE="Backing Up"
COM_MOKOJOOMBACKUP_RUNBACKUP_PROFILE="Profile: %s"
COM_MOKOJOOMBACKUP_RUNBACKUP_STARTING="Starting backup…"
COM_MOKOJOOMBACKUP_RUNBACKUP_RUNNING="Backing up…"
COM_MOKOJOOMBACKUP_RUNBACKUP_COMPLETE="Backup complete"
COM_MOKOJOOMBACKUP_RUNBACKUP_CONTINUING="Backup complete — continuing…"
COM_MOKOJOOMBACKUP_RUNBACKUP_FAILED="Backup failed"
COM_MOKOJOOMBACKUP_RUNBACKUP_RETRY="Retry backup"
COM_MOKOJOOMBACKUP_RUNBACKUP_CONTINUE_ANYWAY="Continue without backup"
COM_MOKOJOOMBACKUP_RUNBACKUP_BACK_TO_DASHBOARD="Back to dashboard"
COM_MOKOJOOMBACKUP_RUNBACKUP_LEAVE_WARNING="A backup is in progress. Leaving now will cancel it."
COM_MOKOJOOMBACKUP_CONFIG_CLEANUP="Cleanup Defaults"
COM_MOKOJOOMBACKUP_CONFIG_MAX_AGE="Max Backup Age (days)"
COM_MOKOJOOMBACKUP_CONFIG_MAX_AGE_DESC="Default maximum age for backup records. Used by the system plugin and CLI cleanup command."
@@ -382,6 +397,8 @@ COM_MOKOJOOMBACKUP_FIELD_NOTIFY_USER_GROUPS_DESC="Select Joomla user groups whos
; Dashboard warnings
COM_MOKOJOOMBACKUP_DASHBOARD_DEFAULT_DIR_WARNING_TITLE="Backup directory is inside the web root"
COM_MOKOJOOMBACKUP_DASHBOARD_DEFAULT_DIR_WARNING="One or more profiles store backups in the default directory inside the web root. This may expose backup archives if .htaccess is not supported. Move backups to a directory outside the web root for better security."
COM_MOKOJOOMBACKUP_DASHBOARD_LIVESITE_WARNING_TITLE="Site URL (live_site) is not set"
COM_MOKOJOOMBACKUP_DASHBOARD_LIVESITE_WARNING="Your Global Configuration has no Site URL (live_site). CLI and scheduled backups can't reliably determine the site's domain (archive names fall back to the server hostname), and some URLs may be generated incorrectly. Set Site URL in System → Global Configuration to your full site address including https://."
COM_MOKOJOOMBACKUP_WEB_ACCESSIBLE_WARNING="This backup is stored inside the web root and may be directly downloadable if .htaccess is not supported."
@@ -53,9 +53,24 @@ COM_MOKOJOOMBACKUP_CONFIG_SHOW_UPDATE_NOTICE_DESC="Display the update site confi
COM_MOKOJOOMBACKUP_CONFIG_PREACTION="Pre-action Backups"
COM_MOKOJOOMBACKUP_CONFIG_BACKUP_BEFORE_UPDATE="Backup Before Extension Update"
COM_MOKOJOOMBACKUP_CONFIG_BACKUP_BEFORE_UPDATE_DESC="Automatically run a full backup before any extension is updated. Uses the default profile. Throttled to once per 10 minutes to prevent duplicate backups during batch updates."
COM_MOKOJOOMBACKUP_CONFIG_PREUPDATE_AUTO_INTERCEPT="Auto-run on Update click"
COM_MOKOJOOMBACKUP_CONFIG_PREUPDATE_AUTO_INTERCEPT_DESC="When enabled, clicking the Update button on the Joomla Update or Extensions update page first pops the backup progress modal, runs the pre-update backup, then automatically continues the update. When disabled, a notice with a manual 'Back up now' button is shown instead."
COM_MOKOJOOMBACKUP_CONFIG_BACKUP_BEFORE_UNINSTALL="Backup Before Extension Uninstall"
COM_MOKOJOOMBACKUP_CONFIG_BACKUP_BEFORE_UNINSTALL_DESC="Automatically run a full backup before any extension is uninstalled. Uses the default profile. Throttled to once per 10 minutes."
; Full-screen backup progress view (runbackup)
COM_MOKOJOOMBACKUP_RUNBACKUP_TITLE="Backing Up"
COM_MOKOJOOMBACKUP_RUNBACKUP_PROFILE="Profile: %s"
COM_MOKOJOOMBACKUP_RUNBACKUP_STARTING="Starting backup…"
COM_MOKOJOOMBACKUP_RUNBACKUP_RUNNING="Backing up…"
COM_MOKOJOOMBACKUP_RUNBACKUP_COMPLETE="Backup complete"
COM_MOKOJOOMBACKUP_RUNBACKUP_CONTINUING="Backup complete — continuing…"
COM_MOKOJOOMBACKUP_RUNBACKUP_FAILED="Backup failed"
COM_MOKOJOOMBACKUP_RUNBACKUP_RETRY="Retry backup"
COM_MOKOJOOMBACKUP_RUNBACKUP_CONTINUE_ANYWAY="Continue without backup"
COM_MOKOJOOMBACKUP_RUNBACKUP_BACK_TO_DASHBOARD="Back to dashboard"
COM_MOKOJOOMBACKUP_RUNBACKUP_LEAVE_WARNING="A backup is in progress. Leaving now will cancel it."
COM_MOKOJOOMBACKUP_CONFIG_CLEANUP="Cleanup Defaults"
COM_MOKOJOOMBACKUP_CONFIG_MAX_AGE="Max Backup Age (days)"
COM_MOKOJOOMBACKUP_CONFIG_MAX_AGE_DESC="Default maximum age for backup records. Used by the system plugin and CLI cleanup command."
@@ -73,6 +88,8 @@ COM_MOKOJOOMBACKUP_FOLDER_NOT_FOUND="Directory not found"
COM_MOKOJOOMBACKUP_BACKUP_DIR_DEFAULT="Default (inside web root)"
COM_MOKOJOOMBACKUP_DASHBOARD_DEFAULT_DIR_WARNING_TITLE="Backup directory is inside the web root"
COM_MOKOJOOMBACKUP_DASHBOARD_DEFAULT_DIR_WARNING="One or more profiles store backups in the default directory inside the web root. This may expose backup archives if .htaccess is not supported. Move backups to a directory outside the web root for better security."
COM_MOKOJOOMBACKUP_DASHBOARD_LIVESITE_WARNING_TITLE="Site URL (live_site) is not set"
COM_MOKOJOOMBACKUP_DASHBOARD_LIVESITE_WARNING="Your Global Configuration has no Site URL (live_site). CLI and scheduled backups can't reliably determine the site's domain (archive names fall back to the server hostname), and some URLs may be generated incorrectly. Set Site URL in System → Global Configuration to your full site address including https://."
COM_MOKOJOOMBACKUP_WEB_ACCESSIBLE_WARNING="This backup is stored inside the web root and may be directly downloadable if .htaccess is not supported."
COM_MOKOJOOMBACKUP_FOLDER_EXISTS="Directory exists"
COM_MOKOJOOMBACKUP_FOLDER_NOT_FOUND="Directory not found"
@@ -17,7 +17,7 @@
display label there.
-->
<name>MokoSuiteBackup</name>
<version>02.58.00</version>
<version>02.58.15</version>
<creationDate>2026-06-02</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -0,0 +1 @@
/* 02.58.01 — no schema changes */
@@ -0,0 +1 @@
/* 02.58.02 — no schema changes */
@@ -0,0 +1 @@
/* 02.58.03 — no schema changes */
@@ -0,0 +1 @@
/* 02.58.04 — no schema changes */
@@ -0,0 +1 @@
/* 02.58.05 — no schema changes */
@@ -0,0 +1 @@
/* 02.58.06 — no schema changes */
@@ -0,0 +1 @@
/* 02.58.07 — no schema changes */
@@ -0,0 +1 @@
/* 02.58.08 — no schema changes */
@@ -0,0 +1 @@
/* 02.58.09 — no schema changes */
@@ -0,0 +1 @@
/* 02.58.10 — no schema changes */
@@ -0,0 +1 @@
/* 02.58.11 — no schema changes */
@@ -0,0 +1 @@
/* 02.58.12 — no schema changes */
@@ -0,0 +1 @@
/* 02.58.13 — no schema changes */
@@ -0,0 +1 @@
/* 02.58.14 — no schema changes */
@@ -0,0 +1 @@
/* 02.58.15 — no schema changes */
@@ -84,6 +84,35 @@ class AjaxController extends BaseController
$this->sendJson($result);
}
/**
* Mark the pre-update backup as satisfied for this session.
*
* Called by the update-page modal after a successful on-demand backup so the
* imminent server-side onExtensionBeforeUpdate backup is skipped — it sets
* the same 10-minute throttle key the system plugin checks, preventing a
* duplicate backup when the admin then clicks Joomla's Update button.
* POST: task=ajax.preupdateAck
*/
public function preupdateAck(): void
{
if (!Session::checkToken('get') && !Session::checkToken('post')) {
$this->sendJson(['error' => true, 'message' => 'Invalid token'], 403);
return;
}
if (!$this->app->getIdentity()->authorise('mokosuitebackup.backup.run', 'com_mokosuitebackup')) {
$this->sendJson(['error' => true, 'message' => 'Access denied'], 403);
return;
}
// Same key + semantics as plg_system_mokosuitebackup::runPreActionBackup().
Factory::getSession()->set('mokosuitebackup.preaction_backup_before_update', time());
$this->sendJson(['error' => false, 'message' => 'Pre-update backup acknowledged']);
}
/**
* Cancel a backup record stuck in "running" status.
* POST: task=ajax.cancelBackup&id=123
@@ -16,8 +16,8 @@ use Joomla\CMS\Language\Text;
use Joomla\CMS\MVC\Controller\AdminController;
use Joomla\CMS\Router\Route;
use Joomla\CMS\Session\Session;
use Joomla\Component\MokoSuiteBackup\Administrator\Engine\BackupEngine;
use Joomla\Component\MokoSuiteBackup\Administrator\Engine\RestoreEngine;
use Joomla\Component\MokoSuiteBackup\Administrator\Service\BackupRunner;
class BackupsController extends AdminController
{
@@ -54,8 +54,7 @@ class BackupsController extends AdminController
$profileId = $this->input->getInt('profile_id', 1);
$description = $this->input->getString('description', '');
$engine = new BackupEngine();
$result = $engine->run($profileId, $description, 'backend');
$result = (new BackupRunner())->run($profileId, $description, 'backend');
// Surface preflight warnings as Joomla messages
if (!empty($result['warnings'])) {
@@ -304,7 +304,18 @@ class BackupEngine
$this->log(' Upload complete: ' . $result['message']);
if (!empty($restoreScriptPath) && is_file($restoreScriptPath)) {
$uploader->upload($restoreScriptPath, basename($restoreScriptPath));
$scriptName = basename($restoreScriptPath);
$this->log(' Uploading restore script: ' . $scriptName . '...');
$scriptResult = $uploader->upload($restoreScriptPath, $scriptName);
if (!empty($scriptResult['success'])) {
$this->log(' Restore script uploaded: ' . ($scriptResult['message'] ?? $scriptName));
} else {
/* Never silent: a missing restore script makes the archive
much harder to restore, so surface it as a warning. */
$uploadFailed = true;
$this->log(' WARNING: Restore script upload failed: ' . ($scriptResult['message'] ?? 'unknown error'));
}
}
} else {
$uploadFailed = true;
@@ -500,24 +511,7 @@ class BackupEngine
*/
private function createUploaderFromParams(string $type, array $params): RemoteUploaderInterface
{
$prefixMap = ['ftp' => 'ftp_', 'sftp' => 'sftp_', 's3' => 's3_', 'google_drive' => 'gdrive_'];
$prefix = $prefixMap[$type] ?? '';
$prefixed = [];
foreach ($params as $key => $value) {
$prefixed[$prefix . $key] = $value;
}
$fake = (object) $prefixed;
return match ($type) {
'ftp' => new FtpUploader($fake),
'sftp' => new SftpUploader($fake),
'google_drive' => new GoogleDriveUploader($fake),
's3' => new S3Uploader($fake),
default => throw new \InvalidArgumentException('Unknown remote storage type: ' . $type),
};
return RemoteUploaderFactory::create($type, $params);
}
/**
@@ -89,6 +89,52 @@ class FtpUploader implements RemoteUploaderInterface
}
}
public function delete(string $remoteName): array
{
if (!extension_loaded('ftp')) {
return ['success' => false, 'message' => 'PHP ext-ftp is required for FTP deletes. Enable it in php.ini.'];
}
if (empty($this->host)) {
return ['success' => false, 'message' => 'FTP host is not configured'];
}
$conn = $this->connect();
if (!$conn) {
return ['success' => false, 'message' => 'Failed to connect to FTP server ' . $this->host . ':' . $this->port];
}
try {
if (!@ftp_login($conn, $this->username, $this->password)) {
throw new \RuntimeException('FTP login failed for user: ' . $this->username);
}
if ($this->passive) {
ftp_pasv($conn, true);
}
$remoteFile = $this->remotePath . '/' . $remoteName;
/* Already-absent files: ftp_delete returns false, but if the file
isn't there the retention goal is already met, so probe size to
decide. ftp_size returns -1 when the file does not exist. */
if (@ftp_size($conn, $remoteFile) < 0) {
return ['success' => true, 'message' => 'FTP: nothing to delete (' . $remoteFile . ')'];
}
if (!@ftp_delete($conn, $remoteFile)) {
throw new \RuntimeException('ftp_delete failed for: ' . $remoteFile);
}
return ['success' => true, 'message' => 'Deleted from FTP: ' . $remoteFile];
} catch (\Throwable $e) {
return ['success' => false, 'message' => 'FTP delete failed: ' . $e->getMessage()];
} finally {
@ftp_close($conn);
}
}
public function testConnection(): array
{
if (empty($this->host)) {
@@ -81,6 +81,59 @@ class GoogleDriveUploader implements RemoteUploaderInterface
}
}
public function delete(string $remoteName): array
{
if (!extension_loaded('curl')) {
return ['success' => false, 'message' => 'PHP ext-curl is required for Google Drive deletes. Enable it in php.ini.'];
}
if (empty($this->clientId) || empty($this->refreshToken)) {
return ['success' => false, 'message' => 'Google Drive credentials not configured'];
}
try {
$accessToken = $this->getAccessToken();
/* Drive has no path addressing resolve the file id(s) by name
within the configured folder, then delete each match. */
$q = "name = '" . str_replace("'", "\\'", $remoteName) . "' and trashed = false";
if (!empty($this->folderId)) {
$q .= " and '" . $this->folderId . "' in parents";
}
$listUrl = self::API_URL . '/files?q=' . rawurlencode($q) . '&fields=' . rawurlencode('files(id,name)') . '&pageSize=25';
$response = $this->curlRequest('GET', $listUrl, null, [
'Authorization: Bearer ' . $accessToken,
]);
if ($response['code'] !== 200) {
throw new \RuntimeException('Drive file lookup failed (HTTP ' . $response['code'] . ')');
}
$files = json_decode($response['body'], true)['files'] ?? [];
if (empty($files)) {
return ['success' => true, 'message' => 'Google Drive: nothing to delete (' . $remoteName . ')'];
}
foreach ($files as $file) {
$del = $this->curlRequest('DELETE', self::API_URL . '/files/' . rawurlencode($file['id']), null, [
'Authorization: Bearer ' . $accessToken,
]);
/* 204 = deleted, 404 = already gone — both acceptable. */
if ($del['code'] !== 204 && $del['code'] !== 200 && $del['code'] !== 404) {
return ['success' => false, 'message' => 'Google Drive delete failed for ' . $remoteName . ' (HTTP ' . $del['code'] . ')'];
}
}
return ['success' => true, 'message' => 'Deleted from Google Drive: ' . $remoteName];
} catch (\Throwable $e) {
return ['success' => false, 'message' => 'Google Drive delete failed: ' . $e->getMessage()];
}
}
public function testConnection(): array
{
if (empty($this->clientId) || empty($this->refreshToken)) {
@@ -204,7 +204,8 @@ ORIG,
'ok' => $backupCount > 0,
'hint' => 'Place one or more backup ZIP files in the same directory as ' . basename($_SERVER['SCRIPT_NAME']),
];
REPL
REPL,
$php
);
/* Modify remaining pre-checks to use getSelectedBackupFile() */
@@ -52,15 +52,25 @@ class PlaceholderResolver
{
$now = new \DateTimeImmutable('now');
/* Resolve hostname: prefer HTTP_HOST (web), then try Joomla config (CLI), then system hostname */
$rawHost = $_SERVER['HTTP_HOST'] ?? $_SERVER['SERVER_NAME'] ?? '';
/* Resolve hostname: prefer the real request host (web), then the
configured live_site (CLI), then the system hostname. Joomla's console
fills the request host with the placeholder 'joomla.invalid' (an
RFC 6761 reserved TLD used as a CLI sentinel), so treat that along
with empty/localhost as unusable and fall through; otherwise every
CLI-triggered backup would be named 'joomla.invalid_…'. */
$unusable = static function (string $h): bool {
$h = strtolower(trim($h));
if (empty($rawHost) || $rawHost === 'localhost') {
return $h === '' || $h === 'localhost' || $h === 'joomla.invalid';
};
$rawHost = (string) ($_SERVER['HTTP_HOST'] ?? $_SERVER['SERVER_NAME'] ?? '');
if ($unusable($rawHost)) {
try {
$app = Factory::getApplication();
$liveSite = $app->get('live_site', '');
$liveSite = (string) Factory::getApplication()->get('live_site', '');
if (!empty($liveSite)) {
if ($liveSite !== '') {
$parsed = parse_url($liveSite, PHP_URL_HOST);
if (!empty($parsed)) {
@@ -68,12 +78,13 @@ class PlaceholderResolver
}
}
} catch (\Throwable $e) {
/* fallback */
/* fall through to system hostname */
}
}
if (empty($rawHost)) {
$rawHost = php_uname('n');
if ($unusable($rawHost)) {
$sysHost = php_uname('n');
$rawHost = $sysHost !== '' ? $sysHost : 'site';
}
$hostname = preg_replace('/[^a-zA-Z0-9._-]/', '', $rawHost);
@@ -0,0 +1,69 @@
<?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;
/**
* Builds a remote uploader from a `#__mokosuitebackup_remotes` row.
*
* This was previously a private method duplicated in both BackupEngine and
* SteppedBackupEngine (and needed a third time by RetentionManager for remote
* deletion). It is centralised here so the type→class map and the param
* key-prefixing convention live in exactly one place.
*
* The remotes table stores per-type settings as a flat JSON object with
* un-prefixed keys (e.g. `{"host":"…","path":"/backups"}`). The concrete
* uploaders, however, read prefixed keys off a profile-shaped object
* (e.g. `sftp_host`, `s3_bucket`). This factory bridges the two.
*/
final class RemoteUploaderFactory
{
/**
* Map of remote type the key prefix its uploader expects.
*/
private const PREFIX_MAP = [
'ftp' => 'ftp_',
'sftp' => 'sftp_',
's3' => 's3_',
'google_drive' => 'gdrive_',
];
/**
* Create an uploader for the given remote type from decoded JSON params.
*
* @param string $type Remote type: ftp, sftp, s3, google_drive
* @param array $params Key-value params decoded from the remote's JSON
*
* @return RemoteUploaderInterface
*
* @throws \InvalidArgumentException On an unknown remote type
*/
public static function create(string $type, array $params): RemoteUploaderInterface
{
$prefix = self::PREFIX_MAP[$type] ?? '';
$prefixed = [];
foreach ($params as $key => $value) {
$prefixed[$prefix . $key] = $value;
}
$fake = (object) $prefixed;
return match ($type) {
'ftp' => new FtpUploader($fake),
'sftp' => new SftpUploader($fake),
'google_drive' => new GoogleDriveUploader($fake),
's3' => new S3Uploader($fake),
default => throw new \InvalidArgumentException('Unknown remote storage type: ' . $type),
};
}
}
@@ -24,6 +24,21 @@ interface RemoteUploaderInterface
*/
public function upload(string $localPath, string $remoteName): array;
/**
* Delete a previously-uploaded file from remote storage.
*
* Used by retention pruning so that expiring a local backup also removes
* its archive from every remote destination. Implementations should treat
* an already-absent remote file as success (idempotent delete), so a
* partially-cleaned destination never blocks pruning.
*
* @param string $remoteName Filename on the remote end (the same
* $remoteName that was passed to upload())
*
* @return array{success: bool, message: string}
*/
public function delete(string $remoteName): array;
/**
* Test the connection / credentials without uploading.
*
@@ -15,32 +15,54 @@ defined('_JEXEC') or die;
use Joomla\Component\MokoSuiteBackup\Administrator\Utility\BackupDirectory;
/**
* Enforces per-profile backup retention.
* Enforces per-profile backup retention the single retention authority.
*
* 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().
* number of copies (retention_count). When a profile leaves a rule at 0 the
* caller-supplied global default is used instead. A backup is pruned when
* EITHER rule matches: it is older than the effective days OR it falls outside
* the newest effective count of copies.
*
* Pruning a record removes, in this order:
* 1. the DB row (#__mokosuitebackup_records),
* 2. the local archive + its .log file, and
* 3. the archive copy on every enabled remote destination for the profile.
*
* The standalone restore script (restore.php) is intentionally NOT deleted
* from remotes: it is a single, fixed-name file that every backup overwrites,
* so newer backups still depend on it removing it while pruning an old
* archive would break restore for the copies that remain.
*
* Because records do not store which destinations they were uploaded to,
* remote deletion targets the profile's *currently enabled* remotes. Remote
* deletes are idempotent (an already-absent file counts as success) and
* best-effort: a failing remote is logged but never blocks local pruning.
*/
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.
* Called after a backup completes and from the periodic admin-side cleanup.
* 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)
* @param object $db Database driver
* @param object $profile Profile row (needs id, retention_days, retention_count)
* @param int $globalDays Fallback max age (days) when the profile's is 0
* @param int $globalCount Fallback max copies when the profile's is 0
*
* @return int Number of backup records deleted
*/
public static function prune(object $db, object $profile): int
public static function prune(object $db, object $profile, int $globalDays = 0, int $globalCount = 0): int
{
$days = (int) ($profile->retention_days ?? 0);
$count = (int) ($profile->retention_count ?? 0);
// A profile value of 0 means "use the global default".
$days = $days > 0 ? $days : $globalDays;
$count = $count > 0 ? $count : $globalCount;
// No retention configured — nothing to do.
if ($days <= 0 && $count <= 0) {
return 0;
@@ -48,7 +70,7 @@ final class RetentionManager
// Newest first, so the index is the copy's position from the top.
$query = $db->getQuery(true)
->select($db->quoteName(['id', 'absolute_path', 'backupstart']))
->select($db->quoteName(['id', 'archivename', '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'])) . ')')
@@ -61,6 +83,7 @@ final class RetentionManager
}
$cutoffTs = $days > 0 ? (time() - ($days * 86400)) : null;
$remotes = self::loadEnabledRemotes($db, (int) $profile->id);
$deleted = 0;
foreach ($records as $index => $record) {
@@ -72,7 +95,7 @@ final class RetentionManager
continue;
}
if (self::deleteRecord($db, $record)) {
if (self::deleteRecord($db, $record, $remotes)) {
$deleted++;
}
}
@@ -81,12 +104,54 @@ final class RetentionManager
}
/**
* Delete a single backup record and its on-disk archive + log file.
* Delete backup records whose profile no longer exists.
*
* 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.
* Orphans have no owning profile and therefore no remote destinations to
* reconcile, so only the local archive/log and DB row are removed.
*
* @param object $db Database driver
*
* @return int Number of orphaned records deleted
*/
private static function deleteRecord(object $db, object $record): bool
public static function pruneOrphans(object $db): int
{
$query = $db->getQuery(true)
->select($db->quoteName(['r.id', 'r.archivename', 'r.absolute_path'], ['id', 'archivename', 'absolute_path']))
->from($db->quoteName('#__mokosuitebackup_records', 'r'))
->join(
'LEFT',
$db->quoteName('#__mokosuitebackup_profiles', 'p')
. ' ON ' . $db->quoteName('p.id') . ' = ' . $db->quoteName('r.profile_id')
)
->where($db->quoteName('p.id') . ' IS NULL')
->where($db->quoteName('r.status') . ' IN (' . implode(',', array_map([$db, 'quote'], ['complete', 'warning'])) . ')');
$db->setQuery($query);
$orphans = $db->loadObjectList() ?: [];
$deleted = 0;
foreach ($orphans as $record) {
if (self::deleteRecord($db, $record, [])) {
$deleted++;
}
}
return $deleted;
}
/**
* Delete a single backup record, its on-disk archive + log file, and the
* archive copy on each enabled remote destination.
*
* The DB row is removed first; the local files are only unlinked if that
* succeeds, so a failed delete never orphans the record from its files.
* Remote deletion is best-effort and runs regardless.
*
* @param object $db Database driver
* @param object $record Record row (needs id, archivename, absolute_path)
* @param object[] $remotes Enabled remote rows (type, params) for the profile
*/
private static function deleteRecord(object $db, object $record, array $remotes): bool
{
$query = $db->getQuery(true)
->delete($db->quoteName('#__mokosuitebackup_records'))
@@ -113,6 +178,69 @@ final class RetentionManager
}
}
self::deleteFromRemotes($record, $remotes);
return true;
}
/**
* Remove the record's archive from every enabled remote destination.
*
* Best-effort: each remote is tried independently and failures are logged,
* never thrown, so one unreachable destination can't stall retention.
*
* @param object $record Record row (needs archivename)
* @param object[] $remotes Enabled remote rows (type, params)
*/
private static function deleteFromRemotes(object $record, array $remotes): void
{
$archiveName = (string) ($record->archivename ?? '');
if ($archiveName === '' || empty($remotes)) {
return;
}
foreach ($remotes as $remote) {
try {
$params = json_decode((string) ($remote->params ?? ''), true) ?: [];
$uploader = RemoteUploaderFactory::create((string) $remote->type, $params);
$result = $uploader->delete($archiveName);
if (empty($result['success'])) {
error_log(
'MokoSuiteBackup: retention could not delete ' . $archiveName
. ' from ' . $remote->type . ' remote: ' . ($result['message'] ?? 'unknown error')
);
}
} catch (\Throwable $e) {
error_log(
'MokoSuiteBackup: retention remote-delete error for ' . $archiveName
. ' (' . ($remote->type ?? '?') . '): ' . $e->getMessage()
);
}
}
}
/**
* Load the profile's enabled remote destinations (type + params only).
*
* Returns an empty array if the remotes table is missing (very old installs)
* or the profile has none, so callers can iterate unconditionally.
*/
private static function loadEnabledRemotes(object $db, int $profileId): array
{
try {
$query = $db->getQuery(true)
->select($db->quoteName(['type', 'params']))
->from($db->quoteName('#__mokosuitebackup_remotes'))
->where($db->quoteName('profile_id') . ' = ' . $profileId)
->where($db->quoteName('enabled') . ' = 1')
->order($db->quoteName('ordering') . ' ASC');
$db->setQuery($query);
return $db->loadObjectList() ?: [];
} catch (\Throwable $e) {
return [];
}
}
}
@@ -75,6 +75,54 @@ class S3Uploader implements RemoteUploaderInterface
}
}
public function delete(string $remoteName): array
{
if (!extension_loaded('curl')) {
return ['success' => false, 'message' => 'PHP ext-curl is required for S3 deletes. Enable it in php.ini.'];
}
if (empty($this->accessKey) || empty($this->secretKey) || empty($this->bucket)) {
return ['success' => false, 'message' => 'S3 credentials or bucket not configured'];
}
try {
$objectKey = ($this->path ? $this->path . '/' : '') . $remoteName;
$url = $this->getObjectUrl($objectKey);
$headers = $this->signRequest('DELETE', $url, hash('sha256', ''));
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => $url,
CURLOPT_CUSTOMREQUEST => 'DELETE',
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_TIMEOUT => 60,
]);
$response = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
if (curl_errno($ch)) {
$error = curl_error($ch);
curl_close($ch);
throw new \RuntimeException('cURL error: ' . $error);
}
curl_close($ch);
/* S3 DELETE returns 204 on success and by design also 204 when
the key never existed. 404 is treated as already-gone too. */
if ($code === 204 || $code === 200 || $code === 404) {
return ['success' => true, 'message' => 'Deleted from S3: s3://' . $this->bucket . '/' . $objectKey];
}
return ['success' => false, 'message' => 'S3 DELETE failed (HTTP ' . $code . '): ' . substr((string) $response, 0, 300)];
} catch (\Throwable $e) {
return ['success' => false, 'message' => 'S3 delete failed: ' . $e->getMessage()];
}
}
public function testConnection(): array
{
if (empty($this->accessKey) || empty($this->secretKey) || empty($this->bucket)) {
@@ -100,6 +100,40 @@ class SftpUploader implements RemoteUploaderInterface
}
}
public function delete(string $remoteName): array
{
if (empty($this->host) || empty($this->username)) {
return ['success' => false, 'message' => 'SFTP is not configured'];
}
$keyFile = null;
try {
if (!empty($this->keyData)) {
$keyFile = $this->writeTempKey();
}
/* rm -f exits 0 even when the file is already gone, so an
already-deleted remote archive is treated as success. */
$remoteFile = $this->remotePath . '/' . $remoteName;
$cmd = $this->buildSshCommand('rm -f ' . escapeshellarg($remoteFile), $keyFile);
$output = [];
$exitCode = 0;
exec($cmd . ' 2>&1', $output, $exitCode);
if ($exitCode !== 0) {
return ['success' => false, 'message' => 'SFTP delete failed: ' . implode(' ', $output)];
}
return ['success' => true, 'message' => 'Deleted from SFTP: ' . $remoteFile];
} catch (\Throwable $e) {
return ['success' => false, 'message' => 'SFTP delete failed: ' . $e->getMessage()];
} finally {
$this->cleanupTempKey($keyFile);
}
}
public function testConnection(): array
{
if (empty($this->host)) {
@@ -806,24 +806,7 @@ class SteppedBackupEngine
*/
private function createUploaderFromParams(string $type, array $params): RemoteUploaderInterface
{
$prefixMap = ['ftp' => 'ftp_', 'sftp' => 'sftp_', 's3' => 's3_', 'google_drive' => 'gdrive_'];
$prefix = $prefixMap[$type] ?? '';
$prefixed = [];
foreach ($params as $key => $value) {
$prefixed[$prefix . $key] = $value;
}
$fake = (object) $prefixed;
return match ($type) {
'ftp' => new FtpUploader($fake),
'sftp' => new SftpUploader($fake),
'google_drive' => new GoogleDriveUploader($fake),
's3' => new S3Uploader($fake),
default => throw new \InvalidArgumentException('Unknown remote storage type: ' . $type),
};
return RemoteUploaderFactory::create($type, $params);
}
}
@@ -0,0 +1,89 @@
<?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\View\Runbackup;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\Language\Text;
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
use Joomla\CMS\Toolbar\ToolbarHelper;
/**
* Full-screen "run a backup" progress view.
*
* Auto-starts the stepped backup (ajax.init loop ajax.step) and shows a
* full-page progress screen. Used both by the dashboard "Backup Now" action
* and via the system plugin's pre-update redirect as the interstitial
* between clicking Joomla's Update and the update actually running. When a
* `returnurl` is supplied the page redirects there once the backup completes
* (e.g. back to `com_joomlaupdate&task=update.install&is_backed_up=1`).
*/
class HtmlView extends BaseHtmlView
{
public int $profileId = 1;
public string $profileTitle = '';
public string $description = '';
/** Raw (possibly base64) return URL from the request; validated in the layout. */
public string $returnUrl = '';
public bool $autostart = true;
public function display($tpl = null): void
{
$input = Factory::getApplication()->getInput();
$this->profileId = (int) $input->getInt('profile_id', $input->getInt('profileid', 1));
$this->description = $input->getString('description', '');
$this->returnUrl = $input->getRaw('returnurl', '');
$this->autostart = (bool) $input->getInt('autostart', 1);
if ($this->profileId <= 0) {
$this->profileId = 1;
}
$this->profileTitle = $this->loadProfileTitle($this->profileId);
$this->addToolbar();
parent::display($tpl);
}
/**
* Look up the target profile's title for display (best-effort).
*/
private function loadProfileTitle(int $profileId): string
{
try {
$db = Factory::getContainer()->get(\Joomla\Database\DatabaseInterface::class);
$query = $db->getQuery(true)
->select($db->quoteName('title'))
->from($db->quoteName('#__mokosuitebackup_profiles'))
->where($db->quoteName('id') . ' = ' . (int) $profileId);
$db->setQuery($query);
return (string) ($db->loadResult() ?? '');
} catch (\Throwable $e) {
return '';
}
}
protected function addToolbar(): void
{
ToolbarHelper::title(
Text::_('COM_MOKOJOOMBACKUP_SHORT') . ': ' . Text::_('COM_MOKOJOOMBACKUP_RUNBACKUP_TITLE'),
'archive'
);
}
}
@@ -17,6 +17,9 @@ use Joomla\CMS\Session\Session;
$ajaxToken = Session::getFormToken();
$ajaxUrl = Route::_('index.php?option=com_mokosuitebackup&format=json', false);
$runbackupUrl = Route::_('index.php?option=com_mokosuitebackup&view=runbackup&autostart=1', false);
$liveSite = trim((string) \Joomla\CMS\Factory::getApplication()->get('live_site', ''));
?>
<?php if ($this->defaultDirWarning) : ?>
<div class="alert alert-warning d-flex align-items-center mb-3" role="alert">
@@ -31,6 +34,16 @@ $ajaxUrl = Route::_('index.php?option=com_mokosuitebackup&format=json', false)
</div>
<?php endif; ?>
<?php if ($liveSite === '') : ?>
<div class="alert alert-warning d-flex align-items-center mb-3" role="alert">
<span class="icon-warning-circle fs-4 me-3" aria-hidden="true"></span>
<div>
<strong><?php echo Text::_('COM_MOKOJOOMBACKUP_DASHBOARD_LIVESITE_WARNING_TITLE'); ?></strong><br>
<?php echo Text::_('COM_MOKOJOOMBACKUP_DASHBOARD_LIVESITE_WARNING'); ?>
</div>
</div>
<?php endif; ?>
<div class="row">
<!-- Row 1: Status Cards (clickable) -->
<div class="col-md-3 mb-3">
@@ -244,7 +257,7 @@ document.querySelectorAll('.mb-tile').forEach(function(tile) {
</option>
<?php endforeach; ?>
</select>
<button type="button" class="btn btn-primary w-100" onclick="window.mokosuitebackupStart()">
<button type="button" class="btn btn-primary w-100" onclick="mokosuitebackupGo()">
<span class="icon-download" aria-hidden="true"></span>
<?php echo Text::_('COM_MOKOJOOMBACKUP_TOOLBAR_BACKUP_NOW'); ?>
</button>
@@ -302,123 +315,18 @@ document.querySelectorAll('.mb-tile').forEach(function(tile) {
</div>
</div>
<!-- Stepped Backup Modal (reused from backups view) -->
<div id="mokosuitebackup-modal" style="display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.6); z-index:10000;">
<div style="max-width:500px; margin:10% auto; background:#fff; border-radius:8px; padding:2rem; box-shadow:0 4px 20px rgba(0,0,0,0.3);">
<h3 id="mb-modal-title" style="margin:0 0 1rem;">Backup in Progress</h3>
<div class="alert alert-warning py-1 px-2 mb-2" style="font-size:0.85rem;">
<span class="icon-warning-circle" aria-hidden="true"></span>
<strong>Do not navigate away or close this window</strong> while the backup is running.
</div>
<div style="background:#e9ecef; border-radius:4px; overflow:hidden; height:24px; margin-bottom:0.5rem;">
<div id="mb-progress-bar" style="height:100%; background:#0d6efd; transition:width 0.3s; width:0%; display:flex; align-items:center; justify-content:center; color:#fff; font-size:0.8rem; font-weight:bold;">0%</div>
</div>
<p id="mb-status" style="color:#666; font-size:0.9rem; margin:0.5rem 0;">Initializing...</p>
<p id="mb-phase" style="color:#999; font-size:0.8rem; margin:0;">Phase: init</p>
</div>
</div>
<script>
(function() {
const AJAX_URL = <?php echo json_encode($ajaxUrl); ?>;
const TOKEN_NAME = <?php echo json_encode($ajaxToken); ?>;
/* "Backup Now" navigates to the full-screen backup progress page (view=runbackup)
for the selected profile the same screen used for the pre-update backup. */
(function () {
'use strict';
var backupRunning = false;
var RUNBACKUP_URL = <?php echo json_encode($runbackupUrl); ?>;
window.addEventListener('beforeunload', function(e) {
if (backupRunning) { e.preventDefault(); e.returnValue = ''; }
});
function showModal() {
backupRunning = true;
document.getElementById('mokosuitebackup-modal').style.display = 'block';
}
function hideModal() {
backupRunning = false;
document.getElementById('mokosuitebackup-modal').style.display = 'none';
}
function updateProgress(progress, message, phase) {
const bar = document.getElementById('mb-progress-bar');
bar.style.width = progress + '%';
bar.textContent = progress + '%';
document.getElementById('mb-status').textContent = message;
document.getElementById('mb-phase').textContent = 'Phase: ' + phase;
}
async function postAjax(params) {
const form = new URLSearchParams();
form.append(TOKEN_NAME, '1');
for (const [k, v] of Object.entries(params)) {
form.append(k, v);
}
const res = await fetch(AJAX_URL, {
method: 'POST',
body: form,
headers: { 'X-Requested-With': 'XMLHttpRequest' }
});
return res.json();
}
async function startSteppedBackup() {
const profileSelect = document.getElementById('mb-profile-select');
const profileId = profileSelect ? profileSelect.value : '1';
showModal();
updateProgress(0, 'Initializing backup...', 'init');
try {
const initResult = await postAjax({
task: 'ajax.init',
profile_id: profileId
});
if (initResult.error) {
updateProgress(0, 'ERROR: ' + initResult.message, 'failed');
setTimeout(hideModal, 5000);
return;
}
// Show preflight warnings if any
if (initResult.warnings && initResult.warnings.length > 0) {
var warningEl = document.getElementById('mb-phase');
warningEl.textContent = 'Warnings: ' + initResult.warnings.join('; ');
warningEl.style.color = '#856404';
}
const sessionId = initResult.session_id;
updateProgress(initResult.progress, initResult.message, initResult.phase);
let done = false;
while (!done) {
const stepResult = await postAjax({
task: 'ajax.step',
session_id: sessionId
});
if (stepResult.error) {
updateProgress(0, 'ERROR: ' + stepResult.message, 'failed');
setTimeout(hideModal, 5000);
return;
}
updateProgress(stepResult.progress, stepResult.message, stepResult.phase);
done = stepResult.done || false;
}
document.getElementById('mb-modal-title').textContent = 'Backup Complete';
setTimeout(function() {
hideModal();
location.reload();
}, 2000);
} catch (err) {
updateProgress(0, 'ERROR: ' + err.message, 'failed');
setTimeout(hideModal, 5000);
}
}
window.mokosuitebackupStart = startSteppedBackup;
window.mokosuitebackupGo = function () {
var sel = document.getElementById('mb-profile-select');
var pid = sel ? sel.value : '1';
window.location.href = RUNBACKUP_URL + '&profile_id=' + encodeURIComponent(pid);
};
})();
</script>
@@ -0,0 +1,253 @@
<?php
/**
* @package MokoSuiteBackup
* @subpackage com_mokosuitebackup
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*
* Full-screen backup progress screen. Reuses the stepped-backup AJAX
* (ajax.init loop ajax.step). On completion, redirects to a validated
* return URL (pre-update flow) or shows a completion panel (Backup Now).
*/
defined('_JEXEC') or die;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Router\Route;
use Joomla\CMS\Session\Session;
use Joomla\CMS\Uri\Uri;
$ajaxToken = Session::getFormToken();
$ajaxUrl = Route::_('index.php?option=com_mokosuitebackup&format=json', false);
/* Validate the return URL to prevent an open redirect / javascript: XSS
(the value ends up in window.location.href). Accept ONLY:
- an absolute http(s) URL whose host matches this site, or
- a root-relative path starting with a single "/" (not "//").
This rejects javascript:, data:, vbscript: (empty host) and
protocol-relative //evil.com. Falls back to '' (no redirect). */
$safeReturnUrl = '';
if ($this->returnUrl !== '') {
$decoded = base64_decode($this->returnUrl, true);
$raw = ($decoded !== false && $decoded !== '') ? $decoded : $this->returnUrl;
$isAbsoluteHttp = (bool) preg_match('#^https?://#i', $raw);
$isRootRelative = isset($raw[0]) && $raw[0] === '/' && (!isset($raw[1]) || $raw[1] !== '/');
if ($isAbsoluteHttp || $isRootRelative) {
try {
$rootHost = Uri::getInstance(Uri::root())->getHost();
$target = new Uri($raw);
$scheme = strtolower((string) $target->getScheme());
$schemeOk = $scheme === '' || $scheme === 'http' || $scheme === 'https';
$hostOk = $target->getHost() === '' || strcasecmp($target->getHost(), $rootHost) === 0;
if ($schemeOk && $hostOk) {
$safeReturnUrl = $raw;
}
} catch (\Throwable $e) {
$safeReturnUrl = '';
}
}
}
$dashboardUrl = Route::_('index.php?option=com_mokosuitebackup&view=dashboard', false);
$config = [
'ajaxUrl' => $ajaxUrl,
'token' => $ajaxToken,
'profileId' => $this->profileId,
'description' => $this->description,
'returnUrl' => $safeReturnUrl,
'dashboardUrl' => $dashboardUrl,
'autostart' => $this->autostart,
'labels' => [
'starting' => Text::_('COM_MOKOJOOMBACKUP_RUNBACKUP_STARTING'),
'running' => Text::_('COM_MOKOJOOMBACKUP_RUNBACKUP_RUNNING'),
'complete' => Text::_('COM_MOKOJOOMBACKUP_RUNBACKUP_COMPLETE'),
'continuing' => Text::_('COM_MOKOJOOMBACKUP_RUNBACKUP_CONTINUING'),
'failed' => Text::_('COM_MOKOJOOMBACKUP_RUNBACKUP_FAILED'),
'retry' => Text::_('COM_MOKOJOOMBACKUP_RUNBACKUP_RETRY'),
'continue' => Text::_('COM_MOKOJOOMBACKUP_RUNBACKUP_CONTINUE_ANYWAY'),
'dashboard' => Text::_('COM_MOKOJOOMBACKUP_RUNBACKUP_BACK_TO_DASHBOARD'),
'leaveWarn' => Text::_('COM_MOKOJOOMBACKUP_RUNBACKUP_LEAVE_WARNING'),
],
];
?>
<div class="msb-runbackup" style="max-width:640px;margin:3rem auto;padding:0 1rem;">
<div class="card shadow-sm">
<div class="card-body p-4">
<h1 class="h4 mb-1" id="msb-rb-title">
<span class="icon-archive" aria-hidden="true"></span>
<?php echo Text::_('COM_MOKOJOOMBACKUP_RUNBACKUP_TITLE'); ?>
</h1>
<p class="text-muted mb-4" id="msb-rb-subtitle">
<?php echo htmlspecialchars(
$this->profileTitle !== ''
? Text::sprintf('COM_MOKOJOOMBACKUP_RUNBACKUP_PROFILE', $this->profileTitle)
: Text::_('COM_MOKOJOOMBACKUP_RUNBACKUP_STARTING'),
ENT_QUOTES,
'UTF-8'
); ?>
</p>
<div class="progress mb-3" style="height:1.75rem;">
<div id="msb-rb-bar" class="progress-bar progress-bar-striped progress-bar-animated"
role="progressbar" style="width:0;" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0">0%</div>
</div>
<div id="msb-rb-phase" class="fw-bold mb-1"></div>
<div id="msb-rb-status" class="text-muted small mb-3"><?php echo htmlspecialchars(Text::_('COM_MOKOJOOMBACKUP_RUNBACKUP_STARTING'), ENT_QUOTES, 'UTF-8'); ?></div>
<div id="msb-rb-actions" class="d-none">
<button type="button" id="msb-rb-retry" class="btn btn-primary d-none"></button>
<a href="#" id="msb-rb-continue" class="btn btn-warning d-none"></a>
<a href="<?php echo $dashboardUrl; ?>" id="msb-rb-dashboard" class="btn btn-secondary d-none"></a>
</div>
</div>
</div>
</div>
<script>
(function () {
'use strict';
var CFG = <?php echo json_encode($config); ?>;
var L = CFG.labels || {};
var running = false;
var el = {
bar: document.getElementById('msb-rb-bar'),
phase: document.getElementById('msb-rb-phase'),
status: document.getElementById('msb-rb-status'),
title: document.getElementById('msb-rb-title'),
actions: document.getElementById('msb-rb-actions'),
retry: document.getElementById('msb-rb-retry'),
continue: document.getElementById('msb-rb-continue'),
dashboard: document.getElementById('msb-rb-dashboard')
};
function setBar(pct, striped) {
pct = Math.max(0, Math.min(100, parseInt(pct, 10) || 0));
el.bar.style.width = pct + '%';
el.bar.textContent = pct + '%';
el.bar.setAttribute('aria-valuenow', pct);
if (!striped) {
el.bar.classList.remove('progress-bar-striped', 'progress-bar-animated');
}
}
function setPhase(t) { el.phase.textContent = t || ''; }
function setStatus(t) { if (t) { el.status.textContent = t; } }
function post(params) {
var body = new URLSearchParams();
body.append(CFG.token, '1');
Object.keys(params).forEach(function (k) { body.append(k, params[k]); });
return fetch(CFG.ajaxUrl, {
method: 'POST',
body: body,
headers: { 'X-Requested-With': 'XMLHttpRequest' }
}).then(function (r) { return r.json(); });
}
function showActions() { el.actions.classList.remove('d-none'); }
function showBtn(node, label, href) {
node.textContent = label;
if (href) { node.setAttribute('href', href); }
node.classList.remove('d-none');
}
function onComplete() {
el.bar.classList.remove('progress-bar-striped', 'progress-bar-animated');
el.bar.classList.add('bg-success');
setBar(100, false);
el.title.textContent = L.complete || 'Backup complete';
if (CFG.returnUrl) {
setStatus(L.continuing || 'Continuing…');
window.setTimeout(function () { window.location.href = CFG.returnUrl; }, 1200);
} else {
setStatus(L.complete || 'Backup complete');
showActions();
showBtn(el.dashboard, L.dashboard || 'Back to dashboard', CFG.dashboardUrl);
}
}
function onError(message) {
running = false;
el.bar.classList.remove('progress-bar-striped', 'progress-bar-animated');
el.bar.classList.add('bg-danger');
el.title.textContent = L.failed || 'Backup failed';
setStatus(message || 'Backup failed');
showActions();
el.retry.textContent = L.retry || 'Retry';
el.retry.classList.remove('d-none');
el.retry.onclick = function () {
el.retry.classList.add('d-none');
el.continue.classList.add('d-none');
el.bar.classList.remove('bg-danger');
el.bar.classList.add('progress-bar-striped', 'progress-bar-animated');
run();
};
if (CFG.returnUrl) {
showBtn(el.continue, L.continue || 'Continue without backup', CFG.returnUrl);
}
showBtn(el.dashboard, L.dashboard || 'Back to dashboard', CFG.dashboardUrl);
}
async function run() {
if (running) { return; }
running = true;
el.actions.classList.add('d-none');
setBar(0, true);
setPhase('');
setStatus(L.starting || 'Starting backup…');
try {
var init = await post({ task: 'ajax.init', profile_id: CFG.profileId, description: CFG.description });
if (!init || init.error || !init.session_id) {
throw new Error((init && init.message) || 'Could not start backup');
}
setBar(init.progress, true);
setPhase(init.phase || '');
setStatus(init.message || (L.running || 'Backing up…'));
var done = false;
while (!done) {
var step = await post({ task: 'ajax.step', session_id: init.session_id });
if (!step || step.error) {
throw new Error((step && step.message) || 'Backup step failed');
}
setBar(step.progress, true);
setPhase(step.phase || '');
setStatus(step.message || '');
done = step.done || false;
}
running = false;
onComplete();
} catch (err) {
onError(err && err.message ? err.message : String(err));
}
}
/* Warn before leaving while a backup is mid-flight. */
window.addEventListener('beforeunload', function (e) {
if (running) { e.preventDefault(); e.returnValue = L.leaveWarn || ''; }
});
if (CFG.autostart) {
document.addEventListener('DOMContentLoaded', run);
}
})();
</script>
@@ -8,7 +8,7 @@
-->
<extension type="module" client="administrator" method="upgrade">
<name>Module - MokoSuiteBackup - cPanel</name>
<version>02.58.00</version>
<version>02.58.15</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.58.00</version>
<version>02.58.15</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.58.00</version>
<version>02.58.15</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.58.00</version>
<version>02.58.15</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.58.00</version>
<version>02.58.15</version>
<creationDate>2026-06-02</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -7,3 +7,10 @@ PLG_SYSTEM_MOKOJOOMBACKUP_FIELD_MAX_AGE="Max Backup Age (days)"
PLG_SYSTEM_MOKOJOOMBACKUP_FIELD_MAX_AGE_DESC="Delete backup records older than this many days."
PLG_SYSTEM_MOKOJOOMBACKUP_FIELD_MAX_BACKUPS="Max Backup Count"
PLG_SYSTEM_MOKOJOOMBACKUP_FIELD_MAX_BACKUPS_DESC="Keep at most this many completed backup records."
PLG_SYSTEM_MOKOJOOMBACKUP_UPDATE_NOTICE="MokoSuiteBackup: a full-site backup is recommended before you update."
PLG_SYSTEM_MOKOJOOMBACKUP_BACKUP_NOW="Back up now"
PLG_SYSTEM_MOKOJOOMBACKUP_DISMISS="Dismiss"
PLG_SYSTEM_MOKOJOOMBACKUP_BACKING_UP="Backing up…"
PLG_SYSTEM_MOKOJOOMBACKUP_BACKUP_COMPLETE="Backup complete — safe to update."
PLG_SYSTEM_MOKOJOOMBACKUP_BACKUP_FAILED="Backup failed"
PLG_SYSTEM_MOKOJOOMBACKUP_BACKUP_STARTING="Starting backup…"
@@ -7,3 +7,10 @@ PLG_SYSTEM_MOKOJOOMBACKUP_FIELD_MAX_AGE="Max Backup Age (days)"
PLG_SYSTEM_MOKOJOOMBACKUP_FIELD_MAX_AGE_DESC="Delete backup records older than this many days."
PLG_SYSTEM_MOKOJOOMBACKUP_FIELD_MAX_BACKUPS="Max Backup Count"
PLG_SYSTEM_MOKOJOOMBACKUP_FIELD_MAX_BACKUPS_DESC="Keep at most this many completed backup records."
PLG_SYSTEM_MOKOJOOMBACKUP_UPDATE_NOTICE="MokoSuiteBackup: a full-site backup is recommended before you update."
PLG_SYSTEM_MOKOJOOMBACKUP_BACKUP_NOW="Back up now"
PLG_SYSTEM_MOKOJOOMBACKUP_DISMISS="Dismiss"
PLG_SYSTEM_MOKOJOOMBACKUP_BACKING_UP="Backing up…"
PLG_SYSTEM_MOKOJOOMBACKUP_BACKUP_COMPLETE="Backup complete — safe to update."
PLG_SYSTEM_MOKOJOOMBACKUP_BACKUP_FAILED="Backup failed"
PLG_SYSTEM_MOKOJOOMBACKUP_BACKUP_STARTING="Starting backup…"
@@ -7,7 +7,7 @@
-->
<extension type="plugin" group="system" method="upgrade">
<name>System - MokoSuiteBackup</name>
<version>02.58.00</version>
<version>02.58.15</version>
<creationDate>2026-06-02</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -15,6 +15,9 @@ defined('_JEXEC') or die;
use Joomla\CMS\Component\ComponentHelper;
use Joomla\CMS\Factory;
use Joomla\CMS\Plugin\CMSPlugin;
use Joomla\CMS\Session\Session;
use Joomla\CMS\Uri\Uri;
use Joomla\Component\MokoSuiteBackup\Administrator\Engine\RetentionManager;
use Joomla\Component\MokoSuiteBackup\Administrator\Service\BackupRunner;
use Joomla\Event\Event;
use Joomla\Event\SubscriberInterface;
@@ -120,6 +123,10 @@ final class MokoSuiteBackup extends CMSPlugin implements SubscriberInterface
return;
}
// Pre-update: send Joomla core-update installs through the full-screen
// backup page BEFORE the update runs (may not return from here).
$this->maybeRedirectForUpdate();
if (!(int) $this->params->get('auto_cleanup', 1)) {
return;
}
@@ -138,6 +145,82 @@ final class MokoSuiteBackup extends CMSPlugin implements SubscriberInterface
$this->cleanupOldSnapshots();
}
/**
* Send a Joomla core-update install through the full-screen backup page
* BEFORE the update runs (Akeeba-style), so no backup runs synchronously
* inside the update request (which is what white-screened large sites).
*
* Flow: user clicks "Install the update" (com_joomlaupdate&task=update.install)
* we redirect to com_mokosuitebackup&view=runbackup (full screen) with a
* returnurl back to update.install&is_backed_up=1 the backup runs on its
* own page with real progress on completion the browser returns to
* update.install, we arm the throttle so onExtensionBeforeUpdate doesn't
* duplicate the backup, and the update proceeds.
*/
private function maybeRedirectForUpdate(): void
{
$params = ComponentHelper::getParams('com_mokosuitebackup');
if (!(int) $params->get('backup_before_update', 0)) {
return;
}
$app = $this->getApplication();
$input = $app->getInput();
if ($input->getCmd('option', '') !== 'com_joomlaupdate'
|| $input->getCmd('task', '') !== 'update.install') {
return;
}
$session = Factory::getSession();
// Returned from the backup screen: arm the throttle so the synchronous
// onExtensionBeforeUpdate backup doesn't run again, then let it proceed.
if ((int) $input->getInt('is_backed_up', 0) === 1) {
$session->set('mokosuitebackup.preaction_backup_before_update', time());
return;
}
// Backed up recently already — let the update proceed.
$lastRun = (int) $session->get('mokosuitebackup.preaction_backup_before_update', 0);
if ($lastRun > 0 && (time() - $lastRun) < 600) {
return;
}
// Super Users only.
$user = $app->getIdentity();
if (!$user || $user->guest || !$user->authorise('core.admin')) {
return;
}
$token = Session::getFormToken();
$profileId = (int) $params->get('default_profile', 1);
// Where to send the browser after the backup: back to the update install.
$returnUri = new Uri(Uri::base() . 'index.php');
$returnUri->setVar('option', 'com_joomlaupdate');
$returnUri->setVar('task', 'update.install');
$returnUri->setVar('is_backed_up', '1');
$returnUri->setVar($token, '1');
// The full-screen backup page (chromeless via tmpl=component).
$redirect = new Uri(Uri::base() . 'index.php');
$redirect->setVar('option', 'com_mokosuitebackup');
$redirect->setVar('view', 'runbackup');
$redirect->setVar('tmpl', 'component');
$redirect->setVar('autostart', '1');
$redirect->setVar('profile_id', (string) $profileId);
$redirect->setVar('description', 'Pre-update backup');
$redirect->setVar('returnurl', base64_encode($returnUri->toString()));
$redirect->setVar($token, '1');
$app->redirect($redirect->toString());
}
/**
* Remove backup records and files per profile retention settings.
* Each profile can override the global max_age_days and max_backups.
@@ -241,108 +324,28 @@ final class MokoSuiteBackup extends CMSPlugin implements SubscriberInterface
private function doCleanup(): void
{
$db = Factory::getDbo();
$globalMaxAge = (int) ComponentHelper::getParams('com_mokosuitebackup')->get('max_age_days', 30);
$globalMaxCount = (int) ComponentHelper::getParams('com_mokosuitebackup')->get('max_backups', 10);
$db = Factory::getDbo();
$params = ComponentHelper::getParams('com_mokosuitebackup');
$globalMaxAge = (int) $params->get('max_age_days', 30);
$globalMaxCount = (int) $params->get('max_backups', 10);
// Load all published profiles with their retention settings
// Load all published profiles with their retention settings.
$query = $db->getQuery(true)
->select([$db->quoteName('id'), $db->quoteName('retention_days'), $db->quoteName('retention_count')])
->select($db->quoteName(['id', 'retention_days', 'retention_count']))
->from($db->quoteName('#__mokosuitebackup_profiles'))
->where($db->quoteName('published') . ' = 1');
$db->setQuery($query);
$profiles = $db->loadObjectList();
$profiles = $db->loadObjectList() ?: [];
// Delegate to the single retention authority. RetentionManager prunes by
// age OR count (with these globals as the per-profile fallback) and also
// removes each pruned archive from the profile's remote destinations.
foreach ($profiles as $profile) {
$maxAge = (int) $profile->retention_days > 0 ? (int) $profile->retention_days : $globalMaxAge;
$maxCount = (int) $profile->retention_count > 0 ? (int) $profile->retention_count : $globalMaxCount;
$pid = (int) $profile->id;
// Delete by age for this profile
$cutoff = date('Y-m-d H:i:s', strtotime("-{$maxAge} days"));
$query = $db->getQuery(true)
->select('id, absolute_path')
->from($db->quoteName('#__mokosuitebackup_records'))
->where($db->quoteName('profile_id') . ' = ' . $pid)
->where($db->quoteName('backupstart') . ' < ' . $db->quote($cutoff))
->where($db->quoteName('status') . ' = ' . $db->quote('complete'));
$db->setQuery($query);
$expired = $db->loadObjectList();
foreach ($expired as $record) {
$this->deleteBackupRecord($db, $record);
}
// Enforce max count for this profile (keep newest)
$query = $db->getQuery(true)
->select('COUNT(*)')
->from($db->quoteName('#__mokosuitebackup_records'))
->where($db->quoteName('profile_id') . ' = ' . $pid)
->where($db->quoteName('status') . ' = ' . $db->quote('complete'));
$db->setQuery($query);
$totalCount = (int) $db->loadResult();
if ($totalCount > $maxCount) {
$excess = $totalCount - $maxCount;
$query = $db->getQuery(true)
->select('id, absolute_path')
->from($db->quoteName('#__mokosuitebackup_records'))
->where($db->quoteName('profile_id') . ' = ' . $pid)
->where($db->quoteName('status') . ' = ' . $db->quote('complete'))
->order($db->quoteName('backupstart') . ' ASC');
$db->setQuery($query, 0, $excess);
$oldest = $db->loadObjectList();
foreach ($oldest as $record) {
$this->deleteBackupRecord($db, $record);
}
}
RetentionManager::prune($db, $profile, $globalMaxAge, $globalMaxCount);
}
// Also clean up orphaned records (profile deleted but records remain)
$query = $db->getQuery(true)
->select('r.id, r.absolute_path')
->from($db->quoteName('#__mokosuitebackup_records', 'r'))
->join('LEFT', $db->quoteName('#__mokosuitebackup_profiles', 'p') . ' ON p.id = r.profile_id')
->where('p.id IS NULL')
->where($db->quoteName('r.status') . ' = ' . $db->quote('complete'));
$db->setQuery($query);
$orphans = $db->loadObjectList();
foreach ($orphans as $record) {
$this->deleteBackupRecord($db, $record);
}
}
/**
* Delete a backup record and its archive file.
*/
private function deleteBackupRecord(object $db, object $record): void
{
if (!empty($record->absolute_path) && is_file($record->absolute_path)) {
if (!@unlink($record->absolute_path)) {
error_log('MokoSuiteBackup: Could not delete backup file (id=' . $record->id . '): ' . $record->absolute_path);
return;
}
$logPath = preg_replace('/\.(zip|tar\.gz)$/i', '.log', $record->absolute_path);
if (is_file($logPath)) {
@unlink($logPath);
}
}
try {
$db->setQuery(
$db->getQuery(true)
->delete($db->quoteName('#__mokosuitebackup_records'))
->where($db->quoteName('id') . ' = ' . (int) $record->id)
);
$db->execute();
} catch (\Exception $e) {
error_log('MokoSuiteBackup: Could not delete backup record ' . $record->id . ': ' . $e->getMessage());
}
// Records whose profile was deleted (local files + DB row only).
RetentionManager::pruneOrphans($db);
}
/**
@@ -388,6 +391,16 @@ final class MokoSuiteBackup extends CMSPlugin implements SubscriberInterface
$profileId = (int) $params->get('default_profile', 1);
/* This backup runs synchronously inside the extension update/uninstall
request. Without raising these limits a large site blows past
max_execution_time / memory_limit mid-backup and the update page
white-screens. Mirror the web-cron path. (Core Joomla updates are
handled by the full-screen redirect instead see onAfterRoute.) */
@set_time_limit(0);
@ini_set('max_execution_time', '0');
@ini_set('memory_limit', '512M');
@ignore_user_abort(true);
try {
$result = (new BackupRunner())->run($profileId, $description, 'preaction');
$app = Factory::getApplication();
@@ -7,7 +7,7 @@
-->
<extension type="plugin" group="task" method="upgrade">
<name>Task - MokoSuiteBackup</name>
<version>02.58.00</version>
<version>02.58.15</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.58.00</version>
<version>02.58.15</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.58.00</version>
<version>02.58.15</version>
<creationDate>2026-06-02</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
+156
View File
@@ -188,6 +188,35 @@ class Pkg_MokoSuiteBackupInstallerScript
PREPARE/EXECUTE migration that MySQL 8 rejected in Joomla's installer). */
$this->dropLegacyRemoteColumns();
/* Honesty gate: Joomla logs a failed child extension but still runs the
package postflight, so verify every bundled child actually registered
before claiming success. If any are missing, say so and do NOT print the
success / next-steps messages. (All housekeeping above runs regardless.)
Fail-open a manifest/DB glitch returns no missing children. */
$problems = $this->findMissingPackageChildren($parent);
foreach ($this->findMissingComponentTables() as $table) {
$problems[] = 'table ' . $table;
}
if (!empty($problems)) {
$safe = array_map(
static fn($m) => htmlspecialchars($m, ENT_QUOTES, 'UTF-8'),
$problems
);
$detail = count($safe) > 3
? count($safe) . ' bundled extensions/tables are missing'
: 'missing: ' . implode(', ', $safe);
Factory::getApplication()->enqueueMessage(
'<strong>MokoSuiteBackup did not install correctly</strong> — ' . $detail
. '. Review the installation log and re-install.',
'error'
);
return;
}
/* Install completion notice (install and update) */
$this->installSuccessful();
@@ -208,6 +237,133 @@ class Pkg_MokoSuiteBackupInstallerScript
}
}
/**
* Verify every bundled child extension declared in the package manifest
* actually registered in #__extensions. Joomla logs a failed child but does
* not fail the package, so this answers "did the package really install?"
* honestly. Returns human-readable labels for any missing children (empty =
* all present). Matching mirrors extension uniqueness: element + type, and
* for plugins also folder (group), since plugins are only unique that way.
*
* Fail-open: any manifest/DB error returns [] so a transient glitch can never
* turn a good install into a false failure.
*
* @param InstallerAdapter $parent Package installer adapter
*
* @return string[] Labels of missing children (empty when everything installed)
*/
private function findMissingPackageChildren(InstallerAdapter $parent): array
{
try {
$manifest = $parent->getParent()->getManifest();
if (!$manifest instanceof \SimpleXMLElement
|| !isset($manifest->files) || !isset($manifest->files->file)) {
return [];
}
$db = Factory::getContainer()->get(\Joomla\Database\DatabaseInterface::class);
$missing = [];
foreach ($manifest->files->file as $file) {
$attrs = $file->attributes();
$element = isset($attrs['id']) ? (string) $attrs['id'] : '';
$extType = isset($attrs['type']) ? (string) $attrs['type'] : '';
if ($element === '' || $extType === '') {
continue;
}
$query = $db->getQuery(true)
->select('COUNT(*)')
->from($db->quoteName('#__extensions'))
->where($db->quoteName('element') . ' = ' . $db->quote($element))
->where($db->quoteName('type') . ' = ' . $db->quote($extType));
$group = '';
/* Plugins are only unique by element + folder (group). */
if ($extType === 'plugin' && isset($attrs['group'])) {
$group = (string) $attrs['group'];
if ($group !== '') {
$query->where($db->quoteName('folder') . ' = ' . $db->quote($group));
}
}
$db->setQuery($query);
if ((int) $db->loadResult() === 0) {
$label = $extType . ' "' . $element . '"';
if ($group !== '') {
$label .= ' (' . $group . ')';
}
$missing[] = $label;
}
}
return $missing;
} catch (\Throwable $e) {
return [];
}
}
/**
* Verify the component's schema actually installed. The component has no
* installer script of its own, so this package postflight is the only place
* to catch a component whose extension row exists but whose CREATE TABLE
* statements failed (e.g. an installer-rejected migration). Reads the
* *installed* install SQL, extracts every declared table, and confirms each
* exists. Table names are derived dynamically nothing is hard-coded.
*
* Fail-open: any IO/DB/parse error returns [] so a glitch can't fail a good
* install.
*
* @return string[] Missing table names (empty when all present)
*/
private function findMissingComponentTables(): array
{
try {
$sqlFile = JPATH_ADMINISTRATOR . '/components/com_mokosuitebackup/sql/install.mysql.sql';
if (!is_file($sqlFile)) {
return [];
}
$sql = file_get_contents($sqlFile);
if ($sql === false || $sql === '') {
return [];
}
if (!preg_match_all('/CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?[`"\']?(#__[A-Za-z0-9_]+)/i', $sql, $m)) {
return [];
}
$db = Factory::getContainer()->get(\Joomla\Database\DatabaseInterface::class);
$prefix = $db->getPrefix();
/* Existing tables, compared case-insensitively. */
$existing = array_flip(array_map('strtolower', $db->getTableList()));
$missing = [];
foreach (array_unique($m[1]) as $declared) {
$real = str_replace('#__', $prefix, $declared);
if (!isset($existing[strtolower($real)])) {
$missing[] = $real;
}
}
return $missing;
} catch (\Throwable $e) {
return [];
}
}
/**
* Remove the orphaned component registration created when the component
* <name> briefly used the "Type - Name" display convention. Joomla derives a