Skip to content

Download IDs

Nicholas K. Dionysopoulos edited this page May 26, 2021 · 2 revisions

Some software (categories) or download items may have access restrictions. For example, you may have a free of charge and a paid edition of your software for each published version. You want the former to be freely downloadable for everyone but the latter only available to paying customers.

ARS already allows you to restrict access to categories, releases or individual items based on Joomla access levels. The problem is that when an automatic update takes place, e.g. through Joomla's Extensions Update page, the download client is NOT logged into your site. Making updates available without authentication is not a realistic possibility because everyone could download your software. So what do you do?

We have contributed a feature to Joomla which has been included in it since Joomla 3.2. It allows the developer to add a set of query string parameters in the extra_query column of the #__update_sites page. In Joomla 4.0 this has been further refined into a user interface called Download Keys.

ARS supports this kind of Download Keys since 2010 — long before Joomla added support for them. We call them Download IDs. We were one of the first companies — if not the first company — in the Joomla extensions developer ecosystem to offer integrated updates, including updates for the paid editions of our extensions. It's no wonder that Joomla's support for commercial extensions' updates is modeled after our implementation and Akeeba Release System.

In Akeeba Release System each Joomla user is assigned a Main Download ID which is generated automatically for them. On top of that, they can create one or more Add-on Download IDs e.g. to have a different key per domain name they manage. These can be centrally managed in the backend through the Download IDs page. Moreover, the frontend Download IDs page allows the user to self-manage their own keys.

As for the extra_query it should be in the form dlid=DOWNLOAD_KEY where DOWNLOAD_KEY is the user-provided Download ID. ARS sees the dlid query string parameter in the download URL and looks for the DOWNLOAD_KEY in your site's database. If it's found it temporarily logs in the corresponding user and goes through the download process, therefore taking into account the correct Joomla! user groups and access levels for this user. At the end of the process the temporary login is reversed, i.e. the user is logged out, to prevent abusing the temporary session created on your site.

Using Download IDs with Joomla (for developers)

Background information

Each installed extension in Joomla has a corresponding #__extensions record. An #__extensions record can optionally have an #__update_sites_extensions record which links it to an #__update_sites record. The latter is created or update by Joomla automatically every time you install or update an extension.

Please note that you should only include an update server in the XML manifest of the extension you are distributing. That is to say, if you distribute a component and its associated modules and plugins as a “package” type extension (pkg_something) you need to set up the update server in the package's XML manifest, NOT the component's manifest.

The #__update_sites record has an extra_query column. The contents of this column are appended to the download URL for the update package file when Joomla is trying to install an update through its Updates page. For software distributed with Akeeba Release System the extra_query column must have the format dlid=DOWNLOAD_ID where DOWNLOAD_ID is the user's Download ID.

Integrating ARS-compatible, authenticated downloads with Joomla's extensions update is a matter of populating the extra_query column of your extension's update site. How to do that depends on the Joomla version you are using.

Joomla 3

Joomla 3 does not have an interface for managing the download keys / download IDs. This means that you will need to have your user enter the Download ID in a user interface element and then run custom code to find the #__update_sites record for your extension and update its extra_query column. Typically, you have a component, module or plugin option for the Download ID and some common code running when an administrative user accesses your extension to check and, if necessary, update the extra_query column. You can find sample code for that in FOF 4's Update model. Yes, it's very complicated.

Moreover, this approach has many caveats, least of which is the fact that Joomla may reset the extra_query column when rebuilding the update sites (versions older than 3.9.26) or when the user updates your extension.

Another approach to this problem is ignoring the extra_query column, instead writing a plugin in the installer folder. In this case the plugin is responsible for altering the download URL of the update to include the Download ID. The only drawback is that updates will fail unless this plugin is enabled. A sample plugin for a package called pkg_example, distributed as a file called pkg_example-VERSION-pro.zip, which includes a component named com_example with its Download ID saved to a configuration parameter called dlid can be found below.

defined('_JEXEC') || die;

use Joomla\CMS\Component\ComponentHelper;
use Joomla\CMS\Plugin\CMSPlugin;
use Joomla\CMS\Uri\Uri;
use Joomla\Registry\Registry;

/**
 * Component installer helper
 *
 * Adds the Download ID query string parameter to the download URL if for any reason your Joomla Update Sites had the
 * extra_query column wiped (e.g. by rebuilding the update sites list).
 */
