Files
2024-12-17 17:34:10 +01:00

1401 lines
38 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
/**
* @package FOF
* @copyright Copyright (c)2010-2022 Nicholas K. Dionysopoulos / Akeeba Ltd
* @license GNU General Public License version 3, or later
*/
namespace FOF40\InstallScript;
defined('_JEXEC') || die;
use Exception;
use FOF40\Container\Container;
use FOF40\Database\Installer as DatabaseInstaller;
use FOF40\Utils\ViewManifestMigration;
use JDatabaseDriver;
use Joomla\CMS\Factory as JoomlaFactory;
use Joomla\CMS\Filesystem\File;
use Joomla\CMS\Filesystem\Folder;
use Joomla\CMS\Installer\Adapter\ComponentAdapter;
use Joomla\CMS\Installer\Installer as JoomlaInstaller;
use Joomla\CMS\Log\Log;
use Joomla\CMS\Table\Menu;
use Joomla\Database\DatabaseDriver;
// In case FOF's autoloader is not present yet, e.g. new installation
if (!class_exists('FOF40\\InstallScript\\BaseInstaller', true))
{
require_once __DIR__ . '/BaseInstaller.php';
}
/**
* A helper class which you can use to create component installation scripts.
*
* Example usage: class Com_ExampleInstallerScript extends FOF40\Utils\InstallScript\Component
*
* This namespace contains more classes for creating installation scripts for other kinds of Joomla! extensions as well.
* Do keep in mind that only components, modules and plugins could have post-installation scripts before Joomla! 3.3.
*/
class Component extends BaseInstaller
{
/**
* The component's name. Auto-filled from the class name.
*
* @var string
*/
public $componentName = '';
/**
* The title of the component (printed on installation and uninstallation messages)
*
* @var string
*/
protected $componentTitle = 'Foobar Component';
/**
* The list of obsolete extra modules and plugins to uninstall on component upgrade / installation.
*
* @var array
*/
protected $uninstallation_queue = [
// modules => { (folder) => { (module) }* }*
'modules' => [
'admin' => [],
'site' => [],
],
// plugins => { (folder) => { (element) }* }*
'plugins' => [
'system' => [],
],
];
/**
* Obsolete files and folders to remove from the free version only. This is used when you move a feature from the
* free version of your extension to its paid version. If you don't have such a distinction you can ignore this.
*
* @var array
*/
protected $removeFilesFree = [
'files' => [
// Use pathnames relative to your site's root, e.g.
// 'administrator/components/com_foobar/helpers/whatever.php'
],
'folders' => [
// Use pathnames relative to your site's root, e.g.
// 'administrator/components/com_foobar/baz'
],
];
/**
* Obsolete files and folders to remove from both paid and free releases. This is used when you refactor code and
* some files inevitably become obsolete and need to be removed.
*
* @var array
*/
protected $removeFilesAllVersions = [
'files' => [
// Use pathnames relative to your site's root, e.g.
// 'administrator/components/com_foobar/helpers/whatever.php'
],
'folders' => [
// Use pathnames relative to your site's root, e.g.
// 'administrator/components/com_foobar/baz'
],
];
/**
* A list of scripts to be copied to the "cli" directory of the site
*
* @var array
*/
protected $cliScriptFiles = [
// Use just the filename, e.g.
// 'my-cron-script.php'
];
/**
* The path inside your package where cli scripts are stored
*
* @var string
*/
protected $cliSourcePath = 'cli';
/**
* Is the schemaXmlPath class variable a relative path? If set to true the schemaXmlPath variable contains a path
* relative to the component's back-end directory. If set to false the schemaXmlPath variable contains an absolute
* filesystem path.
*
* @var boolean
*/
protected $schemaXmlPathRelative = true;
/**
* The path where the schema XML files are stored. Its contents depend on the schemaXmlPathRelative variable above
* true => schemaXmlPath contains a path relative to the component's back-end directory
* false => schemaXmlPath contains an absolute filesystem path
*
* @var string
*/
protected $schemaXmlPath = 'sql/xml';
/**
* Is this the paid version of the extension? This only determines which files / extensions will be removed.
*
* @var boolean
*/
protected $isPaid = false;
/**
* Should I copy XML manifests from the tmpl and ViewTemplates folders into the views folder on Joomla 3?
*
* This copies `tmpl/<VIEWNAME>/*.xml` and `ViewTemplates/<VIEWNAME>/*.xml` to `views/<VIEWNAME>/tmpl/*.xml` on
* Joomla 3.
*
* @var bool
*/
protected $migrateJoomla4MenuXMLFiles = true;
/**
* Should I remove the legacy `views` folder on Joomla 4?
*
* This removes both the front- and backend `views` folder. Recommended when `$migrateJoomla4MenuXMLFiles` is also
* true.
*
* @var bool
*/
protected $removeLegacyViewsFolder = true;
/**
* The path to the component's backend directory.
*
* Leave null to assume JPATH_ADMINISTRATOR . '/components/' . $this->componentName
*
* @var string|null
* @since 4.0.1
*/
protected $backendPath = null;
/**
* The path to the component's frontend directory.
*
* Leave null to assume JPATH_SITE . '/components/' . $this->componentName
*
* @var string|null
* @since 4.0.1
*/
protected $frontendPath = null;
/**
* Module installer script constructor.
*/
public function __construct()
{
// Get the plugin name and folder from the class name (it's always plgFolderPluginInstallerScript) if necessary.
if (empty($this->componentName))
{
$class = get_class($this);
$words = preg_replace('/(\s)+/', '_', $class);
$words = strtolower(preg_replace('/(?<=\\w)([A-Z])/', '_\\1', $words));
$classParts = explode('_', $words);
$this->componentName = 'com_' . $classParts[2];
}
}
/**
* Joomla! pre-flight event. This runs before Joomla! installs or updates the component. This is our last chance to
* tell Joomla! if it should abort the installation.
*
* @param string $type Installation type (install, update, discover_install)
* @param ComponentAdapter $parent Parent object
*
* @return boolean True to let the installation proceed, false to halt the installation
*
* @noinspection PhpUnusedParameterInspection
*/
public function preflight(string $type, ComponentAdapter $parent): bool
{
// Do not run on uninstall.
if ($type === 'uninstall')
{
return true;
}
// Check the minimum PHP version
if (!$this->checkPHPVersion())
{
return false;
}
// Check the minimum Joomla! version
if (!$this->checkJoomlaVersion())
{
return false;
}
// Clear op-code caches to prevent any cached code issues
$this->clearOpcodeCaches();
// Workarounds for JoomlaInstaller issues.
if (in_array($type, ['install', 'discover_install']))
{
// Bugfix for "Database function returned no error"
$this->bugfixDBFunctionReturnedNoError();
}
else
{
// Bugfix for "Can not build admin menus"
$this->bugfixCantBuildAdminMenus();
}
return true;
}
/**
* Runs after install, update or discover_update. In other words, it executes after Joomla! has finished installing
* or updating your component. This is the last chance you've got to perform any additional installations, clean-up,
* database updates and similar housekeeping functions.
*
* @param string $type install, update or discover_update
* @param ComponentAdapter $parent Parent object
*
* @return void
* @throws Exception
*
*/
public function postflight(string $type, ComponentAdapter $parent): void
{
// Do not run on uninstall.
if ($type === 'uninstall')
{
return;
}
// Add ourselves to the list of extensions depending on FOF40
$this->addDependency('fof40', $this->componentName);
$this->removeDependency('fof30', $this->componentName);
// Install or update database
$dbInstaller = new DatabaseInstaller(JoomlaFactory::getDbo(),
($this->schemaXmlPathRelative ? JPATH_ADMINISTRATOR . '/components/' . $this->componentName : '') . '/' .
$this->schemaXmlPath
);
$dbInstaller->updateSchema();
// These workarounds are only needed, and only work, on Joomla! 3.x
if (strpos(JVERSION, '3.') === 0)
{
// Make sure menu items are installed
$this->_createAdminMenus($parent);
// Make sure menu items are published
$this->_reallyPublishAdminMenuItems($parent);
}
// Which files should I remove?
if ($this->isPaid)
{
// This is the paid version, only remove the removeFilesAllVersions files
$removeFiles = $this->removeFilesAllVersions;
}
else
{
// This is the free version, remove the removeFilesAllVersions and removeFilesFree files
$removeFiles = ['files' => [], 'folders' => []];
if (isset($this->removeFilesAllVersions['files']))
{
if (isset($this->removeFilesFree['files']))
{
$removeFiles['files'] = array_merge($this->removeFilesAllVersions['files'], $this->removeFilesFree['files']);
}
else
{
$removeFiles['files'] = $this->removeFilesAllVersions['files'];
}
}
elseif (isset($this->removeFilesFree['files']))
{
$removeFiles['files'] = $this->removeFilesFree['files'];
}
if (isset($this->removeFilesAllVersions['folders']))
{
if (isset($this->removeFilesFree['folders']))
{
$removeFiles['folders'] = array_merge($this->removeFilesAllVersions['folders'], $this->removeFilesFree['folders']);
}
else
{
$removeFiles['folders'] = $this->removeFilesAllVersions['folders'];
}
}
elseif (isset($this->removeFilesFree['folders']))
{
$removeFiles['folders'] = $this->removeFilesFree['folders'];
}
}
// Remove obsolete files and folders
$this->removeFilesAndFolders($removeFiles);
// Make sure everything is copied properly
$this->bugfixFilesNotCopiedOnUpdate($parent);
// Copy the CLI files (if any)
$this->copyCliFiles($parent);
// Show the post-installation page
$this->renderPostInstallation($parent);
// Uninstall obsolete subextensions
$this->uninstallObsoleteSubextensions($parent);
// Clear the FOF cache
$false = false;
$cache = JoomlaFactory::getCache('fof', '');
$cache->store($false, 'cache', 'fof');
// Make sure the Joomla! menu structure is correct
$this->_rebuildMenu();
// Add post-installation messages on Joomla! 3.2 and later
$this->_applyPostInstallationMessages();
// Clear the opcode caches again - in case someone accessed the extension while the files were being upgraded.
$this->clearOpcodeCaches();
/**
* DO NOT USE THE CONTAINER TO GET THE PATHS.
*
* There are two cases when updating from a FOF 3 version of a component may cause the container to fail to
* load:
*
* 1. If the component failed to update fully (because Joomla does that)
* 2. You are using opcache but it failed to clear, e.g. the host disabled the function to do so.
*
* Using the hardcoded paths is much safer in this context.
*
* Also note that this code carries two further defenses in cases we start using the container again in the
* future:
*
* 1. It is moved AFTER the call to bugfixFilesNotCopiedOnUpdate() to solve the problem of Joomla failing the
* update.
* 2. It is moved AFTER the calll to clearOpcodeCaches() to deal with the opcache not being cleared.
*
* However, neither solution is bulletproof. As a result it makes far more sense to NOT use the container if we
* can help it...
*/
$frontendPath = $this->frontendPath ?? (JPATH_SITE . '/components/' . $this->componentName);
$backendPath = $this->backendPath ?? (JPATH_ADMINISTRATOR . '/components/' . $this->componentName);
// Migrate view manifest XML files
if ($this->migrateJoomla4MenuXMLFiles)
{
ViewManifestMigration::migrateJoomla4MenuXMLFiles_real($frontendPath, $backendPath);
}
// Remove the legacy Joomla 3 `views` folder
if ($this->removeLegacyViewsFolder)
{
ViewManifestMigration::removeJoomla3LegacyViews_real($frontendPath, $backendPath);
}
// Finally, see if FOF 3.x is obsolete and remove it.
// $this->uninstallFOF3IfNecessary();
}
/**
* Runs on uninstallation
*
* @param ComponentAdapter $parent The parent object
*/
public function uninstall(ComponentAdapter $parent): void
{
// Uninstall database
$dbInstaller = new DatabaseInstaller(JoomlaFactory::getDbo(),
($this->schemaXmlPathRelative ? JPATH_ADMINISTRATOR . '/components/' . $this->componentName : '') . '/' .
$this->schemaXmlPath
);
$dbInstaller->removeSchema();
// Uninstall post-installation messages on Joomla! 3.2 and later
$this->uninstallPostInstallationMessages();
// Remove ourselves from the list of extensions depending of FOF 4
$this->removeDependency('fof40', $this->componentName);
// Uninstall FOF 4 if nothing else depends on it
$this->uninstallFOF4IfNecessary();
// Show the post-uninstallation page
$this->renderPostUninstallation($parent);
}
/**
* Copies the CLI scripts into Joomla!'s cli directory
*
* @param ComponentAdapter $parent
*/
protected function copyCliFiles(ComponentAdapter $parent): void
{
$src = $parent->getParent()->getPath('source');
foreach ($this->cliScriptFiles as $script)
{
if (is_file(JPATH_ROOT . '/cli/' . $script))
{
File::delete(JPATH_ROOT . '/cli/' . $script);
}
if (is_file($src . '/' . $this->cliSourcePath . '/' . $script))
{
File::copy($src . '/' . $this->cliSourcePath . '/' . $script, JPATH_ROOT . '/cli/' . $script);
}
}
}
/**
* Fix for Joomla bug: sometimes files are not copied on update.
*
* We have observed that ever since Joomla! 1.5.5, when Joomla! is performing an extension update some files /
* folders are not copied properly. This seems to be a bit random and seems to be more likely to happen the more
* added / modified files and folders you have. We are trying to work around it by retrying the copy operation
* ourselves WITHOUT going through the manifest, based entirely on the conventions we follow for Akeeba Ltd's
* extensions.
*
* @param ComponentAdapter $parent
*/
protected function bugfixFilesNotCopiedOnUpdate(ComponentAdapter $parent): void
{
Log::add("Joomla! extension update workaround for component $this->componentName", Log::INFO, 'fof4_extension_installation');
$temporarySource = $parent->getParent()->getPath('source');
$copyMap = [
// Backend component files
'backend' => JPATH_ADMINISTRATOR . '/components/' . $this->componentName,
'admin' => JPATH_ADMINISTRATOR . '/components/' . $this->componentName,
// Frontend component files
'frontend' => JPATH_SITE . '/components/' . $this->componentName,
'site' => JPATH_SITE . '/components/' . $this->componentName,
// Backend language
'language/backend' => JPATH_ADMINISTRATOR . '/language',
'language/admin' => JPATH_ADMINISTRATOR . '/language',
// Frontend language
'language/frontend' => JPATH_SITE . '/language',
'language/site' => JPATH_SITE . '/language',
// Media files
'media' => JPATH_ROOT . '/media/' . $this->componentName,
];
foreach ($copyMap as $partialSource => $target)
{
$source = $temporarySource . '/' . $partialSource;
Log::add(__CLASS__ . ":: Conditional copy $source to $target", Log::DEBUG, 'fof4_extension_installation');
$this->recursiveConditionalCopy($source, $target);
}
}
/**
* Override this method to display a custom component installation message if you so wish
*
* @param ComponentAdapter $parent Parent class calling us
*
* @noinspection PhpUnusedParameterInspection
*/
protected function renderPostInstallation(ComponentAdapter $parent): void
{
echo "<h3>$this->componentName has been installed</h3>";
}
/**
* Override this method to display a custom component uninstallation message if you so wish
*
* @param ComponentAdapter $parent Parent class calling us
*
* @noinspection PhpUnusedParameterInspection
*/
protected function renderPostUninstallation(ComponentAdapter $parent): void
{
echo "<h3>$this->componentName has been uninstalled</h3>";
}
/**
* Bugfix for "DB function returned no error"
*/
protected function bugfixDBFunctionReturnedNoError(): void
{
$db = JoomlaFactory::getDbo();
try
{
// Fix broken #__assets records
$this->deleteComponentAssetRecords($db);
// Fix broken #__extensions records
$this->deleteComponentExtensionRecord($db);
/**
* Fix broken #__menu records
*
* Only run on Joomla! versions lower than 3.7. Joomla! 3.7 introduced a backend menu manager which
* lets the user create missing menu items. Moreover, it lets them create custom links to the component
* which means that our menu deleting code would break them! So we don't run this code in newer Joomla!
* versions any more.
*/
$this->deleteComponentMenuRecord($db);
}
catch (Exception $exc)
{
return;
}
}
/**
* Joomla! 1.6+ bugfix for "Can not build admin menus"
*/
protected function bugfixCantBuildAdminMenus(): void
{
$db = JoomlaFactory::getDbo();
// If there are multiple #__extensions record, keep one of them
$query = $db->getQuery(true);
$query->select('extension_id')
->from('#__extensions')
->where($db->qn('type') . ' = ' . $db->q('component'))
->where($db->qn('element') . ' = ' . $db->q($this->componentName));
$db->setQuery($query);
try
{
$ids = $db->loadColumn();
}
catch (Exception $exc)
{
return;
}
if ((is_array($ids) || $ids instanceof \Countable ? count($ids) : 0) > 1)
{
asort($ids);
$extension_id = array_shift($ids); // Keep the oldest id
foreach ($ids as $id)
{
$query = $db->getQuery(true);
$query->delete('#__extensions')
->where($db->qn('extension_id') . ' = ' . $db->q($id));
$db->setQuery($query);
try
{
$db->execute();
}
catch (Exception $exc)
{
// Nothing
}
}
}
// If there are multiple assets records, delete all except the oldest one
$query = $db->getQuery(true);
$query->select('id')
->from('#__assets')
->where($db->qn('name') . ' = ' . $db->q($this->componentName));
$db->setQuery($query);
$ids = $db->loadObjectList();
if ((is_array($ids) || $ids instanceof \Countable ? count($ids) : 0) > 1)
{
asort($ids);
$asset_id = array_shift($ids); // Keep the oldest id
foreach ($ids as $id)
{
$query = $db->getQuery(true);
$query->delete('#__assets')
->where($db->qn('id') . ' = ' . $db->q($id));
$db->setQuery($query);
try
{
$db->execute();
}
catch (Exception $exc)
{
// Nothing
}
}
}
// Remove #__menu records for good measure! I think this is not necessary and causes the menu item to
// disappear on extension update.
/**
* $query = $db->getQuery(true);
* $query->select('id')
* ->from('#__menu')
* ->where($db->qn('type') . ' = ' . $db->q('component'))
* ->where($db->qn('menutype') . ' = ' . $db->q('main'))
* ->where($db->qn('link') . ' LIKE ' . $db->q('index.php?option=' . $this->componentName));
* $db->setQuery($query);
*
* try
* {
* $ids1 = $db->loadColumn();
* }
* catch (Exception $exc)
* {
* $ids1 = array();
* }
*
* if (empty($ids1))
* {
* $ids1 = array();
* }
*
* $query = $db->getQuery(true);
* $query->select('id')
* ->from('#__menu')
* ->where($db->qn('type') . ' = ' . $db->q('component'))
* ->where($db->qn('menutype') . ' = ' . $db->q('main'))
* ->where($db->qn('link') . ' LIKE ' . $db->q('index.php?option=' . $this->componentName . '&%'));
* $db->setQuery($query);
*
* try
* {
* $ids2 = $db->loadColumn();
* }
* catch (Exception $exc)
* {
* $ids2 = array();
* }
*
* if (empty($ids2))
* {
* $ids2 = array();
* }
*
* $ids = array_merge($ids1, $ids2);
*
* if (!empty($ids))
* {
* foreach ($ids as $id)
* {
* $query = $db->getQuery(true);
* $query->delete('#__menu')
* ->where($db->qn('id') . ' = ' . $db->q($id));
* $db->setQuery($query);
*
* try
* {
* $db->execute();
* }
* catch (Exception $exc)
* {
* // Nothing
* }
* }
* }
* /**/
}
/**
* Removes obsolete files and folders
*
* @param array $removeList The files and directories to remove
*/
protected function removeFilesAndFolders(array $removeList): void
{
// Remove files
if (isset($removeList['files']) && !empty($removeList['files']))
{
foreach ($removeList['files'] as $file)
{
$f = JPATH_ROOT . '/' . $file;
if (!is_file($f))
{
continue;
}
File::delete($f);
}
}
// Remove folders
if (!isset($removeList['folders']))
{
return;
}
if (empty($removeList['folders']))
{
return;
}
foreach ($removeList['folders'] as $folder)
{
$f = JPATH_ROOT . '/' . $folder;
if (!@file_exists($f) || !is_dir($f) || is_link($f))
{
continue;
}
Folder::delete($f);
}
}
/**
* Uninstalls obsolete subextensions (modules, plugins) bundled with the main extension
*
* @param ComponentAdapter $parent The parent object
*
* @return \stdClass The sub-extension uninstallation status
* @noinspection PhpUnusedParameterInspection
*/
protected function uninstallObsoleteSubextensions(ComponentAdapter $parent)
{
$db = JoomlaFactory::getDBO();
$status = new \stdClass();
$status->modules = [];
$status->plugins = [];
// Modules uninstallation
if (isset($this->uninstallation_queue['modules']) && count($this->uninstallation_queue['modules']))
{
foreach ($this->uninstallation_queue['modules'] as $folder => $modules)
{
if ((is_array($modules) || $modules instanceof \Countable ? count($modules) : 0) > 0)
{
foreach ($modules as $module)
{
// Find the module ID
$sql = $db->getQuery(true)
->select($db->qn('extension_id'))
->from($db->qn('#__extensions'))
->where($db->qn('element') . ' = ' . $db->q('mod_' . $module))
->where($db->qn('type') . ' = ' . $db->q('module'));
$db->setQuery($sql);
$id = $db->loadResult();
// Uninstall the module
if ($id)
{
$installer = new JoomlaInstaller;
$result = $installer->uninstall('module', $id, 1);
$status->modules[] = [
'name' => 'mod_' . $module,
'client' => $folder,
'result' => $result,
];
}
}
}
}
}
// Plugins uninstallation
if (isset($this->uninstallation_queue['plugins']) && count($this->uninstallation_queue['plugins']))
{
foreach ($this->uninstallation_queue['plugins'] as $folder => $plugins)
{
if ((is_array($plugins) || $plugins instanceof \Countable ? count($plugins) : 0) > 0)
{
foreach ($plugins as $plugin)
{
$sql = $db->getQuery(true)
->select($db->qn('extension_id'))
->from($db->qn('#__extensions'))
->where($db->qn('type') . ' = ' . $db->q('plugin'))
->where($db->qn('element') . ' = ' . $db->q($plugin))
->where($db->qn('folder') . ' = ' . $db->q($folder));
$db->setQuery($sql);
$id = $db->loadResult();
if ($id)
{
$installer = new JoomlaInstaller;
$result = $installer->uninstall('plugin', $id, 1);
$status->plugins[] = [
'name' => 'plg_' . $plugin,
'group' => $folder,
'result' => $result,
];
}
}
}
}
}
return $status;
}
/**
* @param ComponentAdapter $parent
*
* @return bool
*
* @throws Exception When the Joomla! menu is FUBAR
*/
private function _createAdminMenus(ComponentAdapter $parent): bool
{
$db = $parent->getParent()->getDbo();
/** @var Menu $table */
$table = new Menu(JoomlaFactory::getDbo());
$option = $parent->get('element');
// If a component exists with this option in the table then we don't need to add menus
$query = $db->getQuery(true)
->select('COUNT(*)')
->from($db->qn('#__menu') . ' AS ' . $db->qn('m'))
->leftJoin($db->qn('#__extensions', 'e') . ' ON ' .
$db->qn('m.component_id') . ' = ' . $db->qn('e.extension_id'))
->where($db->qn('m.parent_id') . ' = ' . $db->q(1))
->where($db->qn('m.client_id') . ' = ' . $db->q(1))
->where($db->qn('e.type') . ' = ' . $db->q('component'))
->where($db->qn('e.element') . ' = ' . $db->q($option));
$db->setQuery($query);
$existingMenus = $db->loadResult();
if ($existingMenus)
{
return true;
}
// Let's find the extension id
$query->clear()
->select($db->qn('e.extension_id'))
->from($db->qn('#__extensions', 'e'))
->where($db->qn('e.type') . ' = ' . $db->q('component'))
->where($db->qn('e.element') . ' = ' . $db->q($option));
$db->setQuery($query);
$componentId = $db->loadResult();
// Ok, now its time to handle the menus. Start with the component root menu, then handle submenus.
if (method_exists($parent, 'getManifest'))
{
$menuElement = $parent->getManifest()->administration->menu;
}
else
{
$menuElement = $parent->get('manifest')->administration->menu;
}
// We need to insert the menu item as the last child of Joomla!'s menu root node. First let's make sure that
// it exists. Normally it should be the menu item with ID = 1.
$query = $db->getQuery(true)
->select($db->qn('id'))
->from($db->qn('#__menu'))
->where($db->qn('id') . ' = ' . $db->q(1));
$rootItemId = $db->setQuery($query)->loadResult();
// If we didn't find the item with ID=1 something has screwed up the menu table, e.g. a bad upgrade script. In
// this case we can try to find the root node by title.
if (is_null($rootItemId))
{
$rootItemId = null;
$query = $db->getQuery(true)
->select($db->qn('id'))
->from($db->qn('#__menu'))
->where($db->qn('title') . ' = ' . $db->q('Menu_Item_Root'));
$rootItemId = $db->setQuery($query, 0, 1)->loadResult();
}
// So, someone changed the title of the menu item too?! Let's find it by alias.
if (is_null($rootItemId))
{
$rootItemId = null;
$query = $db->getQuery(true)
->select($db->qn('id'))
->from($db->qn('#__menu'))
->where($db->qn('alias') . ' = ' . $db->q('root'));
$rootItemId = $db->setQuery($query, 0, 1)->loadResult();
}
// For crying out loud, they changed the alias too? Fine! Find it by component ID.
if (is_null($rootItemId))
{
$rootItemId = null;
$query = $db->getQuery(true)
->select($db->qn('id'))
->from($db->qn('#__menu'))
->where($db->qn('component_id') . ' = ' . $db->q('0'));
$rootItemId = $db->setQuery($query, 0, 1)->loadResult();
}
// Um, OK. Still no go. Let's try with minimum lft value.
if (is_null($rootItemId))
{
$rootItemId = null;
$query = $db->getQuery(true)
->select($db->qn('id'))
->from($db->qn('#__menu'))
->order($db->qn('lft') . ' ASC');
$rootItemId = $db->setQuery($query, 0, 1)->loadResult();
}
// I quit. Your site's menu structure is broken. I'll just throw an error.
if (is_null($rootItemId))
{
throw new Exception("Your site is broken. There is no root menu item. As a result it is impossible to create menu items. The installation of this component has failed. Please fix your database and retry!", 500);
}
/** @var \SimpleXMLElement $menuElement */
if ($menuElement)
{
$data = [];
$data['menutype'] = 'main';
$data['client_id'] = 1;
$data['title'] = (string) trim($menuElement);
$data['alias'] = (string) $menuElement;
$data['link'] = 'index.php?option=' . $option;
$data['type'] = 'component';
$data['published'] = 0;
$data['parent_id'] = 1;
$data['component_id'] = $componentId;
$data['img'] = ((string) $menuElement->attributes()->img !== '') ? (string) $menuElement->attributes()->img : 'class:component';
$data['home'] = 0;
$data['path'] = '';
$data['params'] = '';
}
// No menu element was specified, Let's make a generic menu item
else
{
$data = [];
$data['menutype'] = 'main';
$data['client_id'] = 1;
$data['title'] = $option;
$data['alias'] = $option;
$data['link'] = 'index.php?option=' . $option;
$data['type'] = 'component';
$data['published'] = 0;
$data['parent_id'] = 1;
$data['component_id'] = $componentId;
$data['img'] = 'class:component';
$data['home'] = 0;
$data['path'] = '';
$data['params'] = '';
}
try
{
$table->setLocation($rootItemId, 'last-child');
}
catch (\InvalidArgumentException $e)
{
$this->log($e->getMessage());
return false;
}
if (!$table->bind($data) || !$table->check() || !$table->store())
{
// The menu item already exists. Delete it and retry instead of throwing an error.
$query->clear()
->select('id')
->from('#__menu')
->where('menutype = ' . $db->quote('main'))
->where('client_id = 1')
->where('link = ' . $db->quote('index.php?option=' . $option))
->where('type = ' . $db->quote('component'))
->where('parent_id = 1')
->where('home = 0');
$db->setQuery($query);
$menu_ids_level1 = $db->loadColumn();
if (empty($menu_ids_level1))
{
JoomlaFactory::getApplication()->enqueueMessage($table->getError(), 'warning');
return false;
}
else
{
$ids = implode(',', $menu_ids_level1);
$query->clear()
->select('id')
->from('#__menu')
->where('menutype = ' . $db->quote('main'))
->where('client_id = 1')
->where('type = ' . $db->quote('component'))
->where('parent_id in (' . $ids . ')')
->where('level = 2')
->where('home = 0');
$db->setQuery($query);
$menu_ids_level2 = $db->loadColumn();
$ids = implode(',', array_merge($menu_ids_level1, $menu_ids_level2));
// Remove the old menu item
$query->clear()
->delete('#__menu')
->where('id in (' . $ids . ')');
$db->setQuery($query);
$db->execute();
// Retry creating the menu item
$table->setLocation($rootItemId, 'last-child');
if (!$table->bind($data) || !$table->check() || !$table->store())
{
// Install failed, warn user and rollback changes
JoomlaFactory::getApplication()->enqueueMessage($table->getError(), 'warning');
return false;
}
}
}
/*
* Since we have created a menu item, we add it to the installation step stack
* so that if we have to rollback the changes we can undo it.
*/
$parent->getParent()->pushStep(['type' => 'menu', 'id' => $componentId]);
/*
* Process SubMenus
*/
if (method_exists($parent, 'getManifest'))
{
$submenu = $parent->getManifest()->administration->submenu;
}
else
{
$submenu = $parent->get('manifest')->administration->submenu;
}
if (!$submenu)
{
return true;
}
$parent_id = $table->id;
/** @var \SimpleXMLElement $child */
foreach ($submenu->menu as $child)
{
$data = [];
$data['menutype'] = 'main';
$data['client_id'] = 1;
$data['title'] = (string) trim($child);
$data['alias'] = (string) $child;
$data['type'] = 'component';
$data['published'] = 0;
$data['parent_id'] = $parent_id;
$data['component_id'] = $componentId;
$data['img'] = ((string) $child->attributes()->img !== '') ? (string) $child->attributes()->img : 'class:component';
$data['home'] = 0;
// Set the sub menu link
if ((string) $child->attributes()->link !== '')
{
$data['link'] = 'index.php?' . $child->attributes()->link;
}
else
{
$request = [];
if ((string) $child->attributes()->act !== '')
{
$request[] = 'act=' . $child->attributes()->act;
}
if ((string) $child->attributes()->task !== '')
{
$request[] = 'task=' . $child->attributes()->task;
}
if ((string) $child->attributes()->controller !== '')
{
$request[] = 'controller=' . $child->attributes()->controller;
}
if ((string) $child->attributes()->view !== '')
{
$request[] = 'view=' . $child->attributes()->view;
}
if ((string) $child->attributes()->layout !== '')
{
$request[] = 'layout=' . $child->attributes()->layout;
}
if ((string) $child->attributes()->sub !== '')
{
$request[] = 'sub=' . $child->attributes()->sub;
}
$qstring = ((is_array($request) || $request instanceof \Countable ? count($request) : 0) > 0) ? '&' . implode('&', $request) : '';
$data['link'] = 'index.php?option=' . $option . $qstring;
}
$table = new Menu(JoomlaFactory::getDbo());
try
{
$table->setLocation($parent_id, 'last-child');
}
catch (\InvalidArgumentException $e)
{
return false;
}
if (!$table->bind($data) || !$table->check() || !$table->store())
{
// Install failed, rollback changes
return false;
}
/*
* Since we have created a menu item, we add it to the installation step stack
* so that if we have to rollback the changes we can undo it.
*/
$parent->getParent()->pushStep(['type' => 'menu', 'id' => $componentId]);
}
return true;
}
/**
* Make sure the Component menu items are really published!
*
* @param ComponentAdapter $parent
*/
private function _reallyPublishAdminMenuItems(ComponentAdapter $parent): void
{
$db = $parent->getParent()->getDbo();
$option = $parent->get('element');
$query = $db->getQuery(true)
->update('#__menu AS m')
->join('LEFT', '#__extensions AS e ON m.component_id = e.extension_id')
->set($db->qn('published') . ' = ' . $db->q(1))
->where('m.parent_id = 1')
->where('m.client_id = 1')
->where('e.type = ' . $db->quote('component'))
->where('e.element = ' . $db->quote($option));
try
{
$db->setQuery($query)->execute();
}
catch (Exception $e)
{
// If it fails, it fails. Who cares.
}
}
/**
* Tells Joomla! to rebuild its menu structure to make triple-sure that the Components menu items really do exist
* in the correct place and can really be rendered.
*/
private function _rebuildMenu(): void
{
$table = new Menu(JoomlaFactory::getDbo());
$db = $table->getDbo();
// We need to rebuild the menu based on its root item. By default this is the menu item with ID=1. However, some
// crappy upgrade scripts enjoy screwing it up. Hey, ho, the workaround way I go.
$query = $db->getQuery(true)
->select($db->qn('id'))
->from($db->qn('#__menu'))
->where($db->qn('id') . ' = ' . $db->q(1));
$rootItemId = $db->setQuery($query)->loadResult();
if (is_null($rootItemId))
{
// Guess what? The Problem has happened. Let's find the root node by title.
$rootItemId = null;
$query = $db->getQuery(true)
->select($db->qn('id'))
->from($db->qn('#__menu'))
->where($db->qn('title') . ' = ' . $db->q('Menu_Item_Root'));
$rootItemId = $db->setQuery($query, 0, 1)->loadResult();
}
if (is_null($rootItemId))
{
// Did they change the title too?! Let's find it by alias.
$rootItemId = null;
$query = $db->getQuery(true)
->select($db->qn('id'))
->from($db->qn('#__menu'))
->where($db->qn('alias') . ' = ' . $db->q('root'));
$rootItemId = $db->setQuery($query, 0, 1)->loadResult();
}
if (is_null($rootItemId))
{
// The alias is borked, too?! Find it by component ID.
$rootItemId = null;
$query = $db->getQuery(true)
->select($db->qn('id'))
->from($db->qn('#__menu'))
->where($db->qn('component_id') . ' = ' . $db->q('0'));
$rootItemId = $db->setQuery($query, 0, 1)->loadResult();
}
if (is_null($rootItemId))
{
// Your site is more of a "shite" than a "site". Let's try with minimum lft value.
$rootItemId = null;
$query = $db->getQuery(true)
->select($db->qn('id'))
->from($db->qn('#__menu'))
->order($db->qn('lft') . ' ASC');
$rootItemId = $db->setQuery($query, 0, 1)->loadResult();
}
if (is_null($rootItemId))
{
// I quit. Your site is broken.
return;
}
$table->rebuild($rootItemId);
}
/**
* Deletes the assets table records for the component
*
* @param JDatabaseDriver|DatabaseDriver $db
*
* @return void
*
* @since 3.0.18
*/
private function deleteComponentAssetRecords($db): void
{
$query = $db->getQuery(true);
$query->select('id')
->from('#__assets')
->where($db->qn('name') . ' = ' . $db->q($this->componentName));
$db->setQuery($query);
$ids = $db->loadColumn();
if (empty($ids))
{
return;
}
foreach ($ids as $id)
{
$query = $db->getQuery(true);
$query->delete('#__assets')
->where($db->qn('id') . ' = ' . $db->q($id));
$db->setQuery($query);
try
{
$db->execute();
}
catch (Exception $exc)
{
// Nothing
}
}
}
/**
* Deletes the extensions table records for the component
*
* @param JDatabaseDriver|DatabaseDriver $db
*
* @return void
*
* @since 3.0.18
*/
private function deleteComponentExtensionRecord($db): void
{
$query = $db->getQuery(true);
$query->select('extension_id')
->from('#__extensions')
->where($db->qn('type') . ' = ' . $db->q('component'))
->where($db->qn('element') . ' = ' . $db->q($this->componentName));
$db->setQuery($query);
$ids = $db->loadColumn();
if (empty($ids))
{
return;
}
foreach ($ids as $id)
{
$query = $db->getQuery(true);
$query->delete('#__extensions')
->where($db->qn('extension_id') . ' = ' . $db->q($id));
$db->setQuery($query);
try
{
$db->execute();
}
catch (Exception $exc)
{
// Nothing
}
}
}
/**
* Deletes the menu table records for the component
*
* @param JDatabaseDriver|DatabaseDriver $db
*
* @return void
*
* @since 3.0.18
*/
private function deleteComponentMenuRecord($db): void
{
$query = $db->getQuery(true);
$query->select('id')
->from('#__menu')
->where($db->qn('type') . ' = ' . $db->q('component'))
->where($db->qn('menutype') . ' = ' . $db->q('main'))
->where($db->qn('link') . ' LIKE ' . $db->q('index.php?option=' . $this->componentName));
$db->setQuery($query);
$ids = $db->loadColumn();
if (empty($ids))
{
return;
}
foreach ($ids as $id)
{
$query = $db->getQuery(true);
$query->delete('#__menu')
->where($db->qn('id') . ' = ' . $db->q($id));
$db->setQuery($query);
try
{
$db->execute();
}
catch (Exception $exc)
{
// Nothing
}
}
}
}