diff options
Diffstat (limited to 'lib/private/App/AppStore')
-rw-r--r-- | lib/private/App/AppStore/AppNotFoundException.php | 13 | ||||
-rw-r--r-- | lib/private/App/AppStore/Bundles/Bundle.php | 42 | ||||
-rw-r--r-- | lib/private/App/AppStore/Bundles/BundleFetcher.php | 47 | ||||
-rw-r--r-- | lib/private/App/AppStore/Bundles/EducationBundle.php | 31 | ||||
-rw-r--r-- | lib/private/App/AppStore/Bundles/EnterpriseBundle.php | 31 | ||||
-rw-r--r-- | lib/private/App/AppStore/Bundles/GroupwareBundle.php | 28 | ||||
-rw-r--r-- | lib/private/App/AppStore/Bundles/HubBundle.php | 32 | ||||
-rw-r--r-- | lib/private/App/AppStore/Bundles/PublicSectorBundle.php | 36 | ||||
-rw-r--r-- | lib/private/App/AppStore/Bundles/SocialSharingBundle.php | 28 | ||||
-rw-r--r-- | lib/private/App/AppStore/Fetcher/AppDiscoverFetcher.php | 100 | ||||
-rw-r--r-- | lib/private/App/AppStore/Fetcher/AppFetcher.php | 172 | ||||
-rw-r--r-- | lib/private/App/AppStore/Fetcher/CategoryFetcher.php | 37 | ||||
-rw-r--r-- | lib/private/App/AppStore/Fetcher/Fetcher.php | 229 | ||||
-rw-r--r-- | lib/private/App/AppStore/Version/Version.php | 33 | ||||
-rw-r--r-- | lib/private/App/AppStore/Version/VersionParser.php | 67 |
15 files changed, 926 insertions, 0 deletions
diff --git a/lib/private/App/AppStore/AppNotFoundException.php b/lib/private/App/AppStore/AppNotFoundException.php new file mode 100644 index 00000000000..79ceebb4423 --- /dev/null +++ b/lib/private/App/AppStore/AppNotFoundException.php @@ -0,0 +1,13 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OC\App\AppStore; + +class AppNotFoundException extends \Exception { +} diff --git a/lib/private/App/AppStore/Bundles/Bundle.php b/lib/private/App/AppStore/Bundles/Bundle.php new file mode 100644 index 00000000000..1443be81e92 --- /dev/null +++ b/lib/private/App/AppStore/Bundles/Bundle.php @@ -0,0 +1,42 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\App\AppStore\Bundles; + +use OCP\IL10N; + +abstract class Bundle { + /** + * @param IL10N $l10n + */ + public function __construct( + protected IL10N $l10n, + ) { + } + + /** + * Get the identifier of the bundle + * + * @return string + */ + final public function getIdentifier() { + return substr(strrchr(get_class($this), '\\'), 1); + } + + /** + * Get the name of the bundle + * + * @return string + */ + abstract public function getName(); + + /** + * Get the list of app identifiers in the bundle + * + * @return array + */ + abstract public function getAppIdentifiers(); +} diff --git a/lib/private/App/AppStore/Bundles/BundleFetcher.php b/lib/private/App/AppStore/Bundles/BundleFetcher.php new file mode 100644 index 00000000000..4ff53b0c70b --- /dev/null +++ b/lib/private/App/AppStore/Bundles/BundleFetcher.php @@ -0,0 +1,47 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\App\AppStore\Bundles; + +use OCP\IL10N; + +class BundleFetcher { + public function __construct( + private IL10N $l10n, + ) { + } + + /** + * @return Bundle[] + */ + public function getBundles(): array { + return [ + new EnterpriseBundle($this->l10n), + new HubBundle($this->l10n), + new GroupwareBundle($this->l10n), + new SocialSharingBundle($this->l10n), + new EducationBundle($this->l10n), + new PublicSectorBundle($this->l10n), + ]; + } + + /** + * Get the bundle with the specified identifier + * + * @param string $identifier + * @return Bundle + * @throws \BadMethodCallException If the bundle does not exist + */ + public function getBundleByIdentifier(string $identifier): Bundle { + foreach ($this->getBundles() as $bundle) { + if ($bundle->getIdentifier() === $identifier) { + return $bundle; + } + } + + throw new \BadMethodCallException('Bundle with specified identifier does not exist'); + } +} diff --git a/lib/private/App/AppStore/Bundles/EducationBundle.php b/lib/private/App/AppStore/Bundles/EducationBundle.php new file mode 100644 index 00000000000..23681ec7416 --- /dev/null +++ b/lib/private/App/AppStore/Bundles/EducationBundle.php @@ -0,0 +1,31 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\App\AppStore\Bundles; + +class EducationBundle extends Bundle { + /** + * {@inheritDoc} + */ + public function getName() { + return $this->l10n->t('Education bundle'); + } + + /** + * {@inheritDoc} + */ + public function getAppIdentifiers() { + return [ + 'dashboard', + 'circles', + 'groupfolders', + 'announcementcenter', + 'quota_warning', + 'user_saml', + 'whiteboard', + ]; + } +} diff --git a/lib/private/App/AppStore/Bundles/EnterpriseBundle.php b/lib/private/App/AppStore/Bundles/EnterpriseBundle.php new file mode 100644 index 00000000000..fc2d43e0388 --- /dev/null +++ b/lib/private/App/AppStore/Bundles/EnterpriseBundle.php @@ -0,0 +1,31 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\App\AppStore\Bundles; + +class EnterpriseBundle extends Bundle { + /** + * {@inheritDoc} + */ + public function getName(): string { + return $this->l10n->t('Enterprise bundle'); + } + + /** + * {@inheritDoc} + */ + public function getAppIdentifiers(): array { + return [ + 'admin_audit', + 'user_ldap', + 'files_retention', + 'files_automatedtagging', + 'user_saml', + 'files_accesscontrol', + 'terms_of_service', + ]; + } +} diff --git a/lib/private/App/AppStore/Bundles/GroupwareBundle.php b/lib/private/App/AppStore/Bundles/GroupwareBundle.php new file mode 100644 index 00000000000..93fa70268cd --- /dev/null +++ b/lib/private/App/AppStore/Bundles/GroupwareBundle.php @@ -0,0 +1,28 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\App\AppStore\Bundles; + +class GroupwareBundle extends Bundle { + /** + * {@inheritDoc} + */ + public function getName() { + return $this->l10n->t('Groupware bundle'); + } + + /** + * {@inheritDoc} + */ + public function getAppIdentifiers() { + return [ + 'calendar', + 'contacts', + 'deck', + 'mail' + ]; + } +} diff --git a/lib/private/App/AppStore/Bundles/HubBundle.php b/lib/private/App/AppStore/Bundles/HubBundle.php new file mode 100644 index 00000000000..354e01e25c2 --- /dev/null +++ b/lib/private/App/AppStore/Bundles/HubBundle.php @@ -0,0 +1,32 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\App\AppStore\Bundles; + +class HubBundle extends Bundle { + public function getName() { + return $this->l10n->t('Hub bundle'); + } + + public function getAppIdentifiers() { + $hubApps = [ + 'spreed', + 'contacts', + 'calendar', + 'mail', + ]; + + $architecture = function_exists('php_uname') ? php_uname('m') : null; + if (isset($architecture) && PHP_OS_FAMILY === 'Linux' && in_array($architecture, ['x86_64', 'aarch64'])) { + $hubApps[] = 'richdocuments'; + $hubApps[] = 'richdocumentscode' . ($architecture === 'aarch64' ? '_arm64' : ''); + } + + return $hubApps; + } +} diff --git a/lib/private/App/AppStore/Bundles/PublicSectorBundle.php b/lib/private/App/AppStore/Bundles/PublicSectorBundle.php new file mode 100644 index 00000000000..106a6353029 --- /dev/null +++ b/lib/private/App/AppStore/Bundles/PublicSectorBundle.php @@ -0,0 +1,36 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\App\AppStore\Bundles; + +class PublicSectorBundle extends Bundle { + /** + * {@inheritDoc} + */ + public function getName(): string { + return $this->l10n->t('Public sector bundle'); + } + + /** + * {@inheritDoc} + */ + public function getAppIdentifiers(): array { + + return [ + 'files_confidential', + 'forms', + 'collectives', + 'files_antivirus', + 'twofactor_nextcloud_notification', + 'tables', + 'richdocuments', + 'admin_audit', + 'files_retention', + 'whiteboard', + ]; + } + +} diff --git a/lib/private/App/AppStore/Bundles/SocialSharingBundle.php b/lib/private/App/AppStore/Bundles/SocialSharingBundle.php new file mode 100644 index 00000000000..40f0fb15977 --- /dev/null +++ b/lib/private/App/AppStore/Bundles/SocialSharingBundle.php @@ -0,0 +1,28 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\App\AppStore\Bundles; + +class SocialSharingBundle extends Bundle { + /** + * {@inheritDoc} + */ + public function getName() { + return $this->l10n->t('Social sharing bundle'); + } + + /** + * {@inheritDoc} + */ + public function getAppIdentifiers() { + return [ + 'socialsharing_twitter', + 'socialsharing_facebook', + 'socialsharing_email', + 'socialsharing_diaspora', + ]; + } +} diff --git a/lib/private/App/AppStore/Fetcher/AppDiscoverFetcher.php b/lib/private/App/AppStore/Fetcher/AppDiscoverFetcher.php new file mode 100644 index 00000000000..8389e525750 --- /dev/null +++ b/lib/private/App/AppStore/Fetcher/AppDiscoverFetcher.php @@ -0,0 +1,100 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OC\App\AppStore\Fetcher; + +use DateTimeImmutable; +use OC\App\CompareVersion; +use OC\Files\AppData\Factory; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\Http\Client\IClientService; +use OCP\IConfig; +use OCP\Support\Subscription\IRegistry; +use Psr\Log\LoggerInterface; + +class AppDiscoverFetcher extends Fetcher { + + public const INVALIDATE_AFTER_SECONDS = 86400; + + public function __construct( + Factory $appDataFactory, + IClientService $clientService, + ITimeFactory $timeFactory, + IConfig $config, + LoggerInterface $logger, + IRegistry $registry, + private CompareVersion $compareVersion, + ) { + parent::__construct( + $appDataFactory, + $clientService, + $timeFactory, + $config, + $logger, + $registry + ); + + $this->fileName = 'discover.json'; + $this->endpointName = 'discover.json'; + } + + /** + * Get the app discover section entries + * + * @param bool $allowUnstable Include also upcoming entries + */ + public function get($allowUnstable = false) { + $entries = parent::get(false); + $now = new DateTimeImmutable(); + + return array_filter($entries, function (array $entry) use ($now, $allowUnstable) { + // Always remove expired entries + if (isset($entry['expiryDate'])) { + try { + $expiryDate = new DateTimeImmutable($entry['expiryDate']); + if ($expiryDate < $now) { + return false; + } + } catch (\Throwable $e) { + // Invalid expiryDate format + return false; + } + } + + // If not include upcoming entries, check for upcoming dates and remove those entries + if (!$allowUnstable && isset($entry['date'])) { + try { + $date = new DateTimeImmutable($entry['date']); + if ($date > $now) { + return false; + } + } catch (\Throwable $e) { + // Invalid date format + return false; + } + } + // Otherwise the entry is not time limited and should stay + return true; + }); + } + + public function getETag(): ?string { + $rootFolder = $this->appData->getFolder('/'); + + try { + $file = $rootFolder->getFile($this->fileName); + $jsonBlob = json_decode($file->getContent(), true); + + if (is_array($jsonBlob) && isset($jsonBlob['ETag'])) { + return (string)$jsonBlob['ETag']; + } + } catch (\Throwable $e) { + // ignore + } + return null; + } +} diff --git a/lib/private/App/AppStore/Fetcher/AppFetcher.php b/lib/private/App/AppStore/Fetcher/AppFetcher.php new file mode 100644 index 00000000000..bbf4b00245b --- /dev/null +++ b/lib/private/App/AppStore/Fetcher/AppFetcher.php @@ -0,0 +1,172 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\App\AppStore\Fetcher; + +use OC\App\AppStore\Version\VersionParser; +use OC\App\CompareVersion; +use OC\Files\AppData\Factory; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\Http\Client\IClientService; +use OCP\IConfig; +use OCP\Support\Subscription\IRegistry; +use Psr\Log\LoggerInterface; + +class AppFetcher extends Fetcher { + /** @var bool */ + private $ignoreMaxVersion; + + public function __construct( + Factory $appDataFactory, + IClientService $clientService, + ITimeFactory $timeFactory, + IConfig $config, + private CompareVersion $compareVersion, + LoggerInterface $logger, + protected IRegistry $registry, + ) { + parent::__construct( + $appDataFactory, + $clientService, + $timeFactory, + $config, + $logger, + $registry + ); + + $this->fileName = 'apps.json'; + $this->endpointName = 'apps.json'; + $this->ignoreMaxVersion = true; + } + + /** + * Only returns the latest compatible app release in the releases array + * + * @param string $ETag + * @param string $content + * @param bool [$allowUnstable] Allow unstable releases + * + * @return array + */ + protected function fetch($ETag, $content, $allowUnstable = false) { + /** @var mixed[] $response */ + $response = parent::fetch($ETag, $content); + + if (!isset($response['data']) || $response['data'] === null) { + $this->logger->warning('Response from appstore is invalid, apps could not be retrieved. Try again later.', ['app' => 'appstoreFetcher']); + return []; + } + + $allowPreReleases = $allowUnstable || $this->getChannel() === 'beta' || $this->getChannel() === 'daily' || $this->getChannel() === 'git'; + $allowNightly = $allowUnstable || $this->getChannel() === 'daily' || $this->getChannel() === 'git'; + + foreach ($response['data'] as $dataKey => $app) { + $releases = []; + + // Filter all compatible releases + foreach ($app['releases'] as $release) { + // Exclude all nightly and pre-releases if required + if (($allowNightly || $release['isNightly'] === false) + && ($allowPreReleases || !str_contains($release['version'], '-'))) { + // Exclude all versions not compatible with the current version + try { + $versionParser = new VersionParser(); + $serverVersion = $versionParser->getVersion($release['rawPlatformVersionSpec']); + $ncVersion = $this->getVersion(); + $minServerVersion = $serverVersion->getMinimumVersion(); + $maxServerVersion = $serverVersion->getMaximumVersion(); + $minFulfilled = $this->compareVersion->isCompatible($ncVersion, $minServerVersion, '>='); + $maxFulfilled = $maxServerVersion !== '' + && $this->compareVersion->isCompatible($ncVersion, $maxServerVersion, '<='); + $isPhpCompatible = true; + if (($release['rawPhpVersionSpec'] ?? '*') !== '*') { + $phpVersion = $versionParser->getVersion($release['rawPhpVersionSpec']); + $minPhpVersion = $phpVersion->getMinimumVersion(); + $maxPhpVersion = $phpVersion->getMaximumVersion(); + $minPhpFulfilled = $minPhpVersion === '' || $this->compareVersion->isCompatible( + PHP_VERSION, + $minPhpVersion, + '>=' + ); + $maxPhpFulfilled = $maxPhpVersion === '' || $this->compareVersion->isCompatible( + PHP_VERSION, + $maxPhpVersion, + '<=' + ); + + $isPhpCompatible = $minPhpFulfilled && $maxPhpFulfilled; + } + if ($minFulfilled && ($this->ignoreMaxVersion || $maxFulfilled) && $isPhpCompatible) { + $releases[] = $release; + } + } catch (\InvalidArgumentException $e) { + $this->logger->warning($e->getMessage(), [ + 'exception' => $e, + ]); + } + } + } + + if (empty($releases)) { + // Remove apps that don't have a matching release + $response['data'][$dataKey] = []; + continue; + } + + // Get the highest version + $versions = []; + foreach ($releases as $release) { + $versions[] = $release['version']; + } + usort($versions, function ($version1, $version2) { + return version_compare($version1, $version2); + }); + $versions = array_reverse($versions); + if (isset($versions[0])) { + $highestVersion = $versions[0]; + foreach ($releases as $release) { + if ((string)$release['version'] === (string)$highestVersion) { + $response['data'][$dataKey]['releases'] = [$release]; + break; + } + } + } + } + + $response['data'] = array_values(array_filter($response['data'])); + return $response; + } + + /** + * @param string $version + * @param string $fileName + * @param bool $ignoreMaxVersion + */ + public function setVersion(string $version, string $fileName = 'apps.json', bool $ignoreMaxVersion = true) { + parent::setVersion($version); + $this->fileName = $fileName; + $this->ignoreMaxVersion = $ignoreMaxVersion; + } + + public function get($allowUnstable = false): array { + $allowPreReleases = $allowUnstable || $this->getChannel() === 'beta' || $this->getChannel() === 'daily' || $this->getChannel() === 'git'; + + $apps = parent::get($allowPreReleases); + if (empty($apps)) { + return []; + } + $allowList = $this->config->getSystemValue('appsallowlist'); + + // If the admin specified a allow list, filter apps from the appstore + if (is_array($allowList) && $this->registry->delegateHasValidSubscription()) { + return array_filter($apps, function ($app) use ($allowList) { + return in_array($app['id'], $allowList); + }); + } + + return $apps; + } +} diff --git a/lib/private/App/AppStore/Fetcher/CategoryFetcher.php b/lib/private/App/AppStore/Fetcher/CategoryFetcher.php new file mode 100644 index 00000000000..d7857d41bee --- /dev/null +++ b/lib/private/App/AppStore/Fetcher/CategoryFetcher.php @@ -0,0 +1,37 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\App\AppStore\Fetcher; + +use OC\Files\AppData\Factory; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\Http\Client\IClientService; +use OCP\IConfig; +use OCP\Support\Subscription\IRegistry; +use Psr\Log\LoggerInterface; + +class CategoryFetcher extends Fetcher { + public function __construct( + Factory $appDataFactory, + IClientService $clientService, + ITimeFactory $timeFactory, + IConfig $config, + LoggerInterface $logger, + IRegistry $registry, + ) { + parent::__construct( + $appDataFactory, + $clientService, + $timeFactory, + $config, + $logger, + $registry + ); + + $this->fileName = 'categories.json'; + $this->endpointName = 'categories.json'; + } +} diff --git a/lib/private/App/AppStore/Fetcher/Fetcher.php b/lib/private/App/AppStore/Fetcher/Fetcher.php new file mode 100644 index 00000000000..24876675d60 --- /dev/null +++ b/lib/private/App/AppStore/Fetcher/Fetcher.php @@ -0,0 +1,229 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\App\AppStore\Fetcher; + +use GuzzleHttp\Exception\ConnectException; +use OC\Files\AppData\Factory; +use OCP\AppFramework\Http; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\Files\IAppData; +use OCP\Files\NotFoundException; +use OCP\Http\Client\IClientService; +use OCP\IConfig; +use OCP\Server; +use OCP\ServerVersion; +use OCP\Support\Subscription\IRegistry; +use Psr\Log\LoggerInterface; + +abstract class Fetcher { + public const INVALIDATE_AFTER_SECONDS = 3600; + public const INVALIDATE_AFTER_SECONDS_UNSTABLE = 900; + public const RETRY_AFTER_FAILURE_SECONDS = 300; + public const APP_STORE_URL = 'https://apps.nextcloud.com/api/v1'; + + /** @var IAppData */ + protected $appData; + + /** @var string */ + protected $fileName; + /** @var string */ + protected $endpointName; + /** @var ?string */ + protected $version = null; + /** @var ?string */ + protected $channel = null; + + public function __construct( + Factory $appDataFactory, + protected IClientService $clientService, + protected ITimeFactory $timeFactory, + protected IConfig $config, + protected LoggerInterface $logger, + protected IRegistry $registry, + ) { + $this->appData = $appDataFactory->get('appstore'); + } + + /** + * Fetches the response from the server + * + * @param string $ETag + * @param string $content + * + * @return array + */ + protected function fetch($ETag, $content, $allowUnstable = false) { + $appstoreenabled = $this->config->getSystemValueBool('appstoreenabled', true); + if ((int)$this->config->getAppValue('settings', 'appstore-fetcher-lastFailure', '0') > time() - self::RETRY_AFTER_FAILURE_SECONDS) { + return []; + } + + if (!$appstoreenabled) { + return []; + } + + $options = [ + 'timeout' => 60, + ]; + + if ($ETag !== '') { + $options['headers'] = [ + 'If-None-Match' => $ETag, + ]; + } + + if ($this->config->getSystemValueString('appstoreurl', self::APP_STORE_URL) === self::APP_STORE_URL) { + // If we have a valid subscription key, send it to the appstore + $subscriptionKey = $this->config->getAppValue('support', 'subscription_key'); + if ($this->registry->delegateHasValidSubscription() && $subscriptionKey) { + $options['headers'] ??= []; + $options['headers']['X-NC-Subscription-Key'] = $subscriptionKey; + } + } + + $client = $this->clientService->newClient(); + try { + $response = $client->get($this->getEndpoint(), $options); + } catch (ConnectException $e) { + $this->config->setAppValue('settings', 'appstore-fetcher-lastFailure', (string)time()); + $this->logger->error('Failed to connect to the app store', ['exception' => $e]); + return []; + } + + $responseJson = []; + if ($response->getStatusCode() === Http::STATUS_NOT_MODIFIED) { + $responseJson['data'] = json_decode($content, true); + } else { + $responseJson['data'] = json_decode($response->getBody(), true); + $ETag = $response->getHeader('ETag'); + } + $this->config->deleteAppValue('settings', 'appstore-fetcher-lastFailure'); + + $responseJson['timestamp'] = $this->timeFactory->getTime(); + $responseJson['ncversion'] = $this->getVersion(); + if ($ETag !== '') { + $responseJson['ETag'] = $ETag; + } + + return $responseJson; + } + + /** + * Returns the array with the entries on the appstore server + * + * @param bool [$allowUnstable] Allow unstable releases + * @return array + */ + public function get($allowUnstable = false) { + $appstoreenabled = $this->config->getSystemValueBool('appstoreenabled', true); + $internetavailable = $this->config->getSystemValueBool('has_internet_connection', true); + $isDefaultAppStore = $this->config->getSystemValueString('appstoreurl', self::APP_STORE_URL) === self::APP_STORE_URL; + + if (!$appstoreenabled || (!$internetavailable && $isDefaultAppStore)) { + $this->logger->info('AppStore is disabled or this instance has no Internet connection to access the default app store', ['app' => 'appstoreFetcher']); + return []; + } + + $rootFolder = $this->appData->getFolder('/'); + + $ETag = ''; + $content = ''; + + try { + // File does already exists + $file = $rootFolder->getFile($this->fileName); + $jsonBlob = json_decode($file->getContent(), true); + + if (is_array($jsonBlob)) { + // No caching when the version has been updated + if (isset($jsonBlob['ncversion']) && $jsonBlob['ncversion'] === $this->getVersion()) { + // If the timestamp is older than 3600 seconds request the files new + $invalidateAfterSeconds = self::INVALIDATE_AFTER_SECONDS; + + if ($allowUnstable) { + $invalidateAfterSeconds = self::INVALIDATE_AFTER_SECONDS_UNSTABLE; + } + + if ((int)$jsonBlob['timestamp'] > ($this->timeFactory->getTime() - $invalidateAfterSeconds)) { + return $jsonBlob['data']; + } + + if (isset($jsonBlob['ETag'])) { + $ETag = $jsonBlob['ETag']; + $content = json_encode($jsonBlob['data']); + } + } + } + } catch (NotFoundException $e) { + // File does not already exists + $file = $rootFolder->newFile($this->fileName); + } + + // Refresh the file content + try { + $responseJson = $this->fetch($ETag, $content, $allowUnstable); + + if (empty($responseJson) || empty($responseJson['data'])) { + return []; + } + + $file->putContent(json_encode($responseJson)); + return json_decode($file->getContent(), true)['data']; + } catch (ConnectException $e) { + $this->logger->warning('Could not connect to appstore: ' . $e->getMessage(), ['app' => 'appstoreFetcher']); + return []; + } catch (\Exception $e) { + $this->logger->warning($e->getMessage(), [ + 'exception' => $e, + 'app' => 'appstoreFetcher', + ]); + return []; + } + } + + /** + * Get the currently Nextcloud version + * @return string + */ + protected function getVersion() { + if ($this->version === null) { + $this->version = $this->config->getSystemValueString('version', '0.0.0'); + } + return $this->version; + } + + /** + * Set the current Nextcloud version + * @param string $version + */ + public function setVersion(string $version) { + $this->version = $version; + } + + /** + * Get the currently Nextcloud update channel + * @return string + */ + protected function getChannel() { + if ($this->channel === null) { + $this->channel = Server::get(ServerVersion::class)->getChannel(); + } + return $this->channel; + } + + /** + * Set the current Nextcloud update channel + * @param string $channel + */ + public function setChannel(string $channel) { + $this->channel = $channel; + } + + protected function getEndpoint(): string { + return $this->config->getSystemValueString('appstoreurl', 'https://apps.nextcloud.com/api/v1') . '/' . $this->endpointName; + } +} diff --git a/lib/private/App/AppStore/Version/Version.php b/lib/private/App/AppStore/Version/Version.php new file mode 100644 index 00000000000..2d169a291f1 --- /dev/null +++ b/lib/private/App/AppStore/Version/Version.php @@ -0,0 +1,33 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\App\AppStore\Version; + +class Version { + /** + * @param string $minVersion + * @param string $maxVersion + */ + public function __construct( + private string $minVersion, + private string $maxVersion, + ) { + } + + /** + * @return string + */ + public function getMinimumVersion() { + return $this->minVersion; + } + + /** + * @return string + */ + public function getMaximumVersion() { + return $this->maxVersion; + } +} diff --git a/lib/private/App/AppStore/Version/VersionParser.php b/lib/private/App/AppStore/Version/VersionParser.php new file mode 100644 index 00000000000..8976f28837f --- /dev/null +++ b/lib/private/App/AppStore/Version/VersionParser.php @@ -0,0 +1,67 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\App\AppStore\Version; + +/** + * Class VersionParser parses the versions as sent by the Nextcloud app store + * + * @package OC\App\AppStore + */ +class VersionParser { + /** + * @param string $versionString + * @return bool + */ + private function isValidVersionString($versionString) { + return (bool)preg_match('/^[0-9.]+$/', $versionString); + } + + /** + * Returns the version for a version string + * + * @param string $versionSpec + * @return Version + * @throws \Exception If the version cannot be parsed + */ + public function getVersion($versionSpec) { + // * indicates that the version is compatible with all versions + if ($versionSpec === '*') { + return new Version('', ''); + } + + // Count the amount of =, if it is one then it's either maximum or minimum + // version. If it is two then it is maximum and minimum. + $versionElements = explode(' ', $versionSpec); + $firstVersion = $versionElements[0] ?? ''; + $firstVersionNumber = substr($firstVersion, 2); + $secondVersion = $versionElements[1] ?? ''; + $secondVersionNumber = substr($secondVersion, 2); + + switch (count($versionElements)) { + case 1: + if (!$this->isValidVersionString($firstVersionNumber)) { + break; + } + if (str_starts_with($firstVersion, '>')) { + return new Version($firstVersionNumber, ''); + } + return new Version('', $firstVersionNumber); + case 2: + if (!$this->isValidVersionString($firstVersionNumber) || !$this->isValidVersionString($secondVersionNumber)) { + break; + } + return new Version($firstVersionNumber, $secondVersionNumber); + } + + throw new \Exception( + sprintf( + 'Version cannot be parsed: %s', + $versionSpec + ) + ); + } +} |