class plgInstallerExample extends CMSPlugin
{
	/**
	 * The filename in the download URL must match this pattern for this plugin to add the Download ID.
	 *
	 * @var   string
	 */
	private $urlFilePattern = 'pkg_example-*pro.zip';

	/**
	 * List of URL prefixes we are allowed to work with
	 *
	 * @var   array
	 */
	private $urlPrefixes = [
		'https://www.example.com',
	];

	/**
	 * The name of the component whose options include the Download ID
	 *
	 * @var string
	 */
	private $componentName = 'com_example';

	/**
	 * The name of the option in your component's `config.xml` file which contains the Download ID
	 *
	 * @var string
	 */
	private $downloadIDOptionKey = 'dlid';

	/**
	 * Handles Joomla's event fired before downloading an update package.
	 *
	 * @param   string  $url      The URL of the package Joomla is trying to install
	 * @param   array   $headers  The HTTP headers used for downloading the package
	 *
	 * @return  void
	 */
	public function onInstallerBeforePackageDownload(&$url, &$headers)
	{
		// This plugin only applies to Joomla! 3
		if (version_compare(JVERSION, '3.999.999', 'gt'))
		{
			return;
		}

		// Make sure the URL belongs to one of our domain names
		if (!$this->hasAllowedPrefix($url))
		{
			return;
		}

		// Make sure the URL seems to be for a package are meant to handle
		$uri      = Uri::getInstance($url);
		$path     = $uri->getPath();
		$baseName = basename($path);

		if (!fnmatch($this->urlFilePattern, $baseName))
		{
			return;
		}

		// Make sure this URL does not already have a download ID
		if ($this->hasDownloadID($uri))
		{
			return;
		}

		// Get the Download ID and make sure it's not empty
		try
		{
			$dlid = $this->getDownloadID();
		}
		catch (Exception $e)
		{
			$dlid = '';
		}

		if (empty($dlid))
		{
			return;
		}

		// Apply the download ID to the download URL
		$uri->setVar('dlid', $dlid);

		$url = $uri->toString();
	}

	/**
	 * Checks if the download URL is one we're supposed to handle. We have a whitelist of allowed URL prefixes set up
	 * at the top of this plugin.
	 *
	 * @param   string  $url  The download URL to check
	 *
	 * @return  bool
	 */
	private function hasAllowedPrefix($url)
	{
		$hasAllowedPrefix = false;

		foreach ($this->urlPrefixes as $prefix)
		{
			$hasAllowedPrefix = $hasAllowedPrefix || (strpos($url, $prefix) === 0);
		}

		return $hasAllowedPrefix;
	}

	/**
	 * Does the download URL already have a non-empty Download ID query parameter?
	 *
	 * @param   Uri  $uri  The download URL to check
	 *
	 * @return  bool
	 */
	private function hasDownloadID($uri)
	{
		$dlid = $uri->getVar('dlid', null);

		return !empty($dlid);

	}

	/**
	 * Get the applicable Download ID for the extension.
	 *
	 * @return  string
	 */
	private function getDownloadID()
	{
		// Make sure the component is installed and enabled
		if (!ComponentHelper::isInstalled($this->componentName) || !ComponentHelper::isEnabled($this->componentName))
		{
			return null;
		}

		// Get the component's parameters
		$params = ComponentHelper::getParams($this->componentName);

		// Make sure the parameters object is valid
		if (is_null($params) || !is_object($params) || !($params instanceof Registry))
		{
			return '';
		}

		$value = $params->get($this->downloadIDOptionKey, null);

		if (empty($value))
		{
			return '';
		}

		return empty($value) ? '' : $value;
	}
}

We strongly recommend that you use BOTH methods simultaneously on Joomla 3. The extra_query column in #__update_sites should be your primary method and the installer plugin your failsafe.

Joomla 4

Joomla 4 is MUCH better in supporting commercial extensions. The extra_query column in the #__update_sites can be managed through the System, Update Sites page. Joomla 4 will even warn the users if a Download Key is missing, i.e. if they have an extension which says that it needs a Download Key but the extra_query column contents are empty or invalid.

So, for Joomla 4 you only need to add the following line to the XML manifest of the extension which also defines the update server (as noted above):

<dlid prefix="dlid=" suffix=""/>

That's all there is to it. No complicated custom code, no need for installer plugins. It's all managed by Joomla just by adding this ridiculously short line into your XML manifest.