aboutsummaryrefslogtreecommitdiffstats
path: root/lib/private/App/AppStore
diff options
context:
space:
mode:
Diffstat (limited to 'lib/private/App/AppStore')
-rw-r--r--lib/private/App/AppStore/AppNotFoundException.php13
-rw-r--r--lib/private/App/AppStore/Bundles/Bundle.php42
-rw-r--r--lib/private/App/AppStore/Bundles/BundleFetcher.php47
-rw-r--r--lib/private/App/AppStore/Bundles/EducationBundle.php31
-rw-r--r--lib/private/App/AppStore/Bundles/EnterpriseBundle.php31
-rw-r--r--lib/private/App/AppStore/Bundles/GroupwareBundle.php28
-rw-r--r--lib/private/App/AppStore/Bundles/HubBundle.php32
-rw-r--r--lib/private/App/AppStore/Bundles/PublicSectorBundle.php36
-rw-r--r--lib/private/App/AppStore/Bundles/SocialSharingBundle.php28
-rw-r--r--lib/private/App/AppStore/Fetcher/AppDiscoverFetcher.php100
-rw-r--r--lib/private/App/AppStore/Fetcher/AppFetcher.php172
-rw-r--r--lib/private/App/AppStore/Fetcher/CategoryFetcher.php37
-rw-r--r--lib/private/App/AppStore/Fetcher/Fetcher.php229
-rw-r--r--lib/private/App/AppStore/Version/Version.php33
-rw-r--r--lib/private/App/AppStore/Version/VersionParser.php67
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
+ )
+ );
+ }
+}