Compare commits

...

15 Commits

Author SHA1 Message Date
jmiller 8a4c4efaad Merge pull request 'fix: installer postflight is honest about a partial/failed install' (#239) from fix/installer-honesty into dev
Universal: Auto Version Bump / Version Bump (push) Has been skipped
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Failing after 12s
2026-07-06 15:50:35 +00:00
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
30 changed files with 663 additions and 470 deletions
+1 -1
View File
@@ -5,7 +5,7 @@
# FILE INFORMATION # FILE INFORMATION
# DEFGROUP: MokoGitea.Workflow # DEFGROUP: MokoGitea.Workflow
# INGROUP: mokocli.Automation # INGROUP: mokocli.Automation
# VERSION: 02.58.09 # VERSION: 02.58.15
# BRIEF: Auto-create feature branch when an issue is opened # BRIEF: Auto-create feature branch when an issue is opened
name: "Universal: Issue Branch" name: "Universal: Issue Branch"
+4 -2
View File
@@ -3,8 +3,7 @@
## [Unreleased] ## [Unreleased]
### Added ### Added
- Pre-update backup **notice + live-progress modal** on the admin update pages (Joomla Update and Extensions → Update). Because the server-side `onExtensionBeforeUpdate` backup runs synchronously and can't drive a browser modal, the system plugin now injects a "back up before you update" notice with a **Back up now** button that runs the same stepped backup as the dashboard and shows a live progress bar. On success it pings a new `ajax.preupdateAck` endpoint, which arms the same throttle the server-side hook checks — so clicking Joomla's Update afterwards won't run a duplicate backup. Gated by the existing `show_update_notice` + `backup_before_update` params. (#196) - **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)
- Pre-update modal **phase 2 — "Auto-run on Update click"** (new opt-in param `preupdate_auto_intercept`, off by default). When enabled, clicking the toolbar **Update** button on the Joomla Update / Extensions-update page first pops the backup progress modal, runs the pre-update backup, then automatically continues the update. Implemented by wrapping `Joomla.submitbutton` for the `update.update` / `update.install` tasks only (Find Updates / Clear Cache are untouched); the manual "Back up now" notice remains as a fallback for any flow that doesn't route through `submitbutton`. (#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) - 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 ### Changed
@@ -14,6 +13,9 @@
- 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. - 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 ### 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) - 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) - 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)
+1 -1
View File
@@ -23,7 +23,7 @@ DEFGROUP: Template-Joomla
INGROUP: Template-Joomla.Documentation INGROUP: Template-Joomla.Documentation
REPO: https://git.mokoconsulting.tech/MokoConsulting/Template-Joomla REPO: https://git.mokoconsulting.tech/MokoConsulting/Template-Joomla
PATH: /SECURITY.md PATH: /SECURITY.md
VERSION: 02.58.09 VERSION: 02.58.15
BRIEF: Security vulnerability reporting and handling policy BRIEF: Security vulnerability reporting and handling policy
--> -->
@@ -151,18 +151,6 @@
<option value="1">JYES</option> <option value="1">JYES</option>
<option value="0">JNO</option> <option value="0">JNO</option>
</field> </field>
<field
name="preupdate_auto_intercept"
type="radio"
label="COM_MOKOJOOMBACKUP_CONFIG_PREUPDATE_AUTO_INTERCEPT"
description="COM_MOKOJOOMBACKUP_CONFIG_PREUPDATE_AUTO_INTERCEPT_DESC"
default="0"
class="btn-group"
showon="backup_before_update:1"
>
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
<field <field
name="backup_before_uninstall" name="backup_before_uninstall"
type="radio" type="radio"
@@ -336,6 +336,19 @@ COM_MOKOJOOMBACKUP_CONFIG_PREUPDATE_AUTO_INTERCEPT_DESC="When enabled, clicking
COM_MOKOJOOMBACKUP_CONFIG_BACKUP_BEFORE_UNINSTALL="Backup Before Extension Uninstall" 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." 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_CLEANUP="Cleanup Defaults"
COM_MOKOJOOMBACKUP_CONFIG_MAX_AGE="Max Backup Age (days)" 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." COM_MOKOJOOMBACKUP_CONFIG_MAX_AGE_DESC="Default maximum age for backup records. Used by the system plugin and CLI cleanup command."
@@ -384,6 +397,8 @@ COM_MOKOJOOMBACKUP_FIELD_NOTIFY_USER_GROUPS_DESC="Select Joomla user groups whos
; Dashboard warnings ; Dashboard warnings
COM_MOKOJOOMBACKUP_DASHBOARD_DEFAULT_DIR_WARNING_TITLE="Backup directory is inside the 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_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_WEB_ACCESSIBLE_WARNING="This backup is stored inside the web root and may be directly downloadable if .htaccess is not supported."
@@ -58,6 +58,19 @@ COM_MOKOJOOMBACKUP_CONFIG_PREUPDATE_AUTO_INTERCEPT_DESC="When enabled, clicking
COM_MOKOJOOMBACKUP_CONFIG_BACKUP_BEFORE_UNINSTALL="Backup Before Extension Uninstall" 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." 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_CLEANUP="Cleanup Defaults"
COM_MOKOJOOMBACKUP_CONFIG_MAX_AGE="Max Backup Age (days)" 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." COM_MOKOJOOMBACKUP_CONFIG_MAX_AGE_DESC="Default maximum age for backup records. Used by the system plugin and CLI cleanup command."
@@ -75,6 +88,8 @@ COM_MOKOJOOMBACKUP_FOLDER_NOT_FOUND="Directory not found"
COM_MOKOJOOMBACKUP_BACKUP_DIR_DEFAULT="Default (inside web root)" 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_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_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_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_EXISTS="Directory exists"
COM_MOKOJOOMBACKUP_FOLDER_NOT_FOUND="Directory not found" COM_MOKOJOOMBACKUP_FOLDER_NOT_FOUND="Directory not found"
@@ -17,7 +17,7 @@
display label there. display label there.
--> -->
<name>MokoSuiteBackup</name> <name>MokoSuiteBackup</name>
<version>02.58.09</version> <version>02.58.15</version>
<creationDate>2026-06-02</creationDate> <creationDate>2026-06-02</creationDate>
<author>Moko Consulting</author> <author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -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 */
@@ -52,15 +52,25 @@ class PlaceholderResolver
{ {
$now = new \DateTimeImmutable('now'); $now = new \DateTimeImmutable('now');
/* Resolve hostname: prefer HTTP_HOST (web), then try Joomla config (CLI), then system hostname */ /* Resolve hostname: prefer the real request host (web), then the
$rawHost = $_SERVER['HTTP_HOST'] ?? $_SERVER['SERVER_NAME'] ?? ''; 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 { try {
$app = Factory::getApplication(); $liveSite = (string) Factory::getApplication()->get('live_site', '');
$liveSite = $app->get('live_site', '');
if (!empty($liveSite)) { if ($liveSite !== '') {
$parsed = parse_url($liveSite, PHP_URL_HOST); $parsed = parse_url($liveSite, PHP_URL_HOST);
if (!empty($parsed)) { if (!empty($parsed)) {
@@ -68,12 +78,13 @@ class PlaceholderResolver
} }
} }
} catch (\Throwable $e) { } catch (\Throwable $e) {
/* fallback */ /* fall through to system hostname */
} }
} }
if (empty($rawHost)) { if ($unusable($rawHost)) {
$rawHost = php_uname('n'); $sysHost = php_uname('n');
$rawHost = $sysHost !== '' ? $sysHost : 'site';
} }
$hostname = preg_replace('/[^a-zA-Z0-9._-]/', '', $rawHost); $hostname = preg_replace('/[^a-zA-Z0-9._-]/', '', $rawHost);
@@ -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(); $ajaxToken = Session::getFormToken();
$ajaxUrl = Route::_('index.php?option=com_mokosuitebackup&format=json', false); $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) : ?> <?php if ($this->defaultDirWarning) : ?>
<div class="alert alert-warning d-flex align-items-center mb-3" role="alert"> <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> </div>
<?php endif; ?> <?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"> <div class="row">
<!-- Row 1: Status Cards (clickable) --> <!-- Row 1: Status Cards (clickable) -->
<div class="col-md-3 mb-3"> <div class="col-md-3 mb-3">
@@ -244,7 +257,7 @@ document.querySelectorAll('.mb-tile').forEach(function(tile) {
</option> </option>
<?php endforeach; ?> <?php endforeach; ?>
</select> </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> <span class="icon-download" aria-hidden="true"></span>
<?php echo Text::_('COM_MOKOJOOMBACKUP_TOOLBAR_BACKUP_NOW'); ?> <?php echo Text::_('COM_MOKOJOOMBACKUP_TOOLBAR_BACKUP_NOW'); ?>
</button> </button>
@@ -302,123 +315,18 @@ document.querySelectorAll('.mb-tile').forEach(function(tile) {
</div> </div>
</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> <script>
(function() { /* "Backup Now" navigates to the full-screen backup progress page (view=runbackup)
const AJAX_URL = <?php echo json_encode($ajaxUrl); ?>; for the selected profile — the same screen used for the pre-update backup. */
const TOKEN_NAME = <?php echo json_encode($ajaxToken); ?>; (function () {
'use strict';
var backupRunning = false; var RUNBACKUP_URL = <?php echo json_encode($runbackupUrl); ?>;
window.addEventListener('beforeunload', function(e) { window.mokosuitebackupGo = function () {
if (backupRunning) { e.preventDefault(); e.returnValue = ''; } var sel = document.getElementById('mb-profile-select');
}); var pid = sel ? sel.value : '1';
window.location.href = RUNBACKUP_URL + '&profile_id=' + encodeURIComponent(pid);
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;
})(); })();
</script> </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"> <extension type="module" client="administrator" method="upgrade">
<name>Module - MokoSuiteBackup - cPanel</name> <name>Module - MokoSuiteBackup - cPanel</name>
<version>02.58.09</version> <version>02.58.15</version>
<creationDate>2026-06-23</creationDate> <creationDate>2026-06-23</creationDate>
<author>Moko Consulting</author> <author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -7,7 +7,7 @@
--> -->
<extension type="plugin" group="actionlog" method="upgrade"> <extension type="plugin" group="actionlog" method="upgrade">
<name>Action Log - MokoSuiteBackup</name> <name>Action Log - MokoSuiteBackup</name>
<version>02.58.09</version> <version>02.58.15</version>
<creationDate>2026-06-04</creationDate> <creationDate>2026-06-04</creationDate>
<author>Moko Consulting</author> <author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -7,7 +7,7 @@
--> -->
<extension type="plugin" group="console" method="upgrade"> <extension type="plugin" group="console" method="upgrade">
<name>Console - MokoSuiteBackup</name> <name>Console - MokoSuiteBackup</name>
<version>02.58.09</version> <version>02.58.15</version>
<creationDate>2026-06-04</creationDate> <creationDate>2026-06-04</creationDate>
<author>Moko Consulting</author> <author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -7,7 +7,7 @@
--> -->
<extension type="plugin" group="content" method="upgrade"> <extension type="plugin" group="content" method="upgrade">
<name>Content - MokoSuiteBackup</name> <name>Content - MokoSuiteBackup</name>
<version>02.58.09</version> <version>02.58.15</version>
<creationDate>2026-06-04</creationDate> <creationDate>2026-06-04</creationDate>
<author>Moko Consulting</author> <author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="quickicon" method="upgrade"> <extension type="plugin" group="quickicon" method="upgrade">
<name>Quick Icon - MokoSuiteBackup</name> <name>Quick Icon - MokoSuiteBackup</name>
<version>02.58.09</version> <version>02.58.15</version>
<creationDate>2026-06-02</creationDate> <creationDate>2026-06-02</creationDate>
<author>Moko Consulting</author> <author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1,261 +0,0 @@
/**
* MokoSuiteBackup — pre-update backup notice + live-progress modal.
*
* Injected by plg_system_mokosuitebackup on the Joomla core-update
* (com_joomlaupdate) and extensions-update (com_installer&view=update) pages.
*
* Why a client-side modal: the server-side onExtensionBeforeUpdate backup runs
* synchronously mid-request and cannot drive a browser modal. This offers an
* on-demand "Back up now" button that runs the same stepped backup the
* dashboard uses (ajax.init then a loop of ajax.step) and shows live progress.
* On success it pings ajax.preupdateAck so the imminent server-side pre-update
* backup is skipped (no duplicate backup) when the admin clicks Update.
*
* Config comes from Joomla.getOptions('plg_system_mokosuitebackup.preupdate').
*/
(function () {
'use strict';
var cfg = (window.Joomla && Joomla.getOptions)
? Joomla.getOptions('plg_system_mokosuitebackup.preupdate', null)
: null;
if (!cfg || !cfg.ajaxUrl || !cfg.token) {
return;
}
var L = cfg.labels || {};
var running = false;
var backedUp = false;
document.addEventListener('DOMContentLoaded', function () {
injectNotice();
});
if (cfg.autoIntercept) {
setupInterception();
}
/* ── Phase 2: auto-run on the Update click ──────────────────────────────
* Wrap window.Joomla.submitbutton so clicking the toolbar Update button
* first runs the backup modal, then continues the original update once the
* backup succeeds. submitbutton is synchronous, so we can't pause it — we
* swallow the update task, run the async backup, and re-issue the original
* task ourselves on success. The notice's "Back up now" button remains a
* fallback for any flow that doesn't route through submitbutton. */
function setupInterception() {
if (!wrapSubmit()) {
document.addEventListener('DOMContentLoaded', wrapSubmit);
}
}
function wrapSubmit() {
var J = window.Joomla;
if (!J || typeof J.submitbutton !== 'function' || J.submitbutton.__msbWrapped) {
return !!(J && J.submitbutton && J.submitbutton.__msbWrapped);
}
var original = J.submitbutton;
var wrapped = function (task) {
if (!backedUp && !running && isUpdateTask(task)) {
var args = arguments;
runBackup().then(function (ok) {
if (ok) {
original.apply(window.Joomla, args);
}
});
return;
}
return original.apply(window.Joomla, arguments);
};
wrapped.__msbWrapped = true;
J.submitbutton = wrapped;
return true;
}
/* Only the actual "apply the update" tasks — never Find Updates / Clear
* Cache (update.find / update.purge) which also start with "update". */
function isUpdateTask(task) {
return task === 'update.update' || task === 'update.install';
}
function injectNotice() {
var host = document.querySelector('main') || document.querySelector('#content') || document.body;
if (!host || document.getElementById('msb-preupdate-notice')) {
return;
}
var bar = document.createElement('div');
bar.id = 'msb-preupdate-notice';
bar.setAttribute('role', 'status');
bar.style.cssText = 'display:flex;align-items:center;gap:1rem;flex-wrap:wrap;'
+ 'margin:0 0 1rem 0;padding:.75rem 1rem;border:1px solid #f0c36d;'
+ 'background:#fcf8e3;border-radius:.35rem;color:#8a6d3b;font-size:.95rem;';
var text = document.createElement('span');
text.style.cssText = 'flex:1 1 auto;';
text.textContent = L.notice || 'A full-site backup is recommended before you update.';
var btn = document.createElement('button');
btn.type = 'button';
btn.id = 'msb-preupdate-run';
btn.className = 'btn btn-warning btn-sm';
btn.textContent = L.backupNow || 'Back up now';
btn.addEventListener('click', runBackup);
var dismiss = document.createElement('button');
dismiss.type = 'button';
dismiss.className = 'btn btn-link btn-sm';
dismiss.style.cssText = 'color:#8a6d3b;';
dismiss.textContent = L.dismiss || 'Dismiss';
dismiss.addEventListener('click', function () { bar.remove(); });
bar.appendChild(text);
bar.appendChild(btn);
bar.appendChild(dismiss);
host.insertBefore(bar, host.firstChild);
}
/* ── The live-progress overlay (dependency-free, mirrors the dashboard) ── */
function buildOverlay() {
var overlay = document.createElement('div');
overlay.id = 'msb-preupdate-overlay';
overlay.style.cssText = 'position:fixed;inset:0;z-index:10000;display:flex;'
+ 'align-items:center;justify-content:center;background:rgba(0,0,0,.5);';
var box = document.createElement('div');
box.style.cssText = 'width:min(480px,92vw);background:#fff;border-radius:.5rem;'
+ 'padding:1.5rem;box-shadow:0 10px 40px rgba(0,0,0,.3);';
var title = document.createElement('h3');
title.id = 'msb-pu-title';
title.style.cssText = 'margin:0 0 1rem 0;font-size:1.15rem;';
title.textContent = L.backingUp || 'Backing up…';
var track = document.createElement('div');
track.style.cssText = 'height:1.25rem;background:#e9ecef;border-radius:.35rem;overflow:hidden;';
var barfill = document.createElement('div');
barfill.id = 'msb-pu-bar';
barfill.style.cssText = 'height:100%;width:0;background:#198754;transition:width .3s ease;';
track.appendChild(barfill);
var status = document.createElement('div');
status.id = 'msb-pu-status';
status.style.cssText = 'margin-top:.75rem;font-size:.9rem;color:#555;';
status.textContent = L.starting || 'Starting backup…';
box.appendChild(title);
box.appendChild(track);
box.appendChild(status);
overlay.appendChild(box);
document.body.appendChild(overlay);
return { overlay: overlay, title: title, bar: barfill, status: status };
}
function setProgress(ui, progress, message) {
var pct = Math.max(0, Math.min(100, parseInt(progress, 10) || 0));
ui.bar.style.width = pct + '%';
if (message) {
ui.status.textContent = message;
}
}
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(); });
}
async function runBackup() {
if (running) {
return;
}
running = true;
var runBtn = document.getElementById('msb-preupdate-run');
if (runBtn) {
runBtn.disabled = true;
}
var ui = buildOverlay();
var guard = function (e) { e.preventDefault(); e.returnValue = ''; };
window.addEventListener('beforeunload', guard);
try {
var init = await post({ task: 'ajax.init', profile_id: cfg.profileId });
if (!init || init.error || !init.session_id) {
throw new Error((init && init.message) || 'Could not start backup');
}
setProgress(ui, init.progress, init.message);
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');
}
setProgress(ui, step.progress, step.message);
done = step.done || false;
}
/* Tell the server the pre-update backup is satisfied so the
synchronous onExtensionBeforeUpdate backup is skipped. */
try { await post({ task: 'ajax.preupdateAck' }); } catch (ignore) {}
backedUp = true;
ui.title.textContent = L.complete || 'Backup complete — safe to update.';
setProgress(ui, 100, '');
var notice = document.getElementById('msb-preupdate-notice');
if (notice) {
notice.style.background = '#d1e7dd';
notice.style.borderColor = '#badbcc';
notice.style.color = '#0f5132';
}
setTimeout(function () { ui.overlay.remove(); }, 1500);
return true;
} catch (err) {
ui.title.textContent = (L.failed || 'Backup failed') + ': ' + err.message;
ui.bar.style.background = '#dc3545';
if (runBtn) {
runBtn.disabled = false;
}
return false;
} finally {
running = false;
window.removeEventListener('beforeunload', guard);
}
}
})();
@@ -7,7 +7,7 @@
--> -->
<extension type="plugin" group="system" method="upgrade"> <extension type="plugin" group="system" method="upgrade">
<name>System - MokoSuiteBackup</name> <name>System - MokoSuiteBackup</name>
<version>02.58.09</version> <version>02.58.15</version>
<creationDate>2026-06-02</creationDate> <creationDate>2026-06-02</creationDate>
<author>Moko Consulting</author> <author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -29,10 +29,6 @@
<language tag="en-GB">language/en-GB/plg_system_mokosuitebackup.sys.ini</language> <language tag="en-GB">language/en-GB/plg_system_mokosuitebackup.sys.ini</language>
</languages> </languages>
<media destination="plg_system_mokosuitebackup" folder="media">
<folder>js</folder>
</media>
<config> <config>
<fields name="params"> <fields name="params">
<fieldset name="basic"> <fieldset name="basic">
@@ -13,11 +13,8 @@ namespace Joomla\Plugin\System\MokoSuiteBackup\Extension;
defined('_JEXEC') or die; defined('_JEXEC') or die;
use Joomla\CMS\Component\ComponentHelper; use Joomla\CMS\Component\ComponentHelper;
use Joomla\CMS\Document\HtmlDocument;
use Joomla\CMS\Factory; use Joomla\CMS\Factory;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Plugin\CMSPlugin; use Joomla\CMS\Plugin\CMSPlugin;
use Joomla\CMS\Router\Route;
use Joomla\CMS\Session\Session; use Joomla\CMS\Session\Session;
use Joomla\CMS\Uri\Uri; use Joomla\CMS\Uri\Uri;
use Joomla\Component\MokoSuiteBackup\Administrator\Engine\RetentionManager; use Joomla\Component\MokoSuiteBackup\Administrator\Engine\RetentionManager;
@@ -34,7 +31,6 @@ final class MokoSuiteBackup extends CMSPlugin implements SubscriberInterface
return [ return [
'onAfterInitialise' => 'onAfterInitialise', 'onAfterInitialise' => 'onAfterInitialise',
'onAfterRoute' => 'onAfterRoute', 'onAfterRoute' => 'onAfterRoute',
'onBeforeCompileHead' => 'onBeforeCompileHead',
'onExtensionBeforeUpdate' => 'onExtensionBeforeUpdate', 'onExtensionBeforeUpdate' => 'onExtensionBeforeUpdate',
'onExtensionBeforeUninstall' => 'onExtensionBeforeUninstall', 'onExtensionBeforeUninstall' => 'onExtensionBeforeUninstall',
]; ];
@@ -127,6 +123,10 @@ final class MokoSuiteBackup extends CMSPlugin implements SubscriberInterface
return; 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)) { if (!(int) $this->params->get('auto_cleanup', 1)) {
return; return;
} }
@@ -146,74 +146,79 @@ final class MokoSuiteBackup extends CMSPlugin implements SubscriberInterface
} }
/** /**
* Inject the pre-update backup notice + live-progress modal on the admin * Send a Joomla core-update install through the full-screen backup page
* update pages (com_joomlaupdate and com_installer's update view). * BEFORE the update runs (Akeeba-style), so no backup runs synchronously
* inside the update request (which is what white-screened large sites).
* *
* The server-side onExtensionBeforeUpdate backup runs synchronously and * Flow: user clicks "Install the update" (com_joomlaupdate&task=update.install)
* cannot drive a browser modal, so this offers an on-demand "Back up now" * → we redirect to com_mokosuitebackup&view=runbackup (full screen) with a
* button that runs the same stepped backup as the dashboard and shows live * returnurl back to update.install&is_backed_up=1 → the backup runs on its
* progress. On success the client pings ajax.preupdateAck, which arms the * own page with real progress → on completion the browser returns to
* same throttle key runPreActionBackup() checks — so the subsequent server * update.install, we arm the throttle so onExtensionBeforeUpdate doesn't
* update does not run a duplicate backup. * duplicate the backup, and the update proceeds.
*/ */
public function onBeforeCompileHead(Event $event): void private function maybeRedirectForUpdate(): void
{ {
$app = $this->getApplication();
if (!$app->isClient('administrator')) {
return;
}
$input = $app->getInput();
$option = $input->getCmd('option', '');
$view = $input->getCmd('view', '');
$onUpdatePage = $option === 'com_joomlaupdate'
|| ($option === 'com_installer' && $view === 'update');
if (!$onUpdatePage) {
return;
}
$params = ComponentHelper::getParams('com_mokosuitebackup'); $params = ComponentHelper::getParams('com_mokosuitebackup');
// Respect the notice toggle and the pre-update backup feature flag. if (!(int) $params->get('backup_before_update', 0)) {
if (!(int) $params->get('show_update_notice', 1) || !(int) $params->get('backup_before_update', 0)) {
return; return;
} }
// Already backed up this session (within the throttle window)? Stay quiet. $app = $this->getApplication();
$lastRun = (int) Factory::getSession()->get('mokosuitebackup.preaction_backup_before_update', 0); $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) { if ($lastRun > 0 && (time() - $lastRun) < 600) {
return; return;
} }
$doc = $app->getDocument(); // Super Users only.
$user = $app->getIdentity();
if (!$doc instanceof HtmlDocument) { if (!$user || $user->guest || !$user->authorise('core.admin')) {
return; return;
} }
$doc->getWebAssetManager()->useScript('core'); $token = Session::getFormToken();
$profileId = (int) $params->get('default_profile', 1);
$doc->addScriptOptions('plg_system_mokosuitebackup.preupdate', [ // Where to send the browser after the backup: back to the update install.
'ajaxUrl' => Route::_('index.php?option=com_mokosuitebackup&format=json', false), $returnUri = new Uri(Uri::base() . 'index.php');
'token' => Session::getFormToken(), $returnUri->setVar('option', 'com_joomlaupdate');
'profileId' => (int) $params->get('default_profile', 1), $returnUri->setVar('task', 'update.install');
'autoIntercept' => (bool) (int) $params->get('preupdate_auto_intercept', 0), $returnUri->setVar('is_backed_up', '1');
'labels' => [ $returnUri->setVar($token, '1');
'notice' => Text::_('PLG_SYSTEM_MOKOJOOMBACKUP_UPDATE_NOTICE'),
'backupNow' => Text::_('PLG_SYSTEM_MOKOJOOMBACKUP_BACKUP_NOW'),
'dismiss' => Text::_('PLG_SYSTEM_MOKOJOOMBACKUP_DISMISS'),
'backingUp' => Text::_('PLG_SYSTEM_MOKOJOOMBACKUP_BACKING_UP'),
'starting' => Text::_('PLG_SYSTEM_MOKOJOOMBACKUP_BACKUP_STARTING'),
'complete' => Text::_('PLG_SYSTEM_MOKOJOOMBACKUP_BACKUP_COMPLETE'),
'failed' => Text::_('PLG_SYSTEM_MOKOJOOMBACKUP_BACKUP_FAILED'),
],
]);
$doc->addScript(Uri::root(true) . '/media/plg_system_mokosuitebackup/js/update-backup.js', [], ['defer' => true]); // 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());
} }
/** /**
@@ -386,6 +391,16 @@ final class MokoSuiteBackup extends CMSPlugin implements SubscriberInterface
$profileId = (int) $params->get('default_profile', 1); $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 { try {
$result = (new BackupRunner())->run($profileId, $description, 'preaction'); $result = (new BackupRunner())->run($profileId, $description, 'preaction');
$app = Factory::getApplication(); $app = Factory::getApplication();
@@ -7,7 +7,7 @@
--> -->
<extension type="plugin" group="task" method="upgrade"> <extension type="plugin" group="task" method="upgrade">
<name>Task - MokoSuiteBackup</name> <name>Task - MokoSuiteBackup</name>
<version>02.58.09</version> <version>02.58.15</version>
<creationDate>2026-06-02</creationDate> <creationDate>2026-06-02</creationDate>
<author>Moko Consulting</author> <author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -7,7 +7,7 @@
--> -->
<extension type="plugin" group="webservices" method="upgrade"> <extension type="plugin" group="webservices" method="upgrade">
<name>Web Services - MokoSuiteBackup</name> <name>Web Services - MokoSuiteBackup</name>
<version>02.58.09</version> <version>02.58.15</version>
<creationDate>2026-06-02</creationDate> <creationDate>2026-06-02</creationDate>
<author>Moko Consulting</author> <author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
+1 -1
View File
@@ -8,7 +8,7 @@
<extension type="package" method="upgrade"> <extension type="package" method="upgrade">
<name>Package - MokoSuiteBackup</name> <name>Package - MokoSuiteBackup</name>
<packagename>mokosuitebackup</packagename> <packagename>mokosuitebackup</packagename>
<version>02.58.09</version> <version>02.58.15</version>
<creationDate>2026-06-02</creationDate> <creationDate>2026-06-02</creationDate>
<author>Moko Consulting</author> <author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <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). */ PREPARE/EXECUTE migration that MySQL 8 rejected in Joomla's installer). */
$this->dropLegacyRemoteColumns(); $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) */ /* Install completion notice (install and update) */
$this->installSuccessful(); $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 * Remove the orphaned component registration created when the component
* <name> briefly used the "Type - Name" display convention. Joomla derives a * <name> briefly used the "Type - Name" display convention. Joomla derives a