<?php
namespace XF\AddOn;
use XF\Util\File;
use function count;
class AddOn implements \ArrayAccess
{
/**
* @var Manager
*/
protected $manager;
/**
* @var \XF\Entity\AddOn
*/
protected $installedAddOn;
/**
* @var \XF\AddOn\AbstractSetup
*/
protected $setup;
protected $addOnId;
protected $legacyAddOnId;
protected $addOnDir;
protected $buildDir;
protected $dataDir;
protected $filesDir;
protected $outputDir;
protected $releasesDir;
protected $jsonPath;
protected $json = [];
protected $buildJsonPath;
protected $buildJson = [];
protected $missingFiles = [];
protected $addOnHashes;
public function __construct($addOnOrId, Manager $manager)
{
$this->manager = $manager;
if ($addOnOrId instanceof \XF\Entity\AddOn)
{
$this->installedAddOn = $addOnOrId;
$this->addOnId = $addOnOrId->addon_id;
}
else
{
$this->addOnId = $addOnOrId;
}
$ds = \XF::$DS;
$this->addOnDir = \XF::getAddOnDirectory() . $ds . $this->prepareAddOnIdForPath();
$this->buildDir = $this->addOnDir . $ds . '_build';
$this->dataDir = $this->addOnDir . $ds . '_data';
$this->filesDir = $this->addOnDir . $ds . '_files';
$this->outputDir = $this->addOnDir . $ds . '_output';
$this->releasesDir = $this->addOnDir . $ds . '_releases';
$this->jsonPath = $this->addOnDir . $ds . 'addon.json';
if (file_exists($this->jsonPath))
{
$this->json = $this->prepareJsonFile(
json_decode(file_get_contents($this->jsonPath), true) ?: []
);
}
else
{
$this->missingFiles[] = 'addon.json';
}
if (!$this->installedAddOn && !empty($this->json['legacy_addon_id']))
{
$this->legacyAddOnId = $this->json['legacy_addon_id'];
$this->installedAddOn = \XF::em()->find('XF:AddOn', $this->legacyAddOnId);
}
$this->buildJsonPath = $this->addOnDir . $ds . 'build.json';
$this->buildJson = [
'additional_files' => [],
'minify' => [],
'rollup' => [],
'exec' => []
];
if (file_exists($this->buildJsonPath))
{
$buildJson = @json_decode(file_get_contents($this->buildJsonPath), true);
$this->buildJson = array_replace(
$this->buildJson, $buildJson ?: []
);
}
}
public function getInstalledAddOn()
{
return $this->installedAddOn;
}
public function getAddOnId()
{
return $this->addOnId;
}
public function getAddOnIdUrl()
{
return \XF::app()->repository('XF:AddOn')->convertAddOnIdToUrlVersion($this->getAddOnId());
}
public function prepareVersionForFilename()
{
$versionString = preg_replace('/[^a-z0-9-_. ]/i', '', $this->version_string);
return trim(preg_replace('/\s{2,}/', ' ', $versionString));
}
public function prepareAddOnIdForFilename()
{
if (strpos($this->addOnId, '/') !== false)
{
return str_replace('/', '-', $this->addOnId);
}
else
{
return $this->addOnId;
}
}
public function prepareAddOnIdForPath()
{
if (strpos($this->addOnId, '/') !== false)
{
return str_replace('/', \XF::$DS, $this->addOnId);
}
else
{
return $this->addOnId;
}
}
public function prepareAddOnIdForClass()
{
if (strpos($this->addOnId, '/') !== false)
{
return str_replace('/', '\\', $this->addOnId);
}
else
{
return $this->addOnId;
}
}
public function getMissingFiles()
{
return $this->missingFiles;
}
public function getAddOnDirectory()
{
return $this->addOnDir;
}
public function getBuildDirectory()
{
return $this->buildDir;
}
public function getDataDirectory()
{
return $this->dataDir;
}
public function getFilesDirectory()
{
return $this->filesDir;
}
public function getReleasesDirectory()
{
return $this->releasesDir;
}
public function getReleasePath()
{
$addOnId = $this->prepareAddOnIdForFilename();
$versionString = $this->prepareVersionForFilename();
return $this->releasesDir . \XF::$DS . "$addOnId-$versionString.zip";
}
public function getJsonPath()
{
return $this->jsonPath;
}
public function getJson()
{
return $this->json;
}
public function getJsonHash()
{
return \XF\Util\Hash::hashTextFile($this->jsonPath, 'sha256');
}
public function prepareJsonFile(array $json = [])
{
return array_replace([
'legacy_addon_id' => '',
'title' => '',
'description' => '',
'version_id' => 0,
'version_string' => '',
'dev' => '',
'dev_url' => '',
'faq_url' => '',
'support_url' => '',
'extra_urls' => [],
'require' => [],
'icon' => ''
], $json);
}
public function getBuildJsonPath()
{
return $this->buildJsonPath;
}
public function getBuildJson()
{
return $this->buildJson;
}
public function getIconPath()
{
return $this->addOnDir . \XF::$DS . $this->icon;
}
public function getHashesPath()
{
return $this->addOnDir . \XF::$DS . 'hashes.json';
}
public function getHashes()
{
if ($this->addOnHashes !== null)
{
return $this->addOnHashes;
}
$hashFile = $this->getHashesPath();
if (!file_exists($hashFile))
{
$this->addOnHashes = [];
return $this->addOnHashes;
}
$this->addOnHashes = json_decode(file_get_contents($hashFile), true) ?: [];
return $this->addOnHashes;
}
public function getSetupPath()
{
return $this->addOnDir . \XF::$DS . 'Setup.php';
}
/**
* @return AbstractSetup|false
*/
public function getSetup()
{
if ($this->setup === null)
{
$this->setup = false;
if (file_exists($this->getSetupPath()))
{
$class = $this->prepareAddOnIdForClass() . '\\Setup';
if (class_exists($class))
{
$setup = new $class($this, \XF::app());
if ($setup instanceof AbstractSetup)
{
$this->setup = $setup;
}
}
}
}
return $this->setup;
}
public function isInstalled()
{
if ($this->installedAddOn === null)
{
return false;
}
$lastAction = $this->installedAddOn->getLastActionStep('install');
if ($lastAction !== null)
{
// we haven't completed the install steps yet, don't consider as installed
return false;
}
return true;
}
public function isActive()
{
return $this->isInstalled()
&& $this->installedAddOn->active;
}
public function isLegacy()
{
return $this->isInstalled()
&& $this->installedAddOn->is_legacy;
}
public function isDevOutputAvailable()
{
return \XF::app()->developmentOutput()->isAddOnOutputAvailable($this->getAddOnId());
}
public function hasMissingFiles()
{
return count($this->missingFiles) > 0;
}
public function hasPendingChanges()
{
return $this->isInstalled()
&& !$this->isLegacy()
&& (!$this->isFileVersionValid()
|| $this->isJsonHashChanged()
);
}
public function hasFaIcon()
{
return (
$this->icon
&& !$this->hasIcon()
&& preg_match('#^(fa-|fa[a-z] )#', $this->icon)
);
}
public function hasIcon()
{
return($this->icon && file_exists($this->getIconPath()));
}
public function getIconUri()
{
if (!$this->icon)
{
return null;
}
$iconPath = $this->getIconPath();
$data = file_get_contents($iconPath);
$mimeType = File::getImageMimeType($iconPath);
if (!$mimeType)
{
return null;
}
return 'data:' . $mimeType . ';base64,' . base64_encode($data);
}
public function hasHashes()
{
return file_exists($this->getHashesPath());
}
public function hasSetup()
{
return file_exists($this->getSetupPath());
}
public function isAvailable()
{
return count($this->missingFiles) == 0;
}
public function isFileVersionValid()
{
return $this->isInstalled()
&& $this->isAvailable()
&& $this->installedAddOn->version_id == $this->json['version_id'];
}
public function isJsonHashChanged()
{
return (
$this->isAvailable() &&
$this->getJsonHash() !== $this->installedAddOn->json_hash
);
}
public function isJsonVersionNewer()
{
return ($this->json['version_id'] > $this->installedAddOn->version_id);
}
public function getJsonVersion()
{
return [
'version_id' => $this->json['version_id'],
'version_string' => $this->json['version_string']
];
}
public function canUpgrade()
{
return $this->isInstalled()
&& $this->isJsonHashChanged()
&& $this->isJsonVersionNewer()
&& $this->canEdit();
}
public function checkRequirements(&$errors = [], &$warnings = [])
{
$errors = [];
$warnings = [];
if ($this->require)
{
$title = $this->json['title'];
$require = (array)$this->require;
if (!$this->manager->checkAddOnRequirements($require, $title, $requirementErrors))
{
$errors = $requirementErrors;
return;
}
}
if ($this->hasSetup())
{
$setupClass = '\\' . $this->prepareAddOnIdForClass() . '\\Setup';
/** @var AbstractSetup $setup */
$setup = new $setupClass($this, \XF::app());
$setup->checkRequirements($errors, $warnings);
}
}
public function passesHealthCheck(&$missing = [], &$inconsistent = [])
{
$missing = [];
$inconsistent = [];
if (!$this->hasHashes())
{
return;
}
$rootPath = \XF::getRootDirectory();
foreach ((array)$this->getHashes() AS $path => $hash)
{
$fullPath = $rootPath . \XF::$DS . $path;
if (!file_exists($fullPath))
{
$missing[] = $path;
continue;
}
if (\XF\Util\Hash::hashTextFile($fullPath, 'sha256') !== $hash)
{
$inconsistent[] = $path;
continue;
}
}
}
public function updatePendingAction($action, $step)
{
if ($this->installedAddOn)
{
$this->installedAddOn->fastUpdate('last_pending_action', "$action:$step");
}
}
public function resetPendingAction()
{
if ($this->installedAddOn)
{
$this->installedAddOn->fastUpdate('last_pending_action', null);
}
}
public function preInstall()
{
if (!$this->installedAddOn)
{
$json = $this->getJson();
$installed = \XF::em()->create('XF:AddOn');
$installed->bulkSet([
'addon_id' => $this->getAddOnId(),
'title' => $json['title'],
'version_string' => $json['version_string'],
'version_id' => $json['version_id'],
'active' => true,
'is_processing' => true,
'json_hash' => $this->getJsonHash(),
'last_pending_action' => 'install:0'
]);
$installed->save();
$this->installedAddOn = $installed;
\XF::fire('addon_pre_install', [$this, $installed, $json], $installed->addon_id);
}
}
public function postInstall(array &$stateChanges)
{
$setup = $this->getSetup();
if ($setup)
{
$setup->postInstall($stateChanges);
}
$this->resetPendingAction();
$installed = $this->installedAddOn;
$json = $this->getJson();
\XF::fire('addon_post_install', [$this, $installed, $json, &$stateChanges], $installed->addon_id);
}
public function preUpgrade()
{
$installed = $this->installedAddOn;
if (!$installed)
{
throw new \LogicException("Add-on is not installed");
}
$isLegacy = $this->isLegacy();
if ($isLegacy)
{
if ($this->legacyAddOnId)
{
// Installed add-on is the legacy ID in this case, update it to the new add-on ID.
$installed->addon_id = $this->addOnId;
}
$installed->is_legacy = false;
}
// as we will be importing data after this, we don't need to bother doing this now
$installed->setOption('rebuild_active_change', false);
$installed->active = true;
$installed->is_processing = true;
$installed->saveIfChanged();
$installed->resetOption('rebuild_active_change');
$json = $this->getJson();
\XF::fire('addon_pre_upgrade', [$this, $installed, $json], $installed->addon_id);
}
public function postUpgrade(array &$stateChanges)
{
$installed = $this->installedAddOn;
$setup = $this->getSetup();
if ($setup)
{
$previousVersion = $installed ? $installed->version_id : null;
$setup->postUpgrade($previousVersion, $stateChanges);
}
$this->resetPendingAction();
$this->syncFromJson();
$json = $this->getJson();
\XF::fire('addon_post_upgrade', [$this, $installed, $json, &$stateChanges], $installed->addon_id);
}
public function preUninstall()
{
$installed = $this->installedAddOn;
if (!$installed)
{
throw new \LogicException("Add-on is not installed");
}
$installed->is_processing = true;
$installed->last_pending_action = 'uninstall:0';
$installed->saveIfChanged();
$json = $this->getJson();
\XF::fire('addon_pre_uninstall', [$this, $installed, $json], $installed->addon_id);
}
public function postUninstall()
{
/** @var \XF\Entity\FileCheck $fileCheck */
$fileCheck = \XF::em()->create('XF:FileCheck');
$fileCheck->save();
\XF::app()->jobManager()->enqueueUnique('fileCheck', 'XF:FileCheck', [
'check_id' => $fileCheck->check_id,
'automated' => true
], false);
$json = $this->getJson();
\XF::fire('addon_post_uninstall', [$this, $this->addOnId, $json], $this->addOnId);
}
public function preRebuild()
{
$installed = $this->installedAddOn;
if (!$installed)
{
throw new \LogicException("Add-on is not installed");
}
$installed->is_processing = true;
$installed->saveIfChanged();
$json = $this->getJson();
\XF::fire('addon_pre_rebuild', [$this, $installed, $json], $installed->addon_id);
}
public function postRebuild()
{
$installed = $this->installedAddOn;
$setup = $this->getSetup();
if ($setup)
{
$setup->postRebuild();
}
$this->syncFromJson();
$json = $this->getJson();
\XF::fire('addon_post_rebuild', [$this, $installed, $json], $installed->addon_id);
}
public function onActiveChange($newActive, array &$jobList)
{
$setup = $this->getSetup();
if ($setup)
{
$setup->onActiveChange($newActive, $jobList);
}
}
public function postDataImport()
{
// all data will be imported, re-enable this so postX methods will have access to their methods
$installed = $this->installedAddOn;
if (!$installed)
{
throw new \LogicException("Add-on is not installed");
}
$installed->is_processing = false;
$installed->saveIfChanged();
\XF::repository('XF:Option')->updateOption('jsLastUpdate', \XF::$time);
}
public function syncFromJson($extraChanges = [])
{
$installed = $this->installedAddOn;
if (!$installed)
{
throw new \LogicException("Add-on is not installed");
}
$json = $this->getJson();
$installed->bulkSet($extraChanges);
$installed->bulkSet([
'title' => $json['title'],
'version_string' => $json['version_string'],
'version_id' => $json['version_id'],
'json_hash' => $this->getJsonHash()
]);
$installed->saveIfChanged();
}
public function canInstall()
{
return !$this->isInstalled()
&& $this->isAvailable();
}
public function canUninstall()
{
return $this->isInstalled()
&& $this->canEdit()
&& (
!$this->json || ($this->isFileVersionValid() && !$this->hasMissingFiles())
);
}
public function canEdit()
{
return $this->installedAddOn
&& $this->installedAddOn->canEdit();
}
public function canRebuild()
{
// as long as the JSON is for the correct version, we're ok
return $this->isInstalled() && $this->isFileVersionValid() && $this->canEdit();
}
public function canDeleteFiles(): bool
{
return $this->canInstall()
&& $this->hasHashes()
&& !$this->isDevOutputAvailable()
&& $this->manager->canDeleteFilesForAddOn($this->addOnId);
}
public function getUndeletableFiles(): array
{
$undeletable = [];
$hashedFiles = array_keys($this->getHashes());
foreach ($hashedFiles AS $file)
{
if (!is_writable(\XF::getRootDirectory() . \XF::$DS . $file))
{
$undeletable[] = $file;
}
}
sort($undeletable, SORT_NATURAL | SORT_FLAG_CASE);
return $undeletable;
}
public function getUndeletableConflictingFiles(): array
{
$undeletable = [];
$hashedFiles = array_keys($this->getHashes());
$existingHashedFiles = array_keys($this->manager->getAddOnHashes($this->addOnId));
$conflicts = array_intersect($hashedFiles, $existingHashedFiles);
if (!empty($conflicts))
{
$undeletable = array_replace($undeletable, $conflicts);
}
$xfHashesPath = \XF::getAddOnDirectory() . \XF::$DS . 'XF' . \XF::$DS . 'hashes.json';
if (file_exists($xfHashesPath))
{
$xfHashedFiles = array_keys(json_decode(file_get_contents($xfHashesPath), true));
$xfConflicts = array_intersect($hashedFiles, $xfHashedFiles);
if (!empty($xfConflicts))
{
$undeletable = array_replace($undeletable, $xfConflicts);
}
}
sort($undeletable, SORT_NATURAL | SORT_FLAG_CASE);
return $undeletable;
}
#[\ReturnTypeWillChange]
public function offsetGet($key)
{
switch ($key)
{
case 'addon': return $this->getInstalledAddOn();
case 'addon_id': return $this->getAddOnId();
case 'addon_id_url': return $this->getAddOnIdUrl();
case 'json': return $this->getJson();
case 'json_version_id': return $this->json['version_id'];
case 'json_version_string': return $this->json['version_string'];
case 'missing_files': return $this->getMissingFiles();
case 'additional_files': return $this->getBuildJson()['additional_files'];
case 'legacy_addon_id': return $this->legacyAddOnId;
}
if ($this->installedAddOn && isset($this->installedAddOn[$key]))
{
return $this->installedAddOn[$key];
}
if (isset($this->json[$key]))
{
return $this->json[$key];
}
return null;
}
public function __get($key)
{
return $this->offsetGet($key);
}
#[\ReturnTypeWillChange]
public function offsetExists($key)
{
return $this->offsetGet($key) !== null;
}
#[\ReturnTypeWillChange]
public function offsetSet($key, $value)
{
throw new \LogicException("Cannot write to an add-on class");
}
#[\ReturnTypeWillChange]
public function offsetUnset($key)
{
throw new \LogicException("Cannot write to an add-on class");
}
}