From 1029e40908670b6267b785aae74e6c14a389288f Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Sun, 5 Jul 2026 17:11:51 -0500 Subject: [PATCH] fix(installer): self-heal orphaned com_component-mokosuitebackup on update The old "Type - Name" component made Joomla register a duplicate component under element com_component-mokosuitebackup (its own extension row, admin menu, schema rows and administrator/ folder), orphaning the real com_mokosuitebackup on the old version. The manifest fix (#213) prevents new occurrences; this reconciles already-affected sites automatically. Package postflight now removes any component whose element matches %component%mokosuitebackup% (excluding com_mokosuitebackup): deletes its #__menu, #__schemas and #__update_sites_extensions rows, the #__extensions record, and the stray admin folder. Idempotent and non-fatal. Claude-Session: https://claude.ai/code/session_01WbGBN9VyRK61zczYWcCQ2i --- CHANGELOG.md | 3 ++ source/script.php | 106 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 109 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9792d5fa..9d05235a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,9 @@ # Changelog ## [Unreleased] +### 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 `` — self-healing on update so affected sites reconcile to the real `com_mokosuitebackup`. (#213 follow-up) + ## [02.56.08] --- 2026-07-05 ## [02.56.08] --- 2026-07-05 diff --git a/source/script.php b/source/script.php index ebf16d01..dbd12a54 100644 --- a/source/script.php +++ b/source/script.php @@ -172,6 +172,11 @@ class Pkg_MokoSuiteBackupInstallerScript /* Migrate profiles with old default backup_dir values to [DEFAULT_DIR] placeholder */ $this->migrateDefaultBackupDir(); + /* Remove the orphaned mis-registered component (element + com_component-mokosuitebackup) left behind when the component + briefly used the "Type - Name" convention. Self-healing on every update. */ + $this->removeOrphanedComponent(); + /* Install completion notice (install and update) */ $this->installSuccessful(); @@ -192,6 +197,107 @@ class Pkg_MokoSuiteBackupInstallerScript } } + /** + * Remove the orphaned component registration created when the component + * briefly used the "Type - Name" display convention. Joomla derives a + * component's element from its , so "Component - MokoSuiteBackup" + * produced element com_component-mokosuitebackup — its own extension record, + * admin menu items, schema rows and administrator/ folder — orphaning the real + * com_mokosuitebackup on the old version. This reconciles affected sites on + * update. Idempotent and non-fatal: does nothing when no orphan exists. + * + * @return void + */ + private function removeOrphanedComponent(): void + { + try { + $db = Factory::getDbo(); + + $query = $db->getQuery(true) + ->select($db->quoteName(['extension_id', 'element'])) + ->from($db->quoteName('#__extensions')) + ->where($db->quoteName('type') . ' = ' . $db->quote('component')) + ->where($db->quoteName('client_id') . ' = 1') + ->where($db->quoteName('element') . ' LIKE ' . $db->quote('%component%mokosuitebackup%')) + ->where($db->quoteName('element') . ' <> ' . $db->quote('com_mokosuitebackup')); + $db->setQuery($query); + $orphans = $db->loadObjectList(); + + if (empty($orphans)) { + return; + } + + foreach ($orphans as $orphan) { + $id = (int) $orphan->extension_id; + + /* Admin menu items owned by the orphan component */ + $db->setQuery( + $db->getQuery(true) + ->delete($db->quoteName('#__menu')) + ->where($db->quoteName('component_id') . ' = ' . $id) + )->execute(); + + /* Schema-version rows */ + $db->setQuery( + $db->getQuery(true) + ->delete($db->quoteName('#__schemas')) + ->where($db->quoteName('extension_id') . ' = ' . $id) + )->execute(); + + /* Update-site mapping (leave #__update_sites; harmless if orphaned) */ + $db->setQuery( + $db->getQuery(true) + ->delete($db->quoteName('#__update_sites_extensions')) + ->where($db->quoteName('extension_id') . ' = ' . $id) + )->execute(); + + /* The bogus extension record itself */ + $db->setQuery( + $db->getQuery(true) + ->delete($db->quoteName('#__extensions')) + ->where($db->quoteName('extension_id') . ' = ' . $id) + )->execute(); + + /* The stray admin component folder on disk */ + $this->deleteFolderRecursive(JPATH_ADMINISTRATOR . '/components/' . $orphan->element); + + Factory::getApplication()->enqueueMessage( + 'MokoSuiteBackup: removed orphaned component registration "' + . htmlspecialchars($orphan->element, ENT_QUOTES, 'UTF-8') . '".', + 'info' + ); + } + } catch (\Throwable $e) { + /* Never block the install/update over best-effort cleanup */ + Log::add('MokoSuiteBackup orphan-component cleanup failed: ' . $e->getMessage(), Log::WARNING, 'jerror'); + } + } + + /** + * Recursively delete a directory (version-independent, no Folder dependency). + * + * @param string $dir Absolute path + * + * @return void + */ + private function deleteFolderRecursive(string $dir): void + { + if ($dir === '' || !is_dir($dir)) { + return; + } + + $items = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST + ); + + foreach ($items as $item) { + $item->isDir() ? @rmdir($item->getPathname()) : @unlink($item->getPathname()); + } + + @rmdir($dir); + } + /** * Generate a cryptographically random webcron secret word on fresh install. */