diff --git a/CHANGELOG.md b/CHANGELOG.md index f41d813c..94209868 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ - The backend controller, Web Services API controller, and legacy `cli/mokosuitebackup.php` now run backups through the shared `BackupRunner` (gaining the normalized complete/warning/fail status) instead of instantiating `BackupEngine` directly. ### Fixed +- Package installer is now **honest about success**: the postflight no longer prints "installed successfully" / next-steps when the install actually failed or only partially completed. Joomla logs a failed child extension but still runs the package postflight, so the postflight now (a) verifies every bundled child declared in the manifest actually registered in `#__extensions` (matched by element + type, and folder/group for plugins) and (b) verifies the component's schema tables — derived dynamically from the installed `install.mysql.sql` — actually exist. If anything is missing it enqueues an error listing what's missing and stops before the success message. Fail-open: any manifest/DB/IO glitch is treated as "nothing missing" so a transient error never turns a good install into a false failure. Unconditional housekeeping (e.g. download-key restore) still runs regardless. - Pre-update/uninstall backup no longer **white-screens** the update on large sites. The synchronous backup that runs inside the extension update/uninstall request now raises `max_execution_time`/`memory_limit` (and `ignore_user_abort`) like the web-cron path, so it can't exhaust the request's default limits mid-backup. (Core Joomla updates additionally get a full-screen backup screen — see below.) - Archive names for **CLI/console-triggered backups** no longer come out as `joomla.invalid_…`. The `[HOST]` placeholder took `$_SERVER['HTTP_HOST']` verbatim, but Joomla's console fills that with the reserved sentinel host `joomla.invalid`; the resolver now treats that (like empty/`localhost`) as unusable and falls back to the configured `live_site` host, then the system hostname. (Set the site's *live_site* to get the exact domain in CLI-built names.) - Standalone restore script generation no longer aborts backups with `str_replace() expects at least 3 arguments, 2 given`. `MokoRestore::generateStandaloneScript()` had a `str_replace()` call (the "Backup Archive" pre-check rewrite) that was missing its `$php` subject argument, so **every** standalone-mode backup fatally errored while "Generating standalone restore.php…" — the archive still finalized and uploaded, but no `restore.php` was ever produced (the true root cause behind #226). (#226) diff --git a/source/script.php b/source/script.php index 65ade9fe..b2f64169 100644 --- a/source/script.php +++ b/source/script.php @@ -188,6 +188,35 @@ class Pkg_MokoSuiteBackupInstallerScript PREPARE/EXECUTE migration that MySQL 8 rejected in Joomla's installer). */ $this->dropLegacyRemoteColumns(); + /* Honesty gate: Joomla logs a failed child extension but still runs the + package postflight, so verify every bundled child actually registered + before claiming success. If any are missing, say so and do NOT print the + success / next-steps messages. (All housekeeping above runs regardless.) + Fail-open — a manifest/DB glitch returns no missing children. */ + $problems = $this->findMissingPackageChildren($parent); + + foreach ($this->findMissingComponentTables() as $table) { + $problems[] = 'table ' . $table; + } + + if (!empty($problems)) { + $safe = array_map( + static fn($m) => htmlspecialchars($m, ENT_QUOTES, 'UTF-8'), + $problems + ); + $detail = count($safe) > 3 + ? count($safe) . ' bundled extensions/tables are missing' + : 'missing: ' . implode(', ', $safe); + + Factory::getApplication()->enqueueMessage( + 'MokoSuiteBackup did not install correctly — ' . $detail + . '. Review the installation log and re-install.', + 'error' + ); + + return; + } + /* Install completion notice (install and update) */ $this->installSuccessful(); @@ -208,6 +237,133 @@ class Pkg_MokoSuiteBackupInstallerScript } } + /** + * Verify every bundled child extension declared in the package manifest + * actually registered in #__extensions. Joomla logs a failed child but does + * not fail the package, so this answers "did the package really install?" + * honestly. Returns human-readable labels for any missing children (empty = + * all present). Matching mirrors extension uniqueness: element + type, and + * for plugins also folder (group), since plugins are only unique that way. + * + * Fail-open: any manifest/DB error returns [] so a transient glitch can never + * turn a good install into a false failure. + * + * @param InstallerAdapter $parent Package installer adapter + * + * @return string[] Labels of missing children (empty when everything installed) + */ + private function findMissingPackageChildren(InstallerAdapter $parent): array + { + try { + $manifest = $parent->getParent()->getManifest(); + + if (!$manifest instanceof \SimpleXMLElement + || !isset($manifest->files) || !isset($manifest->files->file)) { + return []; + } + + $db = Factory::getContainer()->get(\Joomla\Database\DatabaseInterface::class); + $missing = []; + + foreach ($manifest->files->file as $file) { + $attrs = $file->attributes(); + $element = isset($attrs['id']) ? (string) $attrs['id'] : ''; + $extType = isset($attrs['type']) ? (string) $attrs['type'] : ''; + + if ($element === '' || $extType === '') { + continue; + } + + $query = $db->getQuery(true) + ->select('COUNT(*)') + ->from($db->quoteName('#__extensions')) + ->where($db->quoteName('element') . ' = ' . $db->quote($element)) + ->where($db->quoteName('type') . ' = ' . $db->quote($extType)); + + $group = ''; + + /* Plugins are only unique by element + folder (group). */ + if ($extType === 'plugin' && isset($attrs['group'])) { + $group = (string) $attrs['group']; + + if ($group !== '') { + $query->where($db->quoteName('folder') . ' = ' . $db->quote($group)); + } + } + + $db->setQuery($query); + + if ((int) $db->loadResult() === 0) { + $label = $extType . ' "' . $element . '"'; + + if ($group !== '') { + $label .= ' (' . $group . ')'; + } + + $missing[] = $label; + } + } + + return $missing; + } catch (\Throwable $e) { + return []; + } + } + + /** + * Verify the component's schema actually installed. The component has no + * installer script of its own, so this package postflight is the only place + * to catch a component whose extension row exists but whose CREATE TABLE + * statements failed (e.g. an installer-rejected migration). Reads the + * *installed* install SQL, extracts every declared table, and confirms each + * exists. Table names are derived dynamically — nothing is hard-coded. + * + * Fail-open: any IO/DB/parse error returns [] so a glitch can't fail a good + * install. + * + * @return string[] Missing table names (empty when all present) + */ + private function findMissingComponentTables(): array + { + try { + $sqlFile = JPATH_ADMINISTRATOR . '/components/com_mokosuitebackup/sql/install.mysql.sql'; + + if (!is_file($sqlFile)) { + return []; + } + + $sql = file_get_contents($sqlFile); + + if ($sql === false || $sql === '') { + return []; + } + + if (!preg_match_all('/CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?[`"\']?(#__[A-Za-z0-9_]+)/i', $sql, $m)) { + return []; + } + + $db = Factory::getContainer()->get(\Joomla\Database\DatabaseInterface::class); + $prefix = $db->getPrefix(); + + /* Existing tables, compared case-insensitively. */ + $existing = array_flip(array_map('strtolower', $db->getTableList())); + + $missing = []; + + foreach (array_unique($m[1]) as $declared) { + $real = str_replace('#__', $prefix, $declared); + + if (!isset($existing[strtolower($real)])) { + $missing[] = $real; + } + } + + return $missing; + } catch (\Throwable $e) { + return []; + } + } + /** * Remove the orphaned component registration created when the component * briefly used the "Type - Name" display convention. Joomla derives a