fix(preupdate): time-bound the pre-action skip flag
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Failing after 7s

The one-shot "client already backed up" skip flag was a bare boolean with no
expiry. If a user ran the full-screen pre-update backup then abandoned the flow
(closed the tab before the re-fired update fired), the flag lingered for the
whole session and could suppress a LATER, unrelated pre-action backup — a real
update running with no backup.

Store the unix time the flag was armed (AjaxController::armPreactionSkip) and
honour the skip only within PREACTION_SKIP_TTL (300s), consuming it one-shot.
A stale flag is now ignored and the server backs up as normal.

Completes the pre-update double-backup hardening: server-side arming stops the
"ack fails -> double backup" direction; the TTL stops the "abandoned flow ->
skipped backup" direction. Fail-safe both ways.

Claude-Session: https://claude.ai/code/session_01WbGBN9VyRK61zczYWcCQ2i
This commit is contained in:
2026-07-16 19:24:37 -05:00
parent c7f0a122a4
commit 462aed0e51
3 changed files with 25 additions and 5 deletions
+1
View File
@@ -2,6 +2,7 @@
## [Unreleased]
### Fixed
- Pre-update / pre-uninstall skip flag is now **time-bounded**. It records the moment it was armed and is honoured only within a short TTL (5 min), always consumed one-shot. Previously a bare boolean: if a user ran the full-screen pre-update backup then abandoned the flow (closed the tab before the update re-fired), the flag lingered for the whole session and could silently suppress a later, unrelated pre-action backup — i.e. a real update running with **no** backup. Now a stale flag is ignored and the server backs up as normal.
- Pre-update / pre-uninstall backup can no longer run **twice**. The one-shot "skip the synchronous server-side backup" flag is now armed **server-side** the moment the full-screen backup completes (in `AjaxController::step`, keyed by the `msb_action` it is fronting), instead of relying solely on the best-effort client `ajax.preupdateAck` POST (whose failure was silently swallowed in a `catch()`). If that ack ever fails to land, the re-fired extension update/uninstall no longer triggers a second backup behind the "update is running" overlay. The flag is armed **only** on genuine completion (`done`, no error), so a failed or cancelled backup still lets the server back up before the action — the failure mode stays "one extra backup", never "no backup".
- Component **Options** page showed the raw `COM_MOKOSUITEBACKUP_CONFIGURATION` constant instead of a translated title. Joomla's `com_config` derives the Options-page title (and the component name) from the extension *element* (`com_mokosuitebackup`), but the language files defined those keys only under the legacy `COM_MOKOJOOMBACKUP_` stem. Added the element-derived `COM_MOKOSUITEBACKUP` and `COM_MOKOSUITEBACKUP_CONFIGURATION` keys to all four language files (en-GB/en-US, `.ini` and `.sys.ini`), matching the already-migrated `COM_MOKOSUITEBACKUP_ACTION_*` ACL keys. The 400+ explicitly-referenced `COM_MOKOJOOMBACKUP_*` keys are unaffected and left as-is. (#234)
@@ -144,15 +144,19 @@ class AjaxController extends BaseController
* Called from two places: ajax.step on genuine completion (authoritative) and
* ajax.preupdateAck (best-effort client ack) — either one arming the flag is
* enough, and re-arming it is harmless.
*
* Stores the unix time it was armed (not a bare bool) so the plugin can honour
* the skip only within a short TTL and never let a stale flag from an abandoned
* backup suppress a genuine, unrelated pre-action backup later in the session.
*/
private function armPreactionSkip(string $action): void
{
$session = Factory::getSession();
if ($action === 'update') {
$session->set('mokosuitebackup.skip_preaction_backup_before_update', true);
$session->set('mokosuitebackup.skip_preaction_backup_before_update', time());
} elseif ($action === 'uninstall') {
$session->set('mokosuitebackup.skip_preaction_backup_before_uninstall', true);
$session->set('mokosuitebackup.skip_preaction_backup_before_uninstall', time());
}
}
@@ -35,6 +35,17 @@ final class MokoSuiteBackup extends CMSPlugin implements SubscriberInterface
/** @var array<string,bool> Per-request cache of the one-shot "client already backed up" skip flag. */
private array $preactionSkip = [];
/**
* Max age (seconds) of the client "already backed up" skip flag that this
* plugin will honour. The flag stores the unix time it was armed (by the
* full-screen backup's ajax.step completion / ajax.preupdateAck); the re-fired
* update/uninstall follows within a second or two, so a small window is ample.
* Bounding it means a backup that was run then ABANDONED (browser closed before
* the update re-fired) cannot silently suppress a genuine, unrelated pre-action
* backup minutes or hours later in the same session.
*/
private const PREACTION_SKIP_TTL = 300;
public static function getSubscribedEvents(): array
{
return [
@@ -648,12 +659,16 @@ JS;
$skipKey = 'mokosuitebackup.skip_preaction_' . $paramName;
if (!array_key_exists($paramName, $this->preactionSkip)) {
$this->preactionSkip[$paramName] = (bool) $session->get($skipKey, false);
$session->set($skipKey, false);
/* The flag stores the unix time it was armed. Consume it one-shot (reset
to 0) and honour the skip ONLY if it was armed within the TTL — a
stale flag from an abandoned backup must not suppress a real one. */
$armedAt = (int) $session->get($skipKey, 0);
$session->set($skipKey, 0);
$this->preactionSkip[$paramName] = ($armedAt > 0 && (time() - $armedAt) <= self::PREACTION_SKIP_TTL);
}
if ($this->preactionSkip[$paramName]) {
// Client already backed up; skip this whole re-fired (possibly batch) request.
// Client already backed up (recently); skip this re-fired (possibly batch) request.
$this->preactionRan[$paramName] = true;
return;