Compare commits

..

1 Commits

Author SHA1 Message Date
mokogitea-actions[bot] 8b3774f1fd chore(version): pre-release bump to 02.58.28-dev [skip ci] 2026-07-12 20:51:09 +00:00
44 changed files with 191 additions and 373 deletions
+7 -4
View File
@@ -380,10 +380,13 @@ jobs:
import sys
version, date = sys.argv[1], sys.argv[2]
content = open('CHANGELOG.md').read()
old = '## [Unreleased]'
new = f'## [Unreleased]\n\n## [{version}] --- {date}'
content = content if ('## [' + version + ']') in content else content.replace(old, new, 1)
open('CHANGELOG.md', 'w').write(content)
marker = f'## [{version}]'
# Idempotent: only promote when this version header isn't already present,
# otherwise re-runs (or same-version builds) create empty duplicate headers.
if marker not in content:
new = f'## [Unreleased]\n\n{marker} --- {date}'
content = content.replace('## [Unreleased]', new, 1)
open('CHANGELOG.md', 'w').write(content)
" "$VERSION" "$DATE"
git add CHANGELOG.md
git commit -m "chore: promote changelog [Unreleased] → [${VERSION}]" || true
+4 -5
View File
@@ -131,11 +131,10 @@ jobs:
test:
name: Tests
runs-on: ubuntu-latest
# Independent job (no `needs: lint`): the Gitea Actions scheduler does not
# offer the dependent 2nd job of a needs-chain to runners, so it stalls in
# "waiting" and is reaped by ABANDONED_JOB_TIMEOUT. Guard template repos
# directly (same condition lint uses) instead of gating on lint's result.
if: ${{ !startsWith(github.event.repository.name, 'Template-') }}
needs: lint
# Run only when lint succeeded; always() forces evaluation so a skipped
# lint (e.g. template repos) skips this job cleanly instead of hanging.
if: ${{ always() && needs.lint.result == 'success' }}
steps:
- name: Checkout
+1 -1
View File
@@ -5,7 +5,7 @@
# FILE INFORMATION
# DEFGROUP: MokoGitea.Workflow
# INGROUP: mokocli.Automation
# VERSION: 02.60.00
# VERSION: 02.58.28
# BRIEF: Auto-create feature branch when an issue is opened
name: "Universal: Issue Branch"
+1 -1
View File
@@ -210,7 +210,7 @@ jobs:
- name: Check for merge conflict markers
run: |
CONFLICTS=$(grep -rn '<<<<<<< \|>>>>>>> \|^=======$' --exclude-dir='.git' --exclude-dir='.mokogitea' --include='*.php' --include='*.xml' --include='*.css' --include='*.js' --include='*.json' --include='*.md' --include='*.yml' --include='*.yaml' --include='*.ini' --include='*.txt' . 2>/dev/null | grep -v '.git/' || true)
CONFLICTS=$(grep -rn '<<<<<<< \|>>>>>>> \|^=======$' --include='*.php' --include='*.xml' --include='*.css' --include='*.js' --include='*.json' --include='*.md' --include='*.yml' --include='*.yaml' --include='*.ini' --include='*.txt' . 2>/dev/null | grep -v '.git/' || true)
if [ -n "$CONFLICTS" ]; then
echo "::error::Merge conflict markers found in source files"
echo "## Conflict Markers Found" >> $GITHUB_STEP_SUMMARY
+3 -9
View File
@@ -7,7 +7,7 @@
# INGROUP: mokocli.Release
# REPO: https://git.mokoconsulting.tech/MokoConsulting/Template-Generic
# PATH: /.mokogitea/workflows/pre-release.yml
# VERSION: 05.02.01
# VERSION: 05.02.00
# BRIEF: Auto pre-release on push to dev/alpha/beta/rc branches
name: "Universal: Pre-Release"
@@ -162,13 +162,7 @@ jobs:
git add -A
git diff --cached --quiet || {
git commit -m "chore(version): pre-release bump to ${VERSION} [skip ci]"
# Push the bump commit, but do NOT fail the release if the target branch
# is protected and the release identity is not on the push allowlist.
# The build proceeds from the in-tree bumped version regardless; if the
# push is rejected, the next run simply re-bumps from the same base.
if ! git push origin HEAD 2>&1; then
echo "::warning::Version-bump commit could not be pushed (protected branch?). Building from in-tree version ${VERSION} anyway."
fi
git push origin HEAD 2>&1
}
# Auto-detect element via manifest_element.php
@@ -280,4 +274,4 @@ jobs:
echo "| Version | \`${VERSION}\` |" >> $GITHUB_STEP_SUMMARY
echo "| Channel | ${STABILITY} |" >> $GITHUB_STEP_SUMMARY
echo "| Package | \`${ZIP_NAME}\` |" >> $GITHUB_STEP_SUMMARY
echo "| SHA-256 | \`${SHA256:-n/a}\` |" >> $GITHUB_STEP_SUMMARY
echo "| SHA-256 | \`${SHA256:-n/a}\` |" >> $GITHUB_STEP_SUMMARY
+22 -7
View File
@@ -1,19 +1,14 @@
# Changelog
## [Unreleased]
## [02.60.00] --- 2026-07-12
## [02.59.00] --- 2026-07-12
### Fixed
- Pre-update backup now fires for **every** extension update/uninstall, not just the first one in a 10-minute window. A coarse 600-second session throttle (armed after any pre-action backup — even after updating MokoSuiteBackup itself or a core Joomla update) was disabling the client-side interceptor **and** the server-side fallback for *all* extensions for 10 minutes, so distinct updates silently went un-backed-up (looked like "only the backup extension triggers it"). Replaced with **one backup per Update action**: a single batch/click still backs up once (deduped per-request), the client re-fire is skipped via a **one-shot** action-keyed flag (consumed once), and the core-update path uses its own dedicated key so it no longer suppresses extension backups.
- Pre-update backup on the **Joomla Update** page is now **seamless** (Akeeba-style): after the full-screen backup runs, the plugin auto-ticks Joomla's "I have taken a backup" checkbox and clicks Install, so the update continues automatically instead of stopping for a second manual click.
- Pre-update full-screen backup now actually fires on the **Joomla Update** page. The "Install the update" button posts to `option=com_joomlaupdate&layout=update` (Joomla's confirm/updating page) — carrying **only `layout=update`**, no `view`/`task` — so the previous match on `view=update`/`update.install` never triggered. The plugin now intercepts `layout=update` (as well as `view=update` and the legacy `update.install`) and returns the browser there flagged `is_backed_up=1`.
### Added
- **Snapshot transfer + master→slave injection (#237):** download a content snapshot as a portable **`.msbsnap`** file (zip of `manifest.json` + `snapshot.json` + a sha256 integrity check); **import** one on another site via a new Import button on the Snapshots page (materialises a local snapshot record you can then restore); and a **direct injection API**`POST /api/index.php/v1/mokosuitebackup/snapshot/inject` — so a **master** site can push a snapshot straight into a **slave**'s Web Services API. Two conflict modes ship now: **overwrite** (replace matching items by ID — master wins) and **create** (skip existing, add only new), selectable per inject call or defaulted in **Options → Snapshot Transfer**. A receiving site must opt in via the new *Allow Snapshot Injection* setting. A third **duplicate** mode inserts everything as brand-new records with **remapped IDs** (articles, categories, modules — plus their tags, custom-field values and featured flags) using Joomla's Table API so assets/UCM/nested-sets stay valid; each item is inserted defensively (a bad item is skipped and logged, never fatal).
- Full-screen backup now also fronts **extension updates and uninstalls** (Extensions → Update / Manage), not just core Joomla updates. Because `com_installer`'s `update.update` / `manage.remove` are POST actions (CSRF token + a checked `cid[]` list) that a server-side redirect can't cleanly resume, this is done client-side: the toolbar Update/Uninstall click is intercepted, the selection is captured, the full-screen backup runs, and on return the original selection is restored and the real POST form is re-submitted. Gated by `backup_before_update` / `backup_before_uninstall`, super-user only, and deduped to one backup per Update action.
- Full-screen backup now also fronts **extension updates and uninstalls** (Extensions → Update / Manage), not just core Joomla updates. Because `com_installer`'s `update.update` / `manage.remove` are POST actions (CSRF token + a checked `cid[]` list) that a server-side redirect can't cleanly resume, this is done client-side: the toolbar Update/Uninstall click is intercepted, the selection is captured, the full-screen backup runs, and on return the original selection is restored and the real POST form is re-submitted. Gated by `backup_before_update` / `backup_before_uninstall`, super-user only, and skipped if a backup already ran this session.
- Manual **"Backup Now"** completion now offers a **View backup record** button (links straight to the record that was just created). It appears only for manual backups — the pre-update / pre-uninstall flow hands control back to Joomla instead.
- **Full-screen backup screen** (`view=runbackup`) for both pre-update and manual backups, modelled on Akeeba's Backup-on-Update — replaces the earlier popup-modal approach. Clicking Joomla's **Install the update** now redirects (server-side, Akeeba-style) to a dedicated full-page backup screen that runs the stepped backup with a real progress bar and then **automatically continues the update** via a validated `returnurl` (flagged `is_backed_up=1` so the backup isn't repeated). Crucially, **no backup runs synchronously inside the update request** — which is what white-screened large sites. The dashboard **Backup Now** now opens the same full-screen screen instead of an inline modal. (#196)
- Retention now prunes **remote** copies too: when a backup is pruned by age/count, its archive is deleted from every enabled remote destination (SFTP / FTP / S3 / Google Drive), not just the local copy. Each uploader gained an idempotent `delete()` method (already-absent file = success), and removal is best-effort — a failing destination is logged but never blocks local pruning. The shared standalone `restore.php` is intentionally left in place (every backup overwrites it, so newer backups still depend on it). (#229)
@@ -64,3 +59,23 @@
### Fixed
- Package `postflight` now removes the orphaned `com_component-mokosuitebackup` registration (stray extension record, admin menu items, schema rows, and `administrator/` folder) left behind by the old "Type - Name" component `<name>` — self-healing on update so affected sites reconcile to the real `com_mokosuitebackup`. (#213 follow-up)
## [02.56.08] --- 2026-07-05
### Fixed
- Component manifest `<name>` reverted to the element-safe `MokoSuiteBackup`. The "Type - Name" convention derived element `com_component-mokosuitebackup`, registering a duplicate component and leaving the real `com_mokosuitebackup` orphaned on the old version. (#213)
## [02.56.07] --- 2026-07-05
### Security
- Mask the FTP password in `AjaxController::maskSecrets()`/`mergeExistingSecrets()` — stored FTP remotes no longer leak their password via `listRemotes`. (#169)
- Stop the API single-item view (`Backups/JsonapiView`) leaking the server `absolute_path`. (#187)
- SFTP uploader uses `StrictHostKeyChecking=accept-new` instead of `no`. (#182)
### Fixed
- `DatabaseDumper` writes a real newline after `DROP TABLE` (was a literal `\n`). (#179)
- Profile-picker forms order by `id` instead of the dropped `ordering` column. (#180)
- `uninstall.mysql.sql` now drops the snapshots table. (#181)
- CLI snapshot delete uses the `data_file` column (was non-existent `file_path`). (#184)
- Remove the duplicate pre-update backup (content plugin no longer subscribes to `onExtensionBeforeUpdate`). (#186)
- `DatabaseImporter` collects non-fatal statement errors (`getErrors()`/`hasErrors()`). (#188)
+1 -1
View File
@@ -23,7 +23,7 @@ DEFGROUP: Template-Joomla
INGROUP: Template-Joomla.Documentation
REPO: https://git.mokoconsulting.tech/MokoConsulting/Template-Joomla
PATH: /SECURITY.md
VERSION: 02.60.00
VERSION: 02.58.28
BRIEF: Security vulnerability reporting and handling policy
-->
@@ -1,84 +0,0 @@
/*
* @package MokoSuiteBackup
* @subpackage com_mokosuitebackup
* @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 (view=runbackup). Loaded via the Joomla
* Web Asset Manager (asset com_mokosuitebackup.runbackup).
*/
/* Full-screen backup runs on a black backdrop. */
html,
body {
background-color: #000 !important;
}
/* Colours are pulled from the Atum admin template's own CSS custom properties
so the screen matches whatever theme the site runs, with Bootstrap and then
a literal as progressive fallbacks. */
.msb-runbackup {
--msb-accent: var(--template-link-color, var(--bs-link-color, #2a69b8));
--msb-accent2: var(--template-special-color, var(--msb-accent));
--msb-success: var(--template-success-color, var(--bs-success, #2f7d32));
--msb-danger: var(--template-danger-color, var(--bs-danger, #c52128));
--msb-track: var(--template-bg-light, var(--bs-gray-200, #edf0f5));
padding: 0 1rem;
}
/* overflow:hidden clips the full-bleed bar to the card's rounded corners. */
.msb-runbackup .card {
overflow: hidden;
}
.msb-rb-progress {
height: 2.5rem;
background: var(--msb-track);
overflow: hidden;
}
.msb-rb-bar {
position: relative;
height: 100%;
width: 0;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-weight: 600;
font-size: .95rem;
white-space: nowrap;
background-color: var(--msb-accent);
background-image: linear-gradient(90deg, var(--msb-accent), var(--msb-accent2));
transition: width .35s ease;
}
.msb-rb-bar.is-animated::after {
content: "";
position: absolute;
inset: 0;
background-image: linear-gradient(45deg, rgba(255, 255, 255, .18) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .18) 50%, rgba(255, 255, 255, .18) 75%, transparent 75%, transparent);
background-size: 1rem 1rem;
animation: msb-rb-stripes 1s linear infinite;
}
.msb-rb-bar.is-success {
background-image: none;
background-color: var(--msb-success);
}
.msb-rb-bar.is-danger {
background-image: none;
background-color: var(--msb-danger);
}
@keyframes msb-rb-stripes {
from {
background-position: 1rem 0;
}
to {
background-position: 0 0;
}
}
@@ -9,11 +9,6 @@
"name": "com_mokosuitebackup.admin",
"type": "style",
"uri": "com_mokosuitebackup/css/admin.css"
},
{
"name": "com_mokosuitebackup.runbackup",
"type": "style",
"uri": "com_mokosuitebackup/css/runbackup.css"
}
]
}
@@ -17,7 +17,7 @@
display label there.
-->
<name>MokoSuiteBackup</name>
<version>02.60.00</version>
<version>02.58.28</version>
<creationDate>2026-06-02</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -0,0 +1 @@
/* 02.58.28 — no schema changes */
@@ -1 +0,0 @@
/* 02.58.29 — no schema changes */
@@ -1 +0,0 @@
/* 02.58.30 — no schema changes */
@@ -1 +0,0 @@
/* 02.58.31 — no schema changes */
@@ -1 +0,0 @@
/* 02.58.32 — no schema changes */
@@ -1 +0,0 @@
/* 02.58.35 — no schema changes */
@@ -1 +0,0 @@
/* 02.58.36 — no schema changes */
@@ -1 +0,0 @@
/* 02.58.37 — no schema changes */
@@ -1 +0,0 @@
/* 02.59.00 — no schema changes */
@@ -1 +0,0 @@
/* 02.59.01 — no schema changes */
@@ -1 +0,0 @@
/* 02.59.02 — no schema changes */
@@ -1 +0,0 @@
/* 02.59.03 — no schema changes */
@@ -1 +0,0 @@
/* 02.59.04 — no schema changes */
@@ -1 +0,0 @@
/* 02.59.05 — no schema changes */
@@ -1 +0,0 @@
/* 02.59.06 — no schema changes */
@@ -1 +0,0 @@
/* 02.59.07 — no schema changes */
@@ -1 +0,0 @@
/* 02.59.08 — no schema changes */
@@ -1 +0,0 @@
/* 02.59.09 — no schema changes */
@@ -1 +0,0 @@
/* 02.60.00 — no schema changes */
@@ -89,11 +89,11 @@ class AjaxController extends BaseController
*
* Called by the full-screen backup screen after a successful backup so the
* imminent server-side onExtensionBeforeUpdate / onExtensionBeforeUninstall
* backup is skipped once — it sets a ONE-SHOT skip flag (keyed by the
* `action` the backup was fronting) that plg_system_mokosuitebackup
* consumes on the re-fired update/uninstall request, so exactly that one
* action is not double-backed-up while later, distinct actions still are.
* POST: task=ajax.preupdateAck&action=update|uninstall
* backup is skipped — it arms the same 10-minute throttle keys the system
* plugin checks, preventing a duplicate backup when the update / uninstall
* then proceeds. Arms BOTH keys so a single ack covers whichever action the
* full-screen backup was fronting.
* POST: task=ajax.preupdateAck
*/
public function preupdateAck(): void
{
@@ -109,21 +109,11 @@ class AjaxController extends BaseController
return;
}
/* Set a ONE-SHOT skip flag for the specific action the full-screen backup
was fronting. runPreActionBackup() consumes it on the re-fired
update/uninstall request (once), so the synchronous backup is skipped
that one time but the NEXT distinct action still backs up. Keyed by
action so an update ack never suppresses a later, unrelated uninstall.
For a core Joomla update (no action) neither flag is set — a core
update doesn't fire onExtensionBefore(Update|Uninstall). */
$action = $this->app->getInput()->getCmd('action', '');
// Same keys + semantics as plg_system_mokosuitebackup::runPreActionBackup().
$session = Factory::getSession();
if ($action === 'update') {
$session->set('mokosuitebackup.skip_preaction_backup_before_update', true);
} elseif ($action === 'uninstall') {
$session->set('mokosuitebackup.skip_preaction_backup_before_uninstall', true);
}
$now = time();
$session->set('mokosuitebackup.preaction_backup_before_update', $now);
$session->set('mokosuitebackup.preaction_backup_before_uninstall', $now);
$this->sendJson(['error' => false, 'message' => 'Pre-action backup acknowledged']);
}
@@ -125,23 +125,11 @@ class SnapshotRestoreEngine
$prefix = $db->getPrefix();
$totalRows = 0;
// Build list of tables to restore based on selected types
$tablesToRestore = $this->getTablesToRestore($restoreTypes);
/* Duplicate mode inserts brand-new records through Joomla's Table API,
whose Nested tables (categories, tags) issue LOCK TABLES — which
implicitly COMMITS any open transaction in MySQL/InnoDB. Wrapping it
in transactionStart()/Rollback() would be a false atomicity promise:
the first category/tag store would commit and defeat the rollback.
Duplicate mode is therefore best-effort and per-item defensive (a bad
item is skipped and logged, never fatal) rather than all-or-nothing.
The raw-DB modes (replace/create/merge) remain fully transactional. */
$useTransaction = ($mode !== 'duplicate');
try {
if ($useTransaction) {
$db->transactionStart();
}
$db->transactionStart();
// Build list of tables to restore based on selected types
$tablesToRestore = $this->getTablesToRestore($restoreTypes);
if ($mode === 'duplicate') {
$totalRows = $this->restoreDuplicate($data, $restoreTypes);
@@ -168,9 +156,7 @@ class SnapshotRestoreEngine
}
}
if ($useTransaction) {
$db->transactionCommit();
}
$db->transactionCommit();
$this->log('Restore complete: ' . $totalRows . ' total rows');
@@ -202,15 +188,11 @@ class SnapshotRestoreEngine
'log' => implode("\n", $this->log),
];
} catch (\Throwable $e) {
if ($useTransaction) {
try {
$db->transactionRollback();
$this->log('Transaction rolled back');
} catch (\Exception $rollbackEx) {
$this->log('Rollback failed: ' . $rollbackEx->getMessage());
}
} else {
$this->log('Duplicate mode is not transactional; records already inserted are left in place.');
try {
$db->transactionRollback();
$this->log('Transaction rolled back');
} catch (\Exception $rollbackEx) {
$this->log('Rollback failed: ' . $rollbackEx->getMessage());
}
$this->log('FATAL: ' . $e->getMessage());
@@ -40,9 +40,6 @@ class HtmlView extends BaseHtmlView
public bool $autostart = true;
/** Which action the backup fronts ('update' | 'uninstall' | ''); drives the completion ack skip flag. */
public string $action = '';
public function display($tpl = null): void
{
$input = Factory::getApplication()->getInput();
@@ -51,7 +48,6 @@ class HtmlView extends BaseHtmlView
$this->description = $input->getString('description', '');
$this->returnUrl = $input->getRaw('returnurl', '');
$this->autostart = (bool) $input->getInt('autostart', 1);
$this->action = $input->getCmd('msb_action', '');
if ($this->profileId <= 0) {
$this->profileId = 1;
@@ -34,14 +34,6 @@ if ($this->returnUrl !== '') {
$decoded = base64_decode($this->returnUrl, true);
$raw = ($decoded !== false && $decoded !== '') ? $decoded : $this->returnUrl;
/* Reject any backslash or control char outright: legitimate Joomla admin
return URLs never contain them, and a browser normalises a leading "/\"
to "//", turning a would-be root-relative path into a protocol-relative
open redirect (e.g. "/\evil.com" → "//evil.com"). */
if (preg_match('/[\x00-\x1f\x7f\\\\]/', $raw)) {
$raw = '';
}
$isAbsoluteHttp = (bool) preg_match('#^https?://#i', $raw);
$isRootRelative = isset($raw[0]) && $raw[0] === '/' && (!isset($raw[1]) || $raw[1] !== '/');
@@ -71,7 +63,6 @@ $config = [
'profileId' => $this->profileId,
'description' => $this->description,
'returnUrl' => $safeReturnUrl,
'action' => $this->action,
'dashboardUrl' => $dashboardUrl,
'recordUrl' => Route::_('index.php?option=com_mokosuitebackup&view=backup&id=__MSBID__', false),
'autostart' => $this->autostart,
@@ -88,14 +79,10 @@ $config = [
'leaveWarn' => Text::_('COM_MOKOJOOMBACKUP_RUNBACKUP_LEAVE_WARNING'),
],
];
// All screen CSS loads via the Joomla Web Asset Manager (media/joomla.asset.json),
// not an inline <style> block.
$this->getDocument()->getWebAssetManager()->useStyle('com_mokosuitebackup.runbackup');
?>
<div class="msb-runbackup">
<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 pb-3">
<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'); ?>
@@ -110,21 +97,21 @@ $this->getDocument()->getWebAssetManager()->useStyle('com_mokosuitebackup.runbac
); ?>
</p>
<div id="msb-rb-phase" class="fw-bold mb-1"></div>
<div id="msb-rb-status" class="text-muted small mb-0"><?php echo htmlspecialchars(Text::_('COM_MOKOJOOMBACKUP_RUNBACKUP_STARTING'), ENT_QUOTES, 'UTF-8'); ?></div>
<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-actions" class="d-none mt-3">
<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-record" class="btn btn-primary d-none"></a>
<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>
<!-- Full-bleed progress bar, edge-to-edge along the bottom of the card. -->
<div class="msb-rb-progress" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0">
<div id="msb-rb-bar" class="msb-rb-bar is-animated">0%</div>
</div>
</div>
</div>
@@ -139,7 +126,6 @@ $this->getDocument()->getWebAssetManager()->useStyle('com_mokosuitebackup.runbac
var el = {
bar: document.getElementById('msb-rb-bar'),
track: document.querySelector('.msb-rb-progress'),
phase: document.getElementById('msb-rb-phase'),
status: document.getElementById('msb-rb-status'),
title: document.getElementById('msb-rb-title'),
@@ -154,9 +140,9 @@ $this->getDocument()->getWebAssetManager()->useStyle('com_mokosuitebackup.runbac
pct = Math.max(0, Math.min(100, parseInt(pct, 10) || 0));
el.bar.style.width = pct + '%';
el.bar.textContent = pct + '%';
if (el.track) { el.track.setAttribute('aria-valuenow', pct); }
el.bar.setAttribute('aria-valuenow', pct);
if (!striped) {
el.bar.classList.remove('is-animated');
el.bar.classList.remove('progress-bar-striped', 'progress-bar-animated');
}
}
function setPhase(t) { el.phase.textContent = t || ''; }
@@ -181,16 +167,15 @@ $this->getDocument()->getWebAssetManager()->useStyle('com_mokosuitebackup.runbac
}
async function onComplete() {
el.bar.classList.remove('is-animated');
el.bar.classList.add('is-success');
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';
/* Set the one-shot skip flag for THIS action so the following
server-side onExtensionBefore(Update|Uninstall) backup is skipped once
(no duplicate backup) without suppressing later, distinct actions.
/* Arm the pre-action throttles so a following server-side onExtension
Before(Update|Uninstall) backup is skipped (no duplicate backup).
Awaited so it lands before we hand back to the update/uninstall. */
try { await post({ task: 'ajax.preupdateAck', action: CFG.action || '' }); } catch (e) {}
try { await post({ task: 'ajax.preupdateAck' }); } catch (e) {}
if (CFG.returnUrl) {
/* Pre-update / pre-uninstall flow: continue the original action.
@@ -213,8 +198,8 @@ $this->getDocument()->getWebAssetManager()->useStyle('com_mokosuitebackup.runbac
function onError(message) {
running = false;
el.bar.classList.remove('is-animated');
el.bar.classList.add('is-danger');
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();
@@ -224,8 +209,8 @@ $this->getDocument()->getWebAssetManager()->useStyle('com_mokosuitebackup.runbac
el.retry.onclick = function () {
el.retry.classList.add('d-none');
el.continue.classList.add('d-none');
el.bar.classList.remove('is-danger');
el.bar.classList.add('is-animated');
el.bar.classList.remove('bg-danger');
el.bar.classList.add('progress-bar-striped', 'progress-bar-animated');
run();
};
@@ -8,7 +8,7 @@
-->
<extension type="module" client="administrator" method="upgrade">
<name>Module - MokoSuiteBackup - cPanel</name>
<version>02.60.00</version>
<version>02.58.28</version>
<creationDate>2026-06-23</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -7,7 +7,7 @@
-->
<extension type="plugin" group="actionlog" method="upgrade">
<name>Action Log - MokoSuiteBackup</name>
<version>02.60.00</version>
<version>02.58.28</version>
<creationDate>2026-06-04</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -7,7 +7,7 @@
-->
<extension type="plugin" group="console" method="upgrade">
<name>Console - MokoSuiteBackup</name>
<version>02.60.00</version>
<version>02.58.28</version>
<creationDate>2026-06-04</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -7,7 +7,7 @@
-->
<extension type="plugin" group="content" method="upgrade">
<name>Content - MokoSuiteBackup</name>
<version>02.60.00</version>
<version>02.58.28</version>
<creationDate>2026-06-04</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="quickicon" method="upgrade">
<name>Quick Icon - MokoSuiteBackup</name>
<version>02.60.00</version>
<version>02.58.28</version>
<creationDate>2026-06-02</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1,17 +1,17 @@
/**
* MokoSuiteBackup — full-screen backup before an EXTENSION update / uninstall.
*
* Injected by plg_system_mokosuitebackup on Extensions → Update and
* Extensions → Manage. Joomla 6 renders those toolbar buttons as web
* components (`<button task="update.update">`), NOT inline
* onclick="Joomla.submitbutton(...)", so wrapping Joomla.submitbutton is
* unreliable. Instead we add a CAPTURE-phase click listener on any element
* carrying a matching `task` attribute — it runs before the toolbar web
* component, so we can stop it, run the full-screen backup, and on return
* restore the checked cid[] selection and re-fire the button.
* Injected by plg_system_mokosuitebackup on the Extensions → Update and
* Extensions → Manage pages. Unlike the core Joomla update (which has a
* server-side page load we can redirect), com_installer's update.update and
* manage.remove are POST actions with a CSRF token and a checked cid[] list —
* so we intercept CLIENT-SIDE: capture the selection, go to the full-screen
* backup page, and on return restore the selection and re-submit the real POST
* form (fresh token) so the update/uninstall proceeds.
*
* The full-screen page arms the pre-action throttle on success, so the
* server-side onExtensionBefore(Update|Uninstall) backup is then skipped.
* The full-screen page arms the pre-action throttle on success (ajax.preupdate
* Ack), so the server-side onExtensionBefore(Update|Uninstall) backup is then
* skipped — no duplicate backup.
*
* Config: Joomla.getOptions('plg_system_mokosuitebackup.installer').
*/
@@ -28,81 +28,26 @@
var STORAGE_KEY = 'msbInstallerBackup';
/* Toolbar tasks to intercept, gated by which feature is enabled. */
var TASKS = {};
if (cfg.backupBeforeUpdate) { TASKS['update.update'] = true; }
if (cfg.backupBeforeUninstall) { TASKS['manage.remove'] = true; TASKS['manage.uninstall'] = true; }
if (cfg.backupBeforeUninstall) { TASKS['manage.remove'] = true; }
function checkedCids() {
var form = document.getElementById('adminForm');
var cids = [];
if (form) {
form.querySelectorAll('input[name="cid[]"]:checked').forEach(function (b) { cids.push(b.value); });
form.querySelectorAll('input[name="cid[]"]:checked').forEach(function (b) {
cids.push(b.value);
});
}
return cids;
}
/* Intercept the toolbar Update/Uninstall click in the CAPTURE phase, before
the toolbar web component's own handler runs. */
document.addEventListener('click', function (e) {
if (window.__msbResuming || cfg.recentBackup) {
return;
}
var node = e.target;
var btn = (node && node.closest) ? node.closest('[task]') : null;
if (!btn) {
return;
}
var task = btn.getAttribute('task');
if (!TASKS[task]) {
return;
}
var cids = checkedCids();
/* Nothing selected — let Joomla show its own "please select" notice. */
if (!cids.length) {
return;
}
var ret = window.location.href;
ret += (ret.indexOf('?') === -1 ? '?' : '&') + 'msb_resume=1';
/* Tell the backup screen which action it is fronting so its completion
ack sets the matching one-shot skip flag (and not the other). */
var action = (task === 'update.update') ? 'update' : 'uninstall';
/* Build the redirect BEFORE we swallow the click. btoa() throws on
non-Latin1 input (e.g. a UTF-8 filter value in the query string), so
encode as UTF-8 first; if it still fails, bail WITHOUT preventing the
click so Joomla's own update/uninstall proceeds normally. */
var target;
try {
target = cfg.runbackupUrl
+ '&profile_id=' + encodeURIComponent(cfg.profileId)
+ '&msb_action=' + action
+ '&returnurl=' + encodeURIComponent(btoa(unescape(encodeURIComponent(ret))));
} catch (x) {
return;
}
e.preventDefault();
e.stopImmediatePropagation();
try {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify({ task: task, cids: cids }));
} catch (x) {}
window.location.href = target;
}, true);
/* On return from the backup screen: restore the selection and re-fire. */
function resume() {
/* On return from the backup screen: restore the selection and re-submit. */
function resumeIfNeeded() {
var params = new URLSearchParams(window.location.search);
if (params.get('msb_resume') !== '1') {
@@ -110,14 +55,15 @@
}
var raw = null;
try { raw = sessionStorage.getItem(STORAGE_KEY); sessionStorage.removeItem(STORAGE_KEY); } catch (x) {}
try { raw = sessionStorage.getItem(STORAGE_KEY); } catch (e) {}
try { sessionStorage.removeItem(STORAGE_KEY); } catch (e) {}
if (!raw) {
return;
}
var data;
try { data = JSON.parse(raw); } catch (x) { data = null; }
try { data = JSON.parse(raw); } catch (e) { data = null; }
if (!data || !data.task) {
return;
@@ -125,31 +71,72 @@
var form = document.getElementById('adminForm');
if (form) {
(data.cids || []).forEach(function (id) {
var box = form.querySelector('input[name="cid[]"][value="' + id + '"]');
if (box) { box.checked = true; }
});
/* Joomla's list-check reads boxchecked — set it so the re-fire passes. */
var bc = form.querySelector('input[name="boxchecked"]');
if (bc) { bc.value = (data.cids || []).length; }
if (!form) {
return;
}
(data.cids || []).forEach(function (id) {
var box = form.querySelector('input[name="cid[]"][value="' + id + '"]');
if (box) { box.checked = true; }
});
/* Re-run the original toolbar action; the wrapper lets it through
because the resume flag is set. */
window.__msbResuming = true;
var btn = document.querySelector('[task="' + data.task + '"]');
if (btn) {
window.setTimeout(function () { btn.click(); }, 300);
} else if (window.Joomla && typeof Joomla.submitbutton === 'function') {
if (window.Joomla && typeof Joomla.submitbutton === 'function') {
Joomla.submitbutton(data.task);
} else if (typeof window.submitbutton === 'function') {
window.submitbutton(data.task);
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', resume);
} else {
resume();
/* Wrap Joomla.submitbutton to intercept the update/uninstall tasks. */
function wrapSubmit() {
var J = window.Joomla;
if (!J || typeof J.submitbutton !== 'function' || J.submitbutton.__msbWrapped) {
return;
}
var original = J.submitbutton;
var wrapped = function (task) {
/* Skip when resuming after a backup, when a backup already ran this
session (throttle window), or for tasks we don't handle. */
if (window.__msbResuming || cfg.recentBackup || !TASKS[task]) {
return original.apply(J, arguments);
}
var cids = checkedCids();
/* Nothing selected — let Joomla show its own "please select" notice
and don't run a pointless backup. */
if (!cids.length) {
return original.apply(J, arguments);
}
try {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify({ task: task, cids: cids }));
} catch (e) {}
var ret = window.location.href;
ret += (ret.indexOf('?') === -1 ? '?' : '&') + 'msb_resume=1';
window.location.href = cfg.runbackupUrl
+ '&profile_id=' + encodeURIComponent(cfg.profileId)
+ '&returnurl=' + encodeURIComponent(btoa(ret));
};
wrapped.__msbWrapped = true;
J.submitbutton = wrapped;
}
/* Wrap as early as possible, and again once the DOM/core JS is ready. */
wrapSubmit();
document.addEventListener('DOMContentLoaded', function () {
wrapSubmit();
resumeIfNeeded();
});
})();
@@ -7,7 +7,7 @@
-->
<extension type="plugin" group="system" method="upgrade">
<name>System - MokoSuiteBackup</name>
<version>02.60.00</version>
<version>02.58.28</version>
<creationDate>2026-06-02</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -28,12 +28,6 @@ final class MokoSuiteBackup extends CMSPlugin implements SubscriberInterface
{
protected $autoloadLanguage = true;
/** @var array<string,bool> Per-request guard: at most one pre-action backup per request (dedupes a batch update). */
private array $preactionRan = [];
/** @var array<string,bool> Per-request cache of the one-shot "client already backed up" skip flag. */
private array $preactionSkip = [];
public static function getSubscribedEvents(): array
{
return [
@@ -229,20 +223,23 @@ final class MokoSuiteBackup extends CMSPlugin implements SubscriberInterface
return;
}
// Already backed up this session (throttle window)? Then the JS should
// let the action proceed without a fresh backup.
$session = Factory::getSession();
$throttleK = $view === 'update'
? 'mokosuitebackup.preaction_backup_before_update'
: 'mokosuitebackup.preaction_backup_before_uninstall';
$lastRun = (int) $session->get($throttleK, 0);
$recent = $lastRun > 0 && (time() - $lastRun) < 600;
$doc->getWebAssetManager()->useScript('core');
/* No time-based "recently backed up" suppression here: each distinct
Update/Uninstall click should get its own full-screen backup. The
re-fired click after a backup is guarded client-side by
window.__msbResuming, and the synchronous server-side fallback is
deduped per-request + by a one-shot skip flag (see runPreActionBackup),
so there is no double backup without blocking distinct actions. */
$doc->addScriptOptions('plg_system_mokosuitebackup.installer', [
'runbackupUrl' => Route::_('index.php?option=com_mokosuitebackup&view=runbackup&tmpl=component&autostart=1', false),
'profileId' => (int) $params->get('default_profile', 1),
'backupBeforeUpdate' => $view === 'update' && (bool) $beforeUpdate,
'backupBeforeUninstall' => $view === 'manage' && (bool) $beforeUninst,
'recentBackup' => false,
'recentBackup' => $recent,
]);
$doc->addScript(Uri::root(true) . '/media/plg_system_mokosuitebackup/js/installer-backup.js', [], ['defer' => true]);
@@ -316,20 +313,16 @@ JS;
$session = Factory::getSession();
// Returned from the backup screen: mark this core update backed up so we
// don't redirect to the backup screen again on a page revisit, then let
// it proceed. Uses a DEDICATED core-update key — a core update does not
// fire onExtensionBefore(Update|Uninstall), so it must not touch the
// extension pre-action flags (which would suppress extension backups).
// 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.core_update_backed_up', time());
$session->set('mokosuitebackup.preaction_backup_before_update', time());
return;
}
// Backed up this core update recently already — let the update proceed
// without redirecting to the backup screen again.
$lastRun = (int) $session->get('mokosuitebackup.core_update_backed_up', 0);
// Backed up recently already — let the update proceed.
$lastRun = (int) $session->get('mokosuitebackup.preaction_backup_before_update', 0);
if ($lastRun > 0 && (time() - $lastRun) < 600) {
return;
@@ -532,37 +525,17 @@ JS;
return;
}
/* One backup per Update action — NOT a 10-minute time throttle. The old
600s window suppressed every extension's pre-action backup for 10
minutes after any single one ran (e.g. after updating this very
extension), so distinct updates silently went un-backed-up. Instead:
// Throttle: only run once per 10 minutes to prevent duplicate
// backups when multiple extensions are updated in a batch
$session = Factory::getSession();
$sessionKey = 'mokosuitebackup.preaction_' . $paramName;
$lastRun = $session->get($sessionKey, 0);
(a) dedupe within a single request — a batch update fires this handler
once per extension, all in the same request, so back up only once;
(b) honour a one-shot session skip flag set by ajax.preupdateAck when
the client-side full-screen backup already ran and is re-firing the
real update/uninstall — consumed once, so the NEXT distinct action
backs up again. */
if (!empty($this->preactionRan[$paramName])) {
if (time() - $lastRun < 600) {
return;
}
$session = Factory::getSession();
$skipKey = 'mokosuitebackup.skip_preaction_' . $paramName;
if (!array_key_exists($paramName, $this->preactionSkip)) {
$this->preactionSkip[$paramName] = (bool) $session->get($skipKey, false);
$session->set($skipKey, false);
}
if ($this->preactionSkip[$paramName]) {
// Client already backed up; skip this whole re-fired (possibly batch) request.
$this->preactionRan[$paramName] = true;
return;
}
$this->preactionRan[$paramName] = true;
$session->set($sessionKey, time());
$profileId = (int) $params->get('default_profile', 1);
@@ -7,7 +7,7 @@
-->
<extension type="plugin" group="task" method="upgrade">
<name>Task - MokoSuiteBackup</name>
<version>02.60.00</version>
<version>02.58.28</version>
<creationDate>2026-06-02</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -7,7 +7,7 @@
-->
<extension type="plugin" group="webservices" method="upgrade">
<name>Web Services - MokoSuiteBackup</name>
<version>02.60.00</version>
<version>02.58.28</version>
<creationDate>2026-06-02</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
+1 -1
View File
@@ -8,7 +8,7 @@
<extension type="package" method="upgrade">
<name>Package - MokoSuiteBackup</name>
<packagename>mokosuitebackup</packagename>
<version>02.60.00</version>
<version>02.58.28</version>
<creationDate>2026-06-02</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>