, 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');
}
}
/**
* Drop the 26 legacy single-remote storage columns from the profiles table if
* they are still present. Replaces sql/updates/mysql/02.56.01.sql, whose
* PREPARE/EXECUTE/DEALLOCATE approach is rejected by Joomla's installer on
* MySQL 8 (error 1295) and aborted the whole install. Done here in PHP: gate on
* INFORMATION_SCHEMA, then issue a plain ALTER for only the columns that exist —
* portable across MariaDB and MySQL 8. Idempotent and non-fatal.
*
* @return void
*/
private function dropLegacyRemoteColumns(): void
{
try {
$db = Factory::getDbo();
$table = $db->getPrefix() . 'mokosuitebackup_profiles';
$columns = [
'remote_storage',
'ftp_host', 'ftp_port', 'ftp_username', 'ftp_password', 'ftp_path', 'ftp_passive', 'ftp_ssl',
'sftp_host', 'sftp_port', 'sftp_username', 'sftp_auth_type', 'sftp_password', 'sftp_key_data', 'sftp_passphrase', 'sftp_path',
'gdrive_client_id', 'gdrive_client_secret', 'gdrive_refresh_token', 'gdrive_folder_id',
's3_endpoint', 's3_region', 's3_access_key', 's3_secret_key', 's3_bucket', 's3_path',
];
// Which legacy columns still exist on this install?
$db->setQuery(
$db->getQuery(true)
->select($db->quoteName('COLUMN_NAME'))
->from($db->quoteName('INFORMATION_SCHEMA.COLUMNS'))
->where($db->quoteName('TABLE_SCHEMA') . ' = DATABASE()')
->where($db->quoteName('TABLE_NAME') . ' = ' . $db->quote($table))
->where($db->quoteName('COLUMN_NAME') . ' IN (' . implode(',', array_map([$db, 'quote'], $columns)) . ')')
);
$present = $db->loadColumn();
if (empty($present)) {
return;
}
$drops = array_map(fn($c) => 'DROP COLUMN ' . $db->quoteName($c), $present);
$db->setQuery('ALTER TABLE ' . $db->quoteName($table) . ' ' . implode(', ', $drops));
$db->execute();
} catch (\Throwable $e) {
/* Best-effort — a leftover column is harmless and must never abort the install */
Log::add('MokoSuiteBackup legacy-remote-column drop 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.
*/
private function generateWebcronSecret(): void
{
try {
$db = Factory::getDbo();
/* Load current component params */
$query = $db->getQuery(true)
->select($db->quoteName('params'))
->from($db->quoteName('#__extensions'))
->where($db->quoteName('element') . ' = ' . $db->quote('com_mokosuitebackup'))
->where($db->quoteName('type') . ' = ' . $db->quote('component'))
->setLimit(1);
$db->setQuery($query);
$rawParams = $db->loadResult();
$params = json_decode($rawParams ?: '{}', true) ?: [];
/* Only generate if not already set */
if (!empty($params['webcron_secret'])) {
return;
}
$chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
$secret = '';
$bytes = random_bytes(32);
for ($i = 0; $i < 32; $i++) {
$secret .= $chars[ord($bytes[$i]) % strlen($chars)];
}
$params['webcron_secret'] = $secret;
$query = $db->getQuery(true)
->update($db->quoteName('#__extensions'))
->set($db->quoteName('params') . ' = ' . $db->quote(json_encode($params)))
->where($db->quoteName('element') . ' = ' . $db->quote('com_mokosuitebackup'))
->where($db->quoteName('type') . ' = ' . $db->quote('component'));
$db->setQuery($query);
$db->execute();
} catch (\Exception $e) {
error_log('MokoSuiteBackup: generateWebcronSecret() failed: ' . $e->getMessage());
Factory::getApplication()->enqueueMessage(
'MokoSuiteBackup could not generate a random webcron secret. '
. 'Please set one manually in the component options to secure the webcron endpoint.',
'warning'
);
}
}
private function enableBundledPlugins(): void
{
$folders = ['system', 'quickicon', 'task', 'webservices', 'console', 'content', 'actionlog'];
$db = Factory::getDbo();
foreach ($folders as $folder) {
try {
$query = $db->getQuery(true)
->update($db->quoteName('#__extensions'))
->set($db->quoteName('enabled') . ' = 1')
->where($db->quoteName('type') . ' = ' . $db->quote('plugin'))
->where($db->quoteName('folder') . ' = ' . $db->quote($folder))
->where($db->quoteName('element') . ' = ' . $db->quote('mokosuitebackup'));
$db->setQuery($query);
$db->execute();
} catch (\Exception $e) {
error_log('MokoSuiteBackup: Failed to enable ' . $folder . ' plugin: ' . $e->getMessage());
Factory::getApplication()->enqueueMessage(
'MokoSuiteBackup: Could not enable the ' . $folder . ' plugin. '
. 'Please enable it manually in Extensions → Plugins.',
'warning'
);
}
}
}
private function createBackupDirectory(): void
{
$backupDir = JPATH_ROOT . '/backups';
if (is_dir($backupDir)) {
return;
}
if (!mkdir($backupDir, 0755, true)) {
error_log('MokoSuiteBackup: Failed to create default backup directory: ' . $backupDir);
Factory::getApplication()->enqueueMessage(
'MokoSuiteBackup could not create the default backup directory at '
. htmlspecialchars($backupDir) . '. '
. 'Please create it manually and ensure the web server has write permissions.',
'warning'
);
return;
}
/* Protect directory from direct web access */
$htaccess = $backupDir . '/.htaccess';
if (!file_exists($htaccess)) {
if (file_put_contents($htaccess, "Order Deny,Allow\nDeny from all\n") === false) {
error_log('MokoSuiteBackup: Failed to write .htaccess to ' . $backupDir);
Factory::getApplication()->enqueueMessage(
'MokoSuiteBackup created the backup directory but could not write an .htaccess file to protect it. '
. 'Please manually create ' . htmlspecialchars($htaccess) . ' with "Deny from all" to prevent direct web access.',
'warning'
);
}
}
$indexHtml = $backupDir . '/index.html';
if (!file_exists($indexHtml)) {
if (file_put_contents($indexHtml, '') === false) {
error_log('MokoSuiteBackup: Failed to write index.html to ' . $backupDir);
}
}
}
private function migrateDefaultBackupDir(): void
{
try {
$db = Factory::getDbo();
$oldDefaults = [
'administrator/components/com_mokosuitebackup/backups',
'administrator/components/com_mokojoombackup/backups',
'./backups',
'backups',
];
$query = $db->getQuery(true)
->select('COUNT(*)')
->from($db->quoteName('#__mokosuitebackup_profiles'))
->where($db->quoteName('published') . ' = 1')
->where('(' . $db->quoteName('backup_dir') . ' IN ('
. implode(',', array_map([$db, 'quote'], $oldDefaults))
. ') OR ' . $db->quoteName('backup_dir') . ' = ' . $db->quote('')
. ' OR ' . $db->quoteName('backup_dir') . ' IS NULL)');
$db->setQuery($query);
if ((int) $db->loadResult() > 0) {
$update = $db->getQuery(true)
->update($db->quoteName('#__mokosuitebackup_profiles'))
->set($db->quoteName('backup_dir') . ' = ' . $db->quote('[DEFAULT_DIR]'))
->where('(' . $db->quoteName('backup_dir') . ' IN ('
. implode(',', array_map([$db, 'quote'], $oldDefaults))
. ') OR ' . $db->quoteName('backup_dir') . ' = ' . $db->quote('')
. ' OR ' . $db->quoteName('backup_dir') . ' IS NULL)');
$db->setQuery($update);
$db->execute();
$migrated = $db->getAffectedRows();
if ($migrated > 0) {
error_log('MokoSuiteBackup: Migrated ' . $migrated . ' profile(s) from legacy backup_dir to [DEFAULT_DIR]');
}
}
} catch (\Exception $e) {
error_log('MokoSuiteBackup: migrateDefaultBackupDir() failed: ' . $e->getMessage());
Factory::getApplication()->enqueueMessage(
'MokoSuiteBackup could not automatically migrate backup directory settings in your profiles. '
. 'Please review your backup profiles and ensure the backup directory is set correctly.',
'warning'
);
}
}
private function createDefaultScheduledTask(): void
{
try {
$db = Factory::getDbo();
/* Check if a MokoSuiteBackup task already exists */
$query = $db->getQuery(true)
->select('COUNT(*)')
->from($db->quoteName('#__scheduler_tasks'))
->where($db->quoteName('type') . ' = ' . $db->quote('mokosuitebackup.run_profile'));
$db->setQuery($query);
if ((int) $db->loadResult() > 0) {
return;
}
$now = date('Y-m-d H:i:s');
$task = (object) [
'title' => 'MokoSuiteBackup — Monthly Full Backup',
'type' => 'mokosuitebackup.run_profile',
'execution_rules' => json_encode([
'rule-type' => 'interval-days',
'interval-days' => '30',
'exec-day' => '1',
'exec-time' => '03:00:00',
]),
'cron_rules' => json_encode([
'type' => 'interval',
'exp' => 'P30D',
]),
'state' => 1,
'params' => json_encode([
'profile_id' => 1,
'individual_log' => true,
'log_file' => '',
'notifications' => [
'success_mail' => '0',
'failure_mail' => '1',
'notification_failure_groups' => ['8'],
'fatal_failure_mail' => '1',
'notification_fatal_groups' => ['8'],
'orphan_mail' => '0',
],
]),
'priority' => 0,
'ordering' => 0,
'cli_exclusive' => 0,
'note' => '',
'created' => $now,
'created_by' => Factory::getApplication()->getIdentity()?->id ?? 0,
'next_execution' => date('Y-m-d 03:00:00', strtotime('+1 day')),
];
$db->insertObject('#__scheduler_tasks', $task);
} catch (\Exception $e) {
error_log('MokoSuiteBackup: createDefaultScheduledTask() failed: ' . $e->getMessage());
Factory::getApplication()->enqueueMessage(
'MokoSuiteBackup could not create the default scheduled backup task. '
. 'Please create a scheduled task manually in System → Scheduled Tasks to enable automated backups.',
'warning'
);
}
}
/**
* Ensure admin submenu items exist in #__menu.
*
* On updates Joomla may not add new submenu entries or update params,
* so we manually create missing items using MenuTable for correct
* nested set positioning (lft/rgt values).
*/
private function ensureSubmenuItems(): void
{
$submenus = [
[
'link' => 'index.php?option=com_mokosuitebackup&view=dashboard',
'title' => 'COM_MOKOJOOMBACKUP_SUBMENU_DASHBOARD',
'img' => 'class:home',
'menu_icon' => 'icon-home',
],
[
'link' => 'index.php?option=com_mokosuitebackup&view=backups',
'title' => 'COM_MOKOJOOMBACKUP_SUBMENU_BACKUPS',
'img' => 'class:database',
'menu_icon' => 'icon-database',
],
[
'link' => 'index.php?option=com_mokosuitebackup&view=snapshots',
'title' => 'COM_MOKOJOOMBACKUP_SUBMENU_SNAPSHOTS',
'img' => 'class:camera',
'menu_icon' => 'icon-camera',
],
[
'link' => 'index.php?option=com_mokosuitebackup&view=profiles',
'title' => 'COM_MOKOJOOMBACKUP_SUBMENU_PROFILES',
'img' => 'class:cog',
'menu_icon' => 'icon-cog',
],
];
try {
$db = Factory::getDbo();
/* Component extension_id first — needed to (re)create the parent menu. */
$query = $db->getQuery(true)
->select($db->quoteName('extension_id'))
->from($db->quoteName('#__extensions'))
->where($db->quoteName('element') . ' = ' . $db->quote('com_mokosuitebackup'))
->where($db->quoteName('type') . ' = ' . $db->quote('component'))
->setLimit(1);
$db->setQuery($query);
$componentId = (int) $db->loadResult();
if (!$componentId) {
error_log('MokoSuiteBackup: ensureSubmenuItems() — component extension_id not found');
return;
}
/* Find the top-level "Backup" parent menu item for our component */
$query = $db->getQuery(true)
->select([$db->quoteName('id'), $db->quoteName('menutype')])
->from($db->quoteName('#__menu'))
->where($db->quoteName('client_id') . ' = 1')
->where($db->quoteName('level') . ' = 1')
->where($db->quoteName('link') . ' LIKE ' . $db->quote('index.php?option=com_mokosuitebackup%'))
->setLimit(1);
$db->setQuery($query);
$parent = $db->loadObject();
/* Self-heal: if the top-level menu was deleted (e.g. by a failed install
after the duplicate-component mess), recreate it — otherwise the whole
Backup menu stays gone and dependents (cPanel module, MokoSuiteClient)
can't locate the component. */
if (!$parent) {
$parent = $this->createTopMenu($componentId);
if (!$parent) {
error_log('MokoSuiteBackup: ensureSubmenuItems() — parent menu missing and could not be created');
return;
}
}
/* Keep the top-level menu label on the short constant and owned by the
real component. */
$db->setQuery(
$db->getQuery(true)
->update($db->quoteName('#__menu'))
->set($db->quoteName('title') . ' = ' . $db->quote('COM_MOKOJOOMBACKUP_SHORT'))
->set($db->quoteName('component_id') . ' = ' . (int) $componentId)
->where($db->quoteName('id') . ' = ' . (int) $parent->id)
);
$db->execute();
foreach ($submenus as $submenu) {
/* Check if this submenu item already exists */
$query = $db->getQuery(true)
->select([$db->quoteName('id'), $db->quoteName('params')])
->from($db->quoteName('#__menu'))
->where($db->quoteName('client_id') . ' = 1')
->where($db->quoteName('link') . ' = ' . $db->quote($submenu['link']))
->setLimit(1);
$db->setQuery($query);
$existing = $db->loadObject();
if ($existing) {
/* Merge menu_icon into existing params to preserve other settings */
$existingParams = json_decode($existing->params ?? '{}', true) ?: [];
$existingParams['menu_icon'] = $submenu['menu_icon'];
$mergedParams = json_encode($existingParams);
$query = $db->getQuery(true)
->update($db->quoteName('#__menu'))
->set($db->quoteName('params') . ' = ' . $db->quote($mergedParams))
->where($db->quoteName('id') . ' = ' . (int) $existing->id);
$db->setQuery($query);
$db->execute();
continue;
}
/* Use Joomla's MenuTable to create the item properly */
$table = Factory::getApplication()
->bootComponent('com_menus')
->getMVCFactory()
->createTable('Menu', 'Administrator');
$params = json_encode(['menu_icon' => $submenu['menu_icon']]);
$table->menutype = $parent->menutype;
$table->title = $submenu['title'];
$table->alias = strtolower(str_replace(' ', '-', $submenu['title']));
$table->link = $submenu['link'];
$table->type = 'component';
$table->published = 1;
$table->parent_id = $parent->id;
$table->level = 2;
$table->component_id = $componentId;
$table->client_id = 1;
$table->img = $submenu['img'];
$table->params = $params;
$table->language = '*';
$table->access = 1;
$table->setLocation($parent->id, 'last-child');
if (!$table->check() || !$table->store()) {
error_log('MokoSuiteBackup: Failed to create submenu "' . $submenu['title'] . '": ' . $table->getError());
}
}
} catch (\Exception $e) {
error_log('MokoSuiteBackup: ensureSubmenuItems() failed: ' . $e->getMessage());
Factory::getApplication()->enqueueMessage(
'MokoSuiteBackup could not create or update sidebar menu items. '
. 'If submenu entries are missing, try reinstalling the component.',
'warning'
);
}
}
/**
* Create the top-level "Backup" admin menu item for the component when it is
* missing (deleted by a failed install / the duplicate-component fallout).
* Label uses the short constant. Returns a lightweight {id, menutype} object,
* or null on failure.
*
* @param int $componentId Extension id of com_mokosuitebackup
*
* @return object|null
*/
private function createTopMenu(int $componentId): ?object
{
try {
$table = Factory::getApplication()
->bootComponent('com_menus')
->getMVCFactory()
->createTable('Menu', 'Administrator');
$table->menutype = 'main';
$table->title = 'COM_MOKOJOOMBACKUP_SHORT';
$table->alias = 'backup';
$table->link = 'index.php?option=com_mokosuitebackup';
$table->type = 'component';
$table->published = 1;
$table->parent_id = 1;
$table->level = 1;
$table->component_id = $componentId;
$table->client_id = 1;
$table->img = 'class:archive';
$table->params = '{}';
$table->language = '*';
$table->access = 1;
$table->setLocation(1, 'last-child');
if (!$table->check() || !$table->store()) {
error_log('MokoSuiteBackup: createTopMenu() failed: ' . $table->getError());
return null;
}
$obj = new \stdClass();
$obj->id = (int) $table->id;
$obj->menutype = 'main';
return $obj;
} catch (\Throwable $e) {
error_log('MokoSuiteBackup: createTopMenu() exception: ' . $e->getMessage());
return null;
}
}
private function fixPackageClientId(): void
{
try {
$db = Factory::getDbo();
$query = $db->getQuery(true)
->update($db->quoteName('#__extensions'))
->set($db->quoteName('client_id') . ' = 0')
->where($db->quoteName('element') . ' = ' . $db->quote('pkg_mokosuitebackup'))
->where($db->quoteName('type') . ' = ' . $db->quote('package'));
$db->setQuery($query);
$db->execute();
} catch (\Exception $e) {
error_log('MokoSuiteBackup: fixPackageClientId() failed: ' . $e->getMessage());
Factory::getApplication()->enqueueMessage(
'MokoSuiteBackup could not correct the package registration. '
. 'Automatic updates may not appear. If you do not see update notifications, '
. 'please reinstall the package.',
'warning'
);
}
}
private function syncMenuIcons(): void
{
$iconMap = [
'view=dashboard' => 'class:home',
'view=backups' => 'class:database',
'view=snapshots' => 'class:camera',
'view=profiles' => 'class:cog',
];
try {
$db = Factory::getDbo();
foreach ($iconMap as $linkFragment => $icon) {
$query = $db->getQuery(true)
->update($db->quoteName('#__menu'))
->set($db->quoteName('img') . ' = ' . $db->quote($icon))
->where($db->quoteName('client_id') . ' = 1')
->where($db->quoteName('link') . ' LIKE ' . $db->quote('index.php?option=com_mokosuitebackup%' . $linkFragment . '%'));
$db->setQuery($query);
$db->execute();
}
$query = $db->getQuery(true)
->update($db->quoteName('#__menu'))
->set($db->quoteName('img') . ' = ' . $db->quote('class:archive'))
->where($db->quoteName('client_id') . ' = 1')
->where($db->quoteName('link') . ' LIKE ' . $db->quote('index.php?option=com_mokosuitebackup'))
->where($db->quoteName('level') . ' = 1');
$db->setQuery($query);
$db->execute();
} catch (\Exception $e) {
error_log('MokoSuiteBackup: syncMenuIcons() failed: ' . $e->getMessage());
Factory::getApplication()->enqueueMessage(
'MokoSuiteBackup could not update sidebar menu icons. This is cosmetic and does not affect functionality.',
'notice'
);
}
}
/**
* Restore the download key to the (possibly new) update site record.
*/
private function restoreDownloadKey(): void
{
try {
$db = Factory::getDbo();
$query = $db->getQuery(true)
->select($db->quoteName('us.update_site_id'))
->from($db->quoteName('#__update_sites', 'us'))
->join(
'INNER',
$db->quoteName('#__update_sites_extensions', 'use')
. ' ON ' . $db->quoteName('use.update_site_id') . ' = ' . $db->quoteName('us.update_site_id')
)
->join(
'INNER',
$db->quoteName('#__extensions', 'e')
. ' ON ' . $db->quoteName('e.extension_id') . ' = ' . $db->quoteName('use.extension_id')
)
->where($db->quoteName('e.element') . ' = ' . $db->quote('pkg_mokosuitebackup'))
->where($db->quoteName('e.type') . ' = ' . $db->quote('package'))
->setLimit(1);
$db->setQuery($query);
$updateSiteId = (int) $db->loadResult();
if ($updateSiteId > 0 && !empty($this->savedDownloadKey)) {
$query = $db->getQuery(true)
->update($db->quoteName('#__update_sites'))
->set($db->quoteName('extra_query') . ' = ' . $db->quote('dlid=' . $this->savedDownloadKey))
->where($db->quoteName('update_site_id') . ' = ' . $updateSiteId);
$db->setQuery($query);
$db->execute();
}
} catch (\Exception $e) {
Log::add('MokoSuiteBackup: Could not restore download key: ' . $e->getMessage(), Log::WARNING, 'jerror');
Factory::getApplication()->enqueueMessage(
'MokoSuiteBackup
'
. 'Your download/license key could not be preserved during the update.
'
. 'Please re-enter it in the Update Sites manager to continue receiving updates.
',
'warning'
);
}
}
/**
* Show post-install license key prompt.
*/
private function warnMissingLicenseKey(): void
{
try {
Factory::getApplication()->enqueueMessage(
'MokoSuiteBackup License Key Required
'
. 'A download/license key (DLID) is required to receive updates.
'
. 'Enter your key in the Update Sites manager '
. 'or contact Moko Consulting Support to obtain one.
',
'warning'
);
} catch (\Exception $e) {}
}
/**
* Show install successful prompt.
*/
private function installSuccessful(): void
{
try {
Factory::getApplication()->enqueueMessage(
'MokoSuiteBackup installed successfully!
',
'info'
);
} catch (\Exception $e) {}
}
}