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. */