fix: pre-action backup fires for every extension, not one per 10 min
Root cause of "pre-update backup only fires on the backup extension":
a coarse 600s session throttle. Whenever ANY pre-action backup ran
(including updating MokoSuiteBackup itself, or a core Joomla update), the
key mokosuitebackup.preaction_backup_before_update was armed for 10
minutes. onBeforeCompileHead read it as `recentBackup` and DISABLED the
client-side interceptor for all extensions, and runPreActionBackup was
throttled by the same window. So after updating one extension, every
other extension update silently skipped its backup for 10 minutes.
Replace the time window with "one backup per Update action":
- runPreActionBackup dedupes per-request (a batch update fires the event
once per extension in a single request -> back up once) via instance
flags, and consumes a ONE-SHOT session skip flag for the re-fired
client update/uninstall (skip once, next distinct action backs up).
- ajax.preupdateAck sets the one-shot flag for the SPECIFIC action
(update|uninstall), passed from installer-backup.js via &msb_action and
surfaced by Runbackup HtmlView -> CFG.action. A core update passes no
action, so it never suppresses an extension backup.
- onBeforeCompileHead drops the recentBackup time-suppression (the
re-fire loop is already guarded client-side by window.__msbResuming).
- The core Joomla update path uses its own key
(mokosuitebackup.core_update_backed_up), decoupled from extensions.
Claude-Session: https://claude.ai/code/session_01WbGBN9VyRK61zczYWcCQ2i
@
fix(review): open-redirect, btoa UTF-8 safety, honest duplicate-mode txn
Pre-release review findings on dev -> main:
- Return-URL open-redirect (MED): a base64 returnurl like "/\evil.com"
passed the root-relative allow-list (raw[1] is "\", not "/"), and a
browser normalises leading "/\" to "//", yielding a protocol-relative
redirect off-site. Reject any backslash or control char in the decoded
return URL outright — legitimate Joomla admin URLs never contain them.
- installer-backup.js btoa() (LOW): btoa() throws on non-Latin1 input
(e.g. a UTF-8 filter value in the list query string), and the throw was
uncaught after preventDefault(), leaving the toolbar button dead. Build
the redirect (UTF-8-safe base64) BEFORE swallowing the click; if it
still fails, bail without preventing so Joomla proceeds normally.
- Snapshot duplicate-mode transaction (HIGH): restore() wrapped duplicate
mode in transactionStart()/Rollback(), but duplicate mode writes via
Joomla Table API whose Nested tables (categories/tags) issue LOCK
TABLES, which implicitly COMMITS the open transaction in MySQL — so the
rollback promise was illusory and a mid-inject failure left a slave
half-updated. Run only the raw-DB modes (replace/create/merge) inside
the transaction; duplicate mode runs outside it, matching its existing
per-item defensive design (bad item skipped + logged, never fatal).
Claude-Session: https://claude.ai/code/session_01WbGBN9VyRK61zczYWcCQ2i
@
style(runbackup): drop max-width/margin so the card spans full width
The backup screen was centred in a 720px column; unset max-width and the
auto margins so it fills the available width. Keeps the horizontal padding.
Claude-Session: https://claude.ai/code/session_01WbGBN9VyRK61zczYWcCQ2i
@
fix: reliably intercept extension update/uninstall + polish backup screen
The full-screen pre-action backup did not fire for extension
updates/uninstalls because Joomla 6 renders the com_installer toolbar
Update/Uninstall buttons as `<button task="update.update">` web
components, not inline onclick="Joomla.submitbutton(...)". Wrapping
Joomla.submitbutton never caught them.
installer-backup.js now uses a capture-phase document click listener on
any `[task]` element matching update.update / manage.remove(/uninstall),
so it runs before the toolbar web component. It captures the checked
cid[] selection, runs the full-screen backup, and on return restores the
selection, sets the boxchecked list-guard field, and re-fires the real
toolbar button with __msbResuming set so it is not re-intercepted.
Also polishes the runbackup screen: the progress bar is now a full-bleed
strip along the bottom edge of the card (edge-to-edge, clipped to the
rounded corners) instead of a boxed bar crowding the status text, and it
is coloured from the Atum template CSS custom properties
(--template-link-color / --template-special-color / --template-success-color
/ --template-danger-color) with Bootstrap and literal fallbacks.
Claude-Session: https://claude.ai/code/session_01WbGBN9VyRK61zczYWcCQ2i
@
After the full-screen pre-update backup returns to the Joomla Update
confirm page (layout=update&is_backed_up=1), inject a small script that
ticks Joomla's 'I have taken a backup' checkbox (#joomlaupdate-confirm-
backup) and clicks the Install button (.emptystate-btnadd) so the update
continues automatically — no second manual click. Injected via
onBeforeCompileHead, super-user only, gated on backup_before_update.
Claude-Session: https://claude.ai/code/session_01WbGBN9VyRK61zczYWcCQ2i
The 'Install the update' button posts to
option=com_joomlaupdate&layout=update (Joomla's confirm/updating page),
carrying only layout=update -- no view, no task. The redirect matched
view=update / update.install, so it never fired on the Joomla Update page.
Match layout=update (plus view=update and legacy update.install), and
return the browser to the same layout flagged is_backed_up=1.
Claude-Session: https://claude.ai/code/session_01WbGBN9VyRK61zczYWcCQ2i
Finishes the deferred 'duplicate' conflict mode. Inserts snapshot content
as brand-new records with remapped IDs, leaving existing content
untouched — for master->slave 'push as new copies'.
- Uses Joomla's Table API (bootComponent MVCFactory) for Article,
Category and Module so assets, UCM entries, category/tag nested-sets
and alias uniqueness are handled by core rather than hand-rolled.
- Remaps category parent_id (parents first), article catid, module menu
assignments, and custom-field-value item_id; re-features articles.
- Tags are reused/created by title (resolveTagIds) and assigned via the
article table's newTags so UCM/tag-map stay correct.
- uniqueAlias() avoids alias collisions per scope.
- DEFENSIVE: every item is wrapped in try/catch — a bad item is skipped
and logged, never fatal — so an untested/partial import degrades
gracefully instead of corrupting content.
NOTE: still needs a real test pass on a dev site before relying on it.
Refs #237
Claude-Session: https://claude.ai/code/session_01WbGBN9VyRK61zczYWcCQ2i
Adds the first increment of snapshot transfer:
- SnapshotPortable engine: package a snapshot into a portable .msbsnap
(zip of manifest.json + snapshot.json + sha256), and ingest one back
(from a zip upload or an inline payload) by materialising a local
snapshot record.
- Admin: per-row 'Download portable snapshot' + an Import modal
(upload .msbsnap) on the Snapshots page.
- API: POST /snapshot/inject — master pushes a snapshot payload into a
slave's Web Services API; gated by a new 'Allow Snapshot Injection'
option. Route added to the webservices plugin.
- Restore engine: new 'create' mode (skip existing, insert only new) and
mode-name normalisation (overwrite->replace, create, duplicate). The
'duplicate' (insert-as-new-with-remapped-IDs) mode returns a clear
'not yet available' — deferred to a dedicated, tested pass.
- Options: 'Snapshot Transfer' fieldset — default conflict mode +
allow-inject toggle. Language keys added (en-GB fallback).
Reuses the existing mokosuitebackup.snapshot.manage ACL. NOTE: untested
end-to-end (no dev build this session) — verify download/import/inject
on a dev build.
Refs #237
Claude-Session: https://claude.ai/code/session_01WbGBN9VyRK61zczYWcCQ2i
Extend the full-screen backup screen beyond core Joomla updates:
- Extension updates/uninstalls (com_installer): update.update and
manage.remove are POST + checkToken with a checked cid[] list, so a
server-side GET redirect can't resume them. New client-side interceptor
(installer-backup.js, injected via onBeforeCompileHead on the update and
manage views) wraps Joomla.submitbutton, stores the selection, sends the
browser to view=runbackup, and on return restores the selection and
re-submits the real POST form. Gated by backup_before_update /
backup_before_uninstall, super-user only, skipped within the throttle
window.
- runbackup: on manual 'Backup Now' completion, show a 'View backup
record' button (links to view=backup&id=<record_id>); not shown for the
pre-update/uninstall flow (which hands back to Joomla). SteppedBackup
Engine now returns record_id in its step result.
- runbackup arms the pre-action throttles on success (ajax.preupdateAck,
now arming both update+uninstall keys) so the following server-side
onExtensionBefore(Update|Uninstall) backup is skipped (no duplicate).
- Plugin manifest re-registers media/js.
NOTE: untested end-to-end (no dev build available this session) — verify
the com_installer resume flow on a dev build before relying on it.
Claude-Session: https://claude.ai/code/session_01WbGBN9VyRK61zczYWcCQ2i
The redirect only matched task=update.install, which Joomla 4/5/6 do not
use. Their 'Install the update' flow server-side-redirects to the updating
page (view=update), which then extracts the downloaded package from
JavaScript (AJAX to com_joomlaupdate/extract.php). So the view=update page
LOAD is the last server-side moment before any files change.
Intercept that page load (in onAfterRoute, before com_joomlaupdate renders
it), redirect to the full-screen backup with a returnurl back to
view=update&is_backed_up=1, then the browser returns and the JS extraction
runs. update.finalise is deliberately NOT intercepted -- by then the files
are already extracted, too late for a pre-update backup. Legacy
task=update.install (Joomla 3) still handled.
Claude-Session: https://claude.ai/code/session_01WbGBN9VyRK61zczYWcCQ2i
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
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
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
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
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
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
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
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
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