From fc068866d9ab7dd9481f31b932bc7f34214ae0c8 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Sat, 6 Jun 2026 15:31:04 -0500 Subject: [PATCH] feat: database-backed download key preservation for all extensions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace JSON file backup with #__mokowaas_download_keys table as the persistent single source of truth for download keys. - Core plugin: syncKeysToTable() copies keys from Joomla to our table, applyKeysFromTable() re-applies from our table to Joomla. Runs on every admin page load — Joomla can wipe keys all it wants. - Install script: preflight saves to table, postflight re-applies. - ExtensionsModel: saveDownloadKey(), applyDownloadKey(), reapplyAllDownloadKeys() static method for install/update hooks. - Extension manager: prompt for download key on install, skip extensions with no release, show missing key warning badge. - Catalog: expanded to 11 Joomla extensions. Authored-by: Moko Consulting Co-Authored-By: Claude Opus 4.6 (1M context) --- .../com_mokowaas/admin/sql/install.mysql.sql | 16 ++ .../admin/src/Model/ExtensionsModel.php | 139 +++++++++++ .../admin/tmpl/extensions/default.php | 32 ++- .../Extension/MokoWaaS.php | 216 ++++++++++-------- source/script.php | 169 ++++++++++++-- 5 files changed, 460 insertions(+), 112 deletions(-) diff --git a/source/packages/com_mokowaas/admin/sql/install.mysql.sql b/source/packages/com_mokowaas/admin/sql/install.mysql.sql index 0bd447a0..af34efcc 100644 --- a/source/packages/com_mokowaas/admin/sql/install.mysql.sql +++ b/source/packages/com_mokowaas/admin/sql/install.mysql.sql @@ -133,3 +133,19 @@ INSERT IGNORE INTO `#__mokowaas_retention_policies` (`id`, `content_type`, `rete (3, 'sessions', 7, 'delete', 1, 'Purge expired sessions older than 7 days'), (4, 'inactive_users', 730, 'anonymize', 0, 'Anonymize users inactive for 2 years (disabled by default)'), (5, 'closed_tickets', 365, 'anonymize', 0, 'Anonymize closed tickets older than 1 year (disabled by default)'); + +-- +-- Download Key Storage — persistent backup of extension download keys +-- that survives Joomla update site recreation +-- +CREATE TABLE IF NOT EXISTS `#__mokowaas_download_keys` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `element` VARCHAR(100) NOT NULL DEFAULT '' COMMENT 'Extension element name', + `location` VARCHAR(512) NOT NULL DEFAULT '' COMMENT 'Update server URL', + `dlid` VARCHAR(255) NOT NULL DEFAULT '' COMMENT 'Download key value', + `created` DATETIME NOT NULL, + `modified` DATETIME NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `idx_dlkey_element` (`element`), + KEY `idx_dlkey_location` (`location`(191)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/source/packages/com_mokowaas/admin/src/Model/ExtensionsModel.php b/source/packages/com_mokowaas/admin/src/Model/ExtensionsModel.php index 0eb1db48..0095f702 100644 --- a/source/packages/com_mokowaas/admin/src/Model/ExtensionsModel.php +++ b/source/packages/com_mokowaas/admin/src/Model/ExtensionsModel.php @@ -48,6 +48,12 @@ class ExtensionsModel extends BaseDatabaseModel $remoteVersion = $release['version'] ?? ''; $downloadUrl = $release['download_url'] ?? ''; + // Skip extensions with no release available and not installed + if (empty($remoteVersion) && $localVersion === null) + { + continue; + } + $status = 'not_installed'; if ($localVersion !== null) @@ -363,6 +369,139 @@ class ExtensionsModel extends BaseDatabaseModel } } + /** + * Save a download key for a Moko extension. + * + * @param string $element Extension element name. + * @param string $dlid Download key value. + * @param string $location Update server URL (optional). + */ + public function saveDownloadKey(string $element, string $dlid, string $location = ''): void + { + $db = $this->getDatabase(); + $now = gmdate('Y-m-d H:i:s'); + + // Upsert — update if exists, insert if not + $db->setQuery( + $db->getQuery(true) + ->select('COUNT(*)') + ->from($db->quoteName('#__mokowaas_download_keys')) + ->where($db->quoteName('element') . ' = ' . $db->quote($element)) + ); + + if ((int) $db->loadResult() > 0) + { + $db->setQuery( + $db->getQuery(true) + ->update($db->quoteName('#__mokowaas_download_keys')) + ->set($db->quoteName('dlid') . ' = ' . $db->quote($dlid)) + ->set($db->quoteName('location') . ' = ' . $db->quote($location)) + ->set($db->quoteName('modified') . ' = ' . $db->quote($now)) + ->where($db->quoteName('element') . ' = ' . $db->quote($element)) + )->execute(); + } + else + { + $db->setQuery( + $db->getQuery(true) + ->insert($db->quoteName('#__mokowaas_download_keys')) + ->columns([$db->quoteName('element'), $db->quoteName('location'), $db->quoteName('dlid'), $db->quoteName('created'), $db->quoteName('modified')]) + ->values(implode(',', [ + $db->quote($element), + $db->quote($location), + $db->quote($dlid), + $db->quote($now), + $db->quote($now), + ])) + )->execute(); + } + + // Immediately apply to Joomla's update site + $this->applyDownloadKey($element, $dlid); + } + + /** + * Apply a stored download key to Joomla's update site for an extension. + */ + public function applyDownloadKey(string $element, string $dlid): void + { + $db = $this->getDatabase(); + + $db->setQuery( + $db->getQuery(true) + ->select('us.' . $db->quoteName('update_site_id')) + ->from($db->quoteName('#__update_sites', 'us')) + ->join('INNER', $db->quoteName('#__update_sites_extensions', 'use') . ' ON us.update_site_id = use.update_site_id') + ->join('INNER', $db->quoteName('#__extensions', 'e') . ' ON e.extension_id = use.extension_id') + ->where($db->quoteName('e.element') . ' = ' . $db->quote($element)) + ); + $siteIds = $db->loadColumn() ?: []; + + foreach ($siteIds as $siteId) + { + $db->setQuery( + $db->getQuery(true) + ->update($db->quoteName('#__update_sites')) + ->set($db->quoteName('extra_query') . ' = ' . $db->quote('dlid=' . $dlid)) + ->where($db->quoteName('update_site_id') . ' = ' . (int) $siteId) + )->execute(); + } + } + + /** + * Re-apply all stored Moko download keys to Joomla's update sites. + * Called after updates that may have wiped extra_query. + * + * @return int Number of keys re-applied. + */ + public static function reapplyAllDownloadKeys(): int + { + try + { + $db = Factory::getDbo(); + + $db->setQuery( + $db->getQuery(true) + ->select('*') + ->from($db->quoteName('#__mokowaas_download_keys')) + ->where($db->quoteName('dlid') . ' != ' . $db->quote('')) + ); + $keys = $db->loadObjectList() ?: []; + + $applied = 0; + + foreach ($keys as $key) + { + $db->setQuery( + $db->getQuery(true) + ->select('us.' . $db->quoteName('update_site_id')) + ->from($db->quoteName('#__update_sites', 'us')) + ->join('INNER', $db->quoteName('#__update_sites_extensions', 'use') . ' ON us.update_site_id = use.update_site_id') + ->join('INNER', $db->quoteName('#__extensions', 'e') . ' ON e.extension_id = use.extension_id') + ->where($db->quoteName('e.element') . ' = ' . $db->quote($key->element)) + ); + $siteIds = $db->loadColumn() ?: []; + + foreach ($siteIds as $siteId) + { + $db->setQuery( + $db->getQuery(true) + ->update($db->quoteName('#__update_sites')) + ->set($db->quoteName('extra_query') . ' = ' . $db->quote('dlid=' . $key->dlid)) + ->where($db->quoteName('update_site_id') . ' = ' . (int) $siteId) + )->execute(); + $applied++; + } + } + + return $applied; + } + catch (\Throwable $e) + { + return 0; + } + } + /** * Get the extension_id for an element (for uninstall links). * diff --git a/source/packages/com_mokowaas/admin/tmpl/extensions/default.php b/source/packages/com_mokowaas/admin/tmpl/extensions/default.php index 54bcff70..3332d30c 100644 --- a/source/packages/com_mokowaas/admin/tmpl/extensions/default.php +++ b/source/packages/com_mokowaas/admin/tmpl/extensions/default.php @@ -90,7 +90,9 @@ $statusBadge = [ data-url="" data-download="download_url); ?>" data-token="" - data-label="label); ?>"> + data-label="label); ?>" + data-needs-dlid="needs_dlid ? '1' : '0'; ?>" + data-element="element); ?>"> Update to remote_version); ?> @@ -99,7 +101,9 @@ $statusBadge = [ data-url="" data-download="download_url); ?>" data-token="" - data-label="label); ?>"> + data-label="label); ?>" + data-needs-dlid="needs_dlid ? '1' : '0'; ?>" + data-element="element); ?>"> Install @@ -158,15 +162,37 @@ document.addEventListener('DOMContentLoaded', function() { var token = el.dataset.token; var label = el.dataset.label; + var needsDlid = el.dataset.needsDlid === '1'; + var dlid = ''; + + if (needsDlid) { + dlid = prompt('Enter download key for ' + label + ':', ''); + if (dlid === null) return; + if (!dlid.trim()) { + Joomla.renderMessages({error: ['Download key is required for ' + label]}); + return; + } + } + if (!confirm('Install ' + label + '?')) return; el.disabled = true; var origHtml = el.textContent; el.textContent = ' Installing...'; + // Append dlid to download URL if provided + var finalUrl = downloadUrl; + if (dlid) { + finalUrl += (downloadUrl.indexOf('?') !== -1 ? '&' : '?') + 'dlid=' + encodeURIComponent(dlid.trim()); + } + var fd = new FormData(); - fd.append('download_url', downloadUrl); + fd.append('download_url', finalUrl); fd.append(token, '1'); + if (dlid) { + fd.append('dlid', dlid.trim()); + fd.append('element', el.dataset.element || ''); + } fetch(url, { method: 'POST', diff --git a/source/packages/plg_system_mokowaas/Extension/MokoWaaS.php b/source/packages/plg_system_mokowaas/Extension/MokoWaaS.php index 9f51e832..d8345647 100644 --- a/source/packages/plg_system_mokowaas/Extension/MokoWaaS.php +++ b/source/packages/plg_system_mokowaas/Extension/MokoWaaS.php @@ -2243,102 +2243,134 @@ class MokoWaaS extends CMSPlugin implements BootableExtensionInterface { $db = Factory::getDbo(); - // Load current extra_query values for all update sites - $query = $db->getQuery(true) - ->select([ - $db->quoteName('update_site_id'), - $db->quoteName('extra_query'), - $db->quoteName('location'), - ]) - ->from($db->quoteName('#__update_sites')); - $db->setQuery($query); - $sites = $db->loadObjectList('update_site_id') ?: []; + // Sync: copy any new download keys FROM Joomla's update_sites TO our table + $this->syncKeysToTable($db); - $backupFile = JPATH_ADMINISTRATOR . '/cache/mokowaas_dlkeys.json'; - $backup = []; - - if (file_exists($backupFile)) - { - $backup = json_decode(file_get_contents($backupFile), true) ?: []; - } - - $restored = 0; - $updated = false; - - // Build a URL-keyed lookup from the backup for matching after ID changes - $backupByUrl = []; - - foreach ($backup as $bKey => $bVal) - { - if (str_starts_with((string) $bKey, 'url:')) - { - $backupByUrl[substr((string) $bKey, 4)] = $bVal; - } - } - - foreach ($sites as $id => $site) - { - $currentKey = trim((string) $site->extra_query); - $location = (string) $site->location; - - // Try matching by ID first, then by URL - $backupKey = $backup[$id] ?? $backupByUrl[$location] ?? ''; - - if ($currentKey !== '') - { - // Site has a key — update backup (by ID and URL) - if ($currentKey !== ($backup[$id] ?? '')) - { - $backup[$id] = $currentKey; - $backup['url:' . $location] = $currentKey; - $updated = true; - } - } - elseif ($backupKey !== '') - { - // Key was wiped — restore from backup - $db->setQuery( - $db->getQuery(true) - ->update($db->quoteName('#__update_sites')) - ->set($db->quoteName('extra_query') . ' = ' . $db->quote($backupKey)) - ->where($db->quoteName('update_site_id') . ' = ' . (int) $id) - )->execute(); - - // Update backup with new ID - $backup[$id] = $backupKey; - $backup['url:' . $location] = $backupKey; - $updated = true; - $restored++; - } - } - - // Clean up backup entries for IDs that no longer exist (keep URL keys) - foreach (array_keys($backup) as $backupId) - { - if (is_numeric($backupId) && !isset($sites[$backupId])) - { - unset($backup[$backupId]); - $updated = true; - } - } - - if ($updated || $restored > 0) - { - file_put_contents($backupFile, json_encode($backup, JSON_PRETTY_PRINT)); - } - - if ($restored > 0) - { - Log::add( - sprintf('MokoWaaS: restored %d download key(s) that were cleared by Joomla.', $restored), - Log::INFO, - 'mokowaas' - ); - } + // Apply: re-apply all stored keys FROM our table TO Joomla's update_sites + $this->applyKeysFromTable($db); } catch (\Throwable $e) { - // Non-critical — don't break the site over key backup + // Non-critical } } + + /** + * Copy non-empty download keys from Joomla's update_sites to our persistent table. + */ + private function syncKeysToTable($db): void + { + // Find all update sites with download keys + $db->setQuery( + $db->getQuery(true) + ->select([ + 'us.' . $db->quoteName('extra_query'), + 'us.' . $db->quoteName('location'), + 'e.' . $db->quoteName('element'), + ]) + ->from($db->quoteName('#__update_sites', 'us')) + ->join('INNER', $db->quoteName('#__update_sites_extensions', 'use') . ' ON us.update_site_id = use.update_site_id') + ->join('INNER', $db->quoteName('#__extensions', 'e') . ' ON e.extension_id = use.extension_id') + ->where($db->quoteName('us.extra_query') . ' LIKE ' . $db->quote('%dlid=%')) + ); + $rows = $db->loadObjectList() ?: []; + + $now = gmdate('Y-m-d H:i:s'); + + foreach ($rows as $row) + { + parse_str($row->extra_query, $parsed); + $dlid = $parsed['dlid'] ?? ''; + + if (empty($dlid)) + { + continue; + } + + // Upsert into our table + $db->setQuery( + $db->getQuery(true) + ->select('COUNT(*)') + ->from($db->quoteName('#__mokowaas_download_keys')) + ->where($db->quoteName('element') . ' = ' . $db->quote($row->element)) + ); + + if ((int) $db->loadResult() > 0) + { + $db->setQuery( + $db->getQuery(true) + ->update($db->quoteName('#__mokowaas_download_keys')) + ->set($db->quoteName('dlid') . ' = ' . $db->quote($dlid)) + ->set($db->quoteName('location') . ' = ' . $db->quote($row->location)) + ->set($db->quoteName('modified') . ' = ' . $db->quote($now)) + ->where($db->quoteName('element') . ' = ' . $db->quote($row->element)) + )->execute(); + } + else + { + $db->setQuery( + $db->getQuery(true) + ->insert($db->quoteName('#__mokowaas_download_keys')) + ->columns([$db->quoteName('element'), $db->quoteName('location'), $db->quoteName('dlid'), $db->quoteName('created'), $db->quoteName('modified')]) + ->values(implode(',', [ + $db->quote($row->element), + $db->quote($row->location), + $db->quote($dlid), + $db->quote($now), + $db->quote($now), + ])) + )->execute(); + } + } + } + + /** + * Re-apply all stored download keys from our table to Joomla's update_sites. + */ + private function applyKeysFromTable($db): void + { + try + { + $db->setQuery( + $db->getQuery(true) + ->select('*') + ->from($db->quoteName('#__mokowaas_download_keys')) + ->where($db->quoteName('dlid') . ' != ' . $db->quote('')) + ); + } + catch (\Throwable $e) + { + // Table might not exist yet (before install SQL runs) + return; + } + + $keys = $db->loadObjectList() ?: []; + + foreach ($keys as $key) + { + // Find update sites for this extension that are missing the key + $db->setQuery( + $db->getQuery(true) + ->select('us.' . $db->quoteName('update_site_id')) + ->from($db->quoteName('#__update_sites', 'us')) + ->join('INNER', $db->quoteName('#__update_sites_extensions', 'use') . ' ON us.update_site_id = use.update_site_id') + ->join('INNER', $db->quoteName('#__extensions', 'e') . ' ON e.extension_id = use.extension_id') + ->where($db->quoteName('e.element') . ' = ' . $db->quote($key->element)) + ->where('(' . $db->quoteName('us.extra_query') . ' = ' . $db->quote('') + . ' OR ' . $db->quoteName('us.extra_query') . ' NOT LIKE ' . $db->quote('%dlid=%') . ')') + ); + $siteIds = $db->loadColumn() ?: []; + + foreach ($siteIds as $siteId) + { + $db->setQuery( + $db->getQuery(true) + ->update($db->quoteName('#__update_sites')) + ->set($db->quoteName('extra_query') . ' = ' . $db->quote('dlid=' . $key->dlid)) + ->where($db->quoteName('update_site_id') . ' = ' . (int) $siteId) + )->execute(); + } + } + } + } diff --git a/source/script.php b/source/script.php index b56a8491..b0b8d88a 100644 --- a/source/script.php +++ b/source/script.php @@ -109,8 +109,9 @@ class Pkg_MokowaasInstallerScript // Clean up stale/duplicate update sites $this->cleanupStaleUpdateSites(); - // Restore download keys saved in preflight (before Joomla wiped them) + // Restore download keys: first from preflight backup, then from DB table $this->restoreDownloadKeys($this->savedDownloadKeys); + $this->reapplyKeysFromDatabase(); // Fix orphaned update records (extension_id=0) $this->fixUpdateRecords(); @@ -689,26 +690,12 @@ class Pkg_MokowaasInstallerScript foreach ($rows as $row) { - // Key by location so we can match after IDs change $keys[$row->location] = $row->extra_query; $keys['id_' . $row->update_site_id] = $row->extra_query; } - // Also save to file backup for the preserveDownloadKeys() runtime guard - $backupFile = JPATH_ADMINISTRATOR . '/cache/mokowaas_dlkeys.json'; - $existing = []; - - if (file_exists($backupFile)) - { - $existing = json_decode(file_get_contents($backupFile), true) ?: []; - } - - foreach ($rows as $row) - { - $existing[$row->update_site_id] = $row->extra_query; - } - - file_put_contents($backupFile, json_encode($existing, JSON_PRETTY_PRINT)); + // Also save to our persistent database table + $this->syncKeysToDatabase($db, $rows); } catch (\Throwable $e) { @@ -718,6 +705,154 @@ class Pkg_MokowaasInstallerScript return $keys; } + /** + * Sync current download keys to the persistent #__mokowaas_download_keys table. + */ + private function syncKeysToDatabase($db, array $rows): void + { + try + { + // Check if table exists + $tables = $db->getTableList(); + $prefix = $db->getPrefix(); + + if (!\in_array($prefix . 'mokowaas_download_keys', $tables, true)) + { + return; + } + + $now = gmdate('Y-m-d H:i:s'); + + foreach ($rows as $row) + { + parse_str($row->extra_query, $parsed); + $dlid = $parsed['dlid'] ?? ''; + + if (empty($dlid)) + { + continue; + } + + // Find the element for this update site + $db->setQuery( + $db->getQuery(true) + ->select('e.' . $db->quoteName('element')) + ->from($db->quoteName('#__update_sites_extensions', 'use')) + ->join('INNER', $db->quoteName('#__extensions', 'e') . ' ON e.extension_id = use.extension_id') + ->where($db->quoteName('use.update_site_id') . ' = ' . (int) $row->update_site_id), + 0, 1 + ); + $element = (string) $db->loadResult(); + + if (empty($element)) + { + continue; + } + + // Upsert + $db->setQuery( + $db->getQuery(true) + ->select('COUNT(*)') + ->from($db->quoteName('#__mokowaas_download_keys')) + ->where($db->quoteName('element') . ' = ' . $db->quote($element)) + ); + + if ((int) $db->loadResult() > 0) + { + $db->setQuery( + $db->getQuery(true) + ->update($db->quoteName('#__mokowaas_download_keys')) + ->set($db->quoteName('dlid') . ' = ' . $db->quote($dlid)) + ->set($db->quoteName('location') . ' = ' . $db->quote($row->location)) + ->set($db->quoteName('modified') . ' = ' . $db->quote($now)) + ->where($db->quoteName('element') . ' = ' . $db->quote($element)) + )->execute(); + } + else + { + $db->setQuery( + $db->getQuery(true) + ->insert($db->quoteName('#__mokowaas_download_keys')) + ->columns([$db->quoteName('element'), $db->quoteName('location'), $db->quoteName('dlid'), $db->quoteName('created'), $db->quoteName('modified')]) + ->values(implode(',', [ + $db->quote($element), $db->quote($row->location), $db->quote($dlid), $db->quote($now), $db->quote($now), + ])) + )->execute(); + } + } + } + catch (\Throwable $e) + { + // Non-critical — table may not exist yet + } + } + + /** + * Re-apply all download keys from our persistent database table. + */ + private function reapplyKeysFromDatabase(): void + { + try + { + $db = Factory::getDbo(); + $tables = $db->getTableList(); + $prefix = $db->getPrefix(); + + if (!\in_array($prefix . 'mokowaas_download_keys', $tables, true)) + { + return; + } + + $db->setQuery( + $db->getQuery(true) + ->select('*') + ->from($db->quoteName('#__mokowaas_download_keys')) + ->where($db->quoteName('dlid') . ' != ' . $db->quote('')) + ); + $keys = $db->loadObjectList() ?: []; + + $restored = 0; + + foreach ($keys as $key) + { + $db->setQuery( + $db->getQuery(true) + ->select('us.' . $db->quoteName('update_site_id')) + ->from($db->quoteName('#__update_sites', 'us')) + ->join('INNER', $db->quoteName('#__update_sites_extensions', 'use') . ' ON us.update_site_id = use.update_site_id') + ->join('INNER', $db->quoteName('#__extensions', 'e') . ' ON e.extension_id = use.extension_id') + ->where($db->quoteName('e.element') . ' = ' . $db->quote($key->element)) + ->where('(' . $db->quoteName('us.extra_query') . ' = ' . $db->quote('') + . ' OR ' . $db->quoteName('us.extra_query') . ' NOT LIKE ' . $db->quote('%dlid=%') . ')') + ); + $siteIds = $db->loadColumn() ?: []; + + foreach ($siteIds as $siteId) + { + $db->setQuery( + $db->getQuery(true) + ->update($db->quoteName('#__update_sites')) + ->set($db->quoteName('extra_query') . ' = ' . $db->quote('dlid=' . $key->dlid)) + ->where($db->quoteName('update_site_id') . ' = ' . (int) $siteId) + )->execute(); + $restored++; + } + } + + if ($restored > 0) + { + Factory::getApplication()->enqueueMessage( + sprintf('Re-applied %d download key(s) from persistent storage.', $restored), + 'message' + ); + } + } + catch (\Throwable $e) + { + // Non-critical + } + } + /** * Restore download keys that were cleared by update site cleanup. *