Files
MokoSuiteBackup/source/script.php
T

1094 lines
36 KiB
PHP
Raw Normal View History

<?php
/**
* @package MokoSuiteBackup
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\Installer\InstallerAdapter;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Log\Log;
use Joomla\CMS\Router\Route;
class Pkg_MokoSuiteBackupInstallerScript
{
/**
* Minimum Joomla version required
*
* @var string
*/
protected $minimumJoomla = '4.0.0';
/**
* Minimum PHP version required
*
* @var string
*/
protected $minimumPhp = '8.1.0';
/**
* Called before any install/update/uninstall action.
*
* @param string $type Action type (install, update, uninstall)
* @param InstallerAdapter $parent Installer adapter
*
* @return bool
*/
public function preflight(string $type, InstallerAdapter $parent): bool
{
if (version_compare(JVERSION, $this->minimumJoomla, '<')) {
Factory::getApplication()->enqueueMessage(
Text::sprintf('PKG_MOKOJOOMBACKUP_JOOMLA_VERSION_ERROR', $this->minimumJoomla),
'error'
);
return false;
}
if (version_compare(PHP_VERSION, $this->minimumPhp, '<')) {
Factory::getApplication()->enqueueMessage(
Text::sprintf('PKG_MOKOJOOMBACKUP_PHP_VERSION_ERROR', $this->minimumPhp),
'error'
);
return false;
}
/* Check required PHP extensions (warn but don't block install) */
2026-06-18 09:10:28 -05:00
$requiredExts = ['zip', 'pdo', 'pdo_mysql', 'mbstring', 'curl'];
$missingExts = array_filter($requiredExts, fn($ext) => !extension_loaded($ext));
if (!empty($missingExts)) {
Factory::getApplication()->enqueueMessage(
'<strong>MokoSuiteBackup — Missing PHP Extensions</strong>: '
. implode(', ', array_map(fn($e) => 'ext-' . $e, $missingExts))
. '. Some features (backup, restore, remote upload, notifications) may not work until these are enabled.',
'warning'
);
}
/* Save download key before Joomla re-registers the update site */
if ($type === 'update') {
$this->backupDownloadKey();
}
/* Remove any orphaned mis-registered component BEFORE the component install
recreates its admin menu. The orphan (element com_component-mokosuitebackup)
owns a "Backup" menu with alias "backup"; if it survives, the real
component's menu creation collides ("The alias backup is already being used")
and the whole install aborts. */
$this->removeOrphanedComponent();
return true;
}
/**
* The download key cached during preflight so it survives an update.
*/
private ?string $savedDownloadKey = null;
/**
* Cache the existing download key from the update sites table before update runs.
*
* Joomla re-registers update sites from the manifest on every update, which
* can reset the extra_query (download key). We save it here and restore it
* in postflight.
*/
private function backupDownloadKey(): void
{
try {
$db = Factory::getDbo();
$query = $db->getQuery(true)
->select($db->quoteName('us.extra_query'))
->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);
$extraQuery = (string) $db->loadResult();
if (!empty($extraQuery)) {
parse_str($extraQuery, $output);
$this->savedDownloadKey = $output['dlid'] ?? $extraQuery;
}
} catch (\Exception $e) {
Log::add('MokoSuiteBackup: Could not backup download key: ' . $e->getMessage(), Log::WARNING, 'jerror');
}
}
/**
* Called after install/update/uninstall.
*
* @param string $type Action type (install, update, uninstall)
* @param InstallerAdapter $parent Installer adapter
*
* @return void
*/
public function postflight(string $type, InstallerAdapter $parent): void
{
if ($type === 'uninstall') {
return;
}
/* Restore the download key preserved before the update re-registered the site */
if ($type === 'update') {
$this->restoreDownloadKey();
}
if ($type === 'install') {
/* Enable all bundled plugins on fresh install */
$this->enableBundledPlugins();
/* Create default backup directory in site root */
$this->createBackupDirectory();
/* Generate a random webcron secret word */
$this->generateWebcronSecret();
/* Create default scheduled task for backup automation */
$this->createDefaultScheduledTask();
}
/* Ensure submenu items exist and are up to date */
/* (Joomla may not add new submenu entries or update params on upgrades) */
$this->ensureSubmenuItems();
/* Fix package client_id — packages must be client_id=0 (site) for */
/* Joomla's updater to match the <client>site</client> in updates.xml */
$this->fixPackageClientId();
/* Sync submenu icons in #__menu (Joomla doesn't update icons on upgrades) */
$this->syncMenuIcons();
/* 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 <name>
briefly used the "Type - Name" convention. Self-healing on every update. */
$this->removeOrphanedComponent();
/* Drop legacy single-remote storage columns portably (replaces the old
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(
'<strong>MokoSuiteBackup did not install correctly</strong> — ' . $detail
. '. Review the installation log and re-install.',
'error'
);
return;
}
/* Install completion notice (install and update) */
$this->installSuccessful();
if ($type === 'install') {
/* Fresh install never carries a download key — prompt for one */
$this->warnMissingLicenseKey();
/* Remind user to review backup profile settings */
$profileUrl = Route::_('index.php?option=com_mokosuitebackup&view=profiles');
Factory::getApplication()->enqueueMessage(
'<strong>Review Your Backup Settings</strong> — '
. 'A default backup profile has been created. Review the profile settings to configure '
. 'backup type, schedule, storage location, and notifications. '
. '<a href="' . $profileUrl . '" class="btn btn-sm btn-primary ms-2">Review Profiles</a>',
'info'
);
}
}
/**
* 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
* <name> briefly used the "Type - Name" display convention. Joomla derives a
* component's element from its <name>, 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 &rarr; 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 <code>'
. htmlspecialchars($backupDir) . '</code>. '
. '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 <code>' . htmlspecialchars($htaccess) . '</code> with "Deny from all" to prevent direct web access.',
'warning'
);
}
}
$indexHtml = $backupDir . '/index.html';
if (!file_exists($indexHtml)) {
if (file_put_contents($indexHtml, '<!DOCTYPE html><title></title>') === 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 &rarr; 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(
'<h4>MokoSuiteBackup</h4>'
. '<p>Your download/license key could not be preserved during the update.</p>'
. '<p>Please re-enter it in the <a class="btn btn-sm btn-warning ms-2" href="index.php?option=com_installer&view=updatesites&filter[search]=pkg_mokosuitebackup">Update Sites</a> manager to continue receiving updates.</p>',
'warning'
);
}
}
/**
* Show post-install license key prompt.
*/
private function warnMissingLicenseKey(): void
{
try {
Factory::getApplication()->enqueueMessage(
'<h4>MokoSuiteBackup License Key Required</h4>'
. '<p>A download/license key (DLID) is required to receive updates.</p>'
. '<p>Enter your key in the <a class="btn btn-sm btn-warning ms-2" href="index.php?option=com_installer&view=updatesites&filter[search]=pkg_mokosuitebackup">Update Sites</a> manager '
. 'or contact <a class="btn btn-sm btn-warning ms-2" href="https://mokoconsulting.tech/support" target="_blank" rel="noopener">Moko Consulting Support</a> to obtain one.</p>',
'warning'
);
} catch (\Exception $e) {}
}
/**
* Show install successful prompt.
*/
private function installSuccessful(): void
{
try {
Factory::getApplication()->enqueueMessage(
'<h4>MokoSuiteBackup installed successfully!</h4>',
'info'
);
} catch (\Exception $e) {}
}
}