Current File : /var/www/prestashop/modules/ps_mbo/src/Module/SourceRetriever/AddonsUrlSourceRetriever.php |
<?php
/**
* Copyright since 2007 PrestaShop SA and Contributors
* PrestaShop is an International Registered Trademark & Property of PrestaShop SA
*
* NOTICE OF LICENSE
*
* This source file is subject to the Academic Free License version 3.0
* that is bundled with this package in the file LICENSE.md.
* It is also available through the world-wide-web at this URL:
* https://opensource.org/licenses/AFL-3.0
* If you did not receive a copy of the license and are unable to
* obtain it through the world-wide-web, please send an email
* to license@prestashop.com so we can send you a copy immediately.
*
* @author PrestaShop SA and Contributors <contact@prestashop.com>
* @copyright Since 2007 PrestaShop SA and Contributors
* @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0
*/
declare(strict_types=1);
namespace PrestaShop\Module\Mbo\Module\SourceRetriever;
use Exception;
use GuzzleHttp\Client;
use GuzzleHttp\ClientInterface;
use GuzzleHttp\Exception\ClientException;
use GuzzleHttp\Exception\GuzzleException;
use GuzzleHttp\Psr7\Utils;
use PrestaShop\Module\Mbo\Addons\Provider\AddonsDataProvider;
use PrestaShop\Module\Mbo\Exception\AddonsDownloadModuleException;
use PrestaShop\Module\Mbo\Helpers\AddonsApiHelper;
use PrestaShop\Module\Mbo\Helpers\ErrorHelper;
use PrestaShop\Module\Mbo\Helpers\ModuleErrorHelper;
use PrestaShop\Module\Mbo\Module\Exception\SourceNotCheckedException;
use PrestaShop\PrestaShop\Core\Module\Exception\ModuleErrorException;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
use ZipArchive;
class AddonsUrlSourceRetriever implements SourceRetrieverInterface
{
private const URL_VALIDATION_REGEX = '/^https?:\\/\\/(?:www\\.)?[-a-zA-Z0-9@:%._\\+~#=]{0,256}api-addons\\.prestashop\\.com(?:[-a-zA-Z0-9()@:%_\\+.~#?&\\/=]*)$/';
private const MODULE_REGEX = '/^(.*)\/\1\.php$/i';
private const ZIP_FILENAME_PATTERN = '/(\w+)\.zip\b/';
private const AUTHORIZED_MIME = [
'application/zip',
'application/x-gzip',
'application/gzip',
'application/x-gtar',
'application/x-tgz',
];
/**
* @var ?string
*/
private $moduleName;
/**
* @var string
*/
public $cacheDir;
/**
* @var string
*/
private $modulePath;
/**
* @var AddonsDataProvider
*/
private $addonsDataProvider;
/**
* @var mixed
*/
private $handledSource;
/**
* @var mixed
*/
private $handledSourceCredentials;
/**
* @var ClientInterface
*/
private $httpClient;
/**
* @var TranslatorInterface
*/
protected $translator;
public function __construct(
AddonsDataProvider $addonsDataProvider,
TranslatorInterface $translator,
string $modulePath
) {
$this->addonsDataProvider = $addonsDataProvider;
$this->translator = $translator;
$this->modulePath = rtrim($modulePath, '/') . '/';
$this->httpClient = new Client([
'timeout' => '7200',
'CURLOPT_FORBID_REUSE' => true,
'CURLOPT_FRESH_CONNECT' => true,
]);
}
/**
* @throws GuzzleException
*/
public function assertCanBeDownloaded($source): bool
{
if (!self::assertIsAddonsUrl($source)) {
return false;
}
try {
$authenticatedQueryParameters = $this->computeAuthentication($source);
$source = $authenticatedQueryParameters['source'];
$options = $authenticatedQueryParameters['options'] ?? [];
if (!is_array($options['headers'])) {
$options['headers'] = [];
}
$options['headers'] = array_merge($options['headers'], AddonsApiHelper::addCustomHeaderIfNeeded());
$response = $this->httpClient->request('HEAD', $source, $options);
} catch (TransportExceptionInterface|\Exception $e) {
if ($e instanceof ClientException) {
try {
$this->httpClient->request(
'GET',
$source,
$options
);
} catch (ClientException $clientException) {
throw ModuleErrorHelper::reportAndConvertError(
new AddonsDownloadModuleException($clientException, $authenticatedQueryParameters ?? []),
$authenticatedQueryParameters ?? []
);
}
}
ErrorHelper::reportError($e);
return false;
}
$this->moduleName = null;
if (preg_match(self::ZIP_FILENAME_PATTERN, $source, $moduleName) === 1) {
$this->moduleName = $moduleName[1];
}
$headers = $response->getHeaders();
if (isset($headers['Content-Disposition'])
&& preg_match(self::ZIP_FILENAME_PATTERN, reset($headers['Content-Disposition']), $moduleName) === 1
) {
$this->moduleName = $moduleName[1];
}
if (!empty($this->moduleName)
&& $response->getStatusCode() === 200
&& isset($headers['Content-Type'])
&& reset($headers['Content-Type']) === 'application/zip'
) {
$this->handledSource = $source;
$this->handledSourceCredentials = $options;
return true;
}
return false;
}
public function getModuleName($source): ?string
{
$this->assertSourceHasBeenChecked($source);
return $this->moduleName;
}
/**
* @throws GuzzleException
* @throws Exception
*/
public function get($source, ?string $expectedModuleName = null, ?array $options = []): string
{
$this->assertSourceHasBeenChecked($source);
// First save the file to filesystem
$temporaryFilename = tempnam($this->cacheDir, 'mod');
if (false === $temporaryFilename) {
throw new Exception('Failed to create temporary file to store downloaded source');
}
$temporaryZipFilename = $temporaryFilename . '.zip';
rename($temporaryFilename, $temporaryZipFilename);
$resource = fopen($temporaryZipFilename, 'w');
$stream = Utils::streamFor($resource);
$this->httpClient->request(
'GET',
$this->handledSource,
array_merge(['sink' => $stream], $this->handledSourceCredentials, $options)
);
if (null !== $expectedModuleName) {
$this->validate($temporaryZipFilename, $expectedModuleName);
}
return $temporaryZipFilename;
}
/**
* @throws Exception
*/
public function validate(string $zipFileName, string $expectedModuleName): bool
{
if (!$this->isZipFile($zipFileName)) {
throw new ModuleErrorException(
$this->translator->trans(
'This file does not seem to be a valid module zip',
[],
'Admin.Modules.Notification'
)
);
}
$zip = new ZipArchive();
if ($zip->open($zipFileName) === true) {
for ($i = 0; $i < $zip->numFiles; ++$i) {
if (preg_match(self::MODULE_REGEX, $zip->getNameIndex($i), $matches)) {
$zip->close();
$zipModuleName = $matches[1];
return $zipModuleName === $expectedModuleName;
}
}
$zip->close();
}
throw new ModuleErrorException(
$this->translator->trans(
'Downloaded zip file does not contain the expected module',
[],
'Admin.Modules.Notification'
)
);
}
public static function assertIsAddonsUrl($source): bool
{
return is_string($source) && 1 === preg_match(self::URL_VALIDATION_REGEX, $source);
}
private function assertSourceHasBeenChecked($source): void
{
$authenticatedQueryParameters = $this->computeAuthentication($source);
if ($authenticatedQueryParameters['source'] !== $this->handledSource) {
throw new SourceNotCheckedException('Method assertCanBeDownloaded() should be called first');
}
}
private function computeAuthentication(string $source): array
{
$url_parts = parse_url($source);
if (is_array($url_parts) && isset($url_parts['query']) && is_string($url_parts['query'])) {
parse_str($url_parts['query'], $params);
} else {
$params = [];
}
$requestOptions = [];
$authParams = $this->addonsDataProvider->getAuthenticationParams();
if (is_string($authParams['bearer'])) {
$requestOptions['headers'] = [
'Authorization' => 'Bearer ' . $authParams['bearer'],
];
$accountsShopUuid = $this->addonsDataProvider->getAccountsShopUuid();
if (!empty($accountsShopUuid)) {
$requestOptions['accounts_shop_uuid'] = $accountsShopUuid;
}
}
if (is_array($authParams['credentials'])) {
$params = array_merge($authParams['credentials'], $params);
}
$url_parts['query'] = urldecode(http_build_query($params));
$source = http_build_url($url_parts);
return [
'source' => $source,
'options' => $requestOptions,
];
}
private function isZipFile(string $file): bool
{
return is_file($file) && in_array(mime_content_type($file), self::AUTHORIZED_MIME);
}
}