Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
142 changes: 79 additions & 63 deletions lib/private/App/AppStore/Fetcher/AppFetcher.php
Original file line number Diff line number Diff line change
Expand Up @@ -60,80 +60,95 @@ protected function fetch($ETag, $content, $allowUnstable = false) {
return [];
}

$allowPreReleases = $allowUnstable || $this->getChannel() === 'beta' || $this->getChannel() === 'daily' || $this->getChannel() === 'git';
$allowNightly = $allowUnstable || $this->getChannel() === 'daily' || $this->getChannel() === 'git';
$channel = $this->getChannel();
$allowPreReleases = $allowUnstable || $channel === 'beta' || $channel === 'daily' || $channel === 'git';
$allowNightly = $allowUnstable || $channel === 'daily' || $channel === 'git';

$versionParser = new VersionParser();
$ncVersion = $this->getVersion();
$currentPhpVersion = PHP_VERSION;
$ignoreMaxVersion = $this->ignoreMaxVersion;

/** @var array<string, array{0: string, 1: string}> $platformSpecCache */
$platformSpecCache = [];
/** @var array<string, array{0: string, 1: string}> $phpSpecCache */
$phpSpecCache = [];

foreach ($response['data'] as $dataKey => $app) {
$releases = [];
$bestRelease = null;

// Filter all compatible releases
// Filter 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;
// Exclude nightly builds
if ($release['isNightly'] !== false && !$allowNightly) {
continue;
}

// Exclude pre-releases
if (str_contains($release['version'], '-') && !$allowPreReleases) {
continue;
}

try {
$rawPlatformVersionSpec = $release['rawPlatformVersionSpec'] ?? null;
if ($rawPlatformVersionSpec === null) {
continue; // no spec; treat as incompatible, skip
}
if (!isset($platformSpecCache[$rawPlatformVersionSpec])) {
$serverVersion = $versionParser->getVersion($rawPlatformVersionSpec);
$platformSpecCache[$rawPlatformVersionSpec] = [
$serverVersion->getMinimumVersion(),
$serverVersion->getMaximumVersion(),
];
}

[$minServerVersion, $maxServerVersion] = $platformSpecCache[$rawPlatformVersionSpec];

$minFulfilled = $this->compareVersion->isCompatible($ncVersion, $minServerVersion, '>=');
$maxFulfilled = $maxServerVersion !== '' && $this->compareVersion->isCompatible($ncVersion, $maxServerVersion, '<=');

$isPhpCompatible = true;

$rawPhpVersionSpec = $release['rawPhpVersionSpec'] ?? '*';
if ($rawPhpVersionSpec !== '*') {
if (!isset($phpSpecCache[$rawPhpVersionSpec])) {
$phpVersion = $versionParser->getVersion($rawPhpVersionSpec);
$phpSpecCache[$rawPhpVersionSpec] = [
$phpVersion->getMinimumVersion(),
$phpVersion->getMaximumVersion(),
];
}
} catch (\InvalidArgumentException $e) {
$this->logger->warning($e->getMessage(), [
'exception' => $e,
]);

[$minPhpVersion, $maxPhpVersion] = $phpSpecCache[$rawPhpVersionSpec];

$minPhpFulfilled = $minPhpVersion === '' || $this->compareVersion->isCompatible($currentPhpVersion, $minPhpVersion, '>=');
$maxPhpFulfilled = $maxPhpVersion === '' || $this->compareVersion->isCompatible($currentPhpVersion, $maxPhpVersion, '<=');

$isPhpCompatible = $minPhpFulfilled && $maxPhpFulfilled;
}

$isCompatible = $minFulfilled && ($ignoreMaxVersion || $maxFulfilled) && $isPhpCompatible;

if (!$isCompatible) {
continue;
}

$betterRelease = $bestRelease === null || version_compare((string)$release['version'], (string)$bestRelease['version'], '>');
if ($betterRelease) {
$bestRelease = $release;
}
} catch (\InvalidArgumentException $e) {
$this->logger->warning($e->getMessage(), [ 'exception' => $e, ]);
}
}

if (empty($releases)) {
if ($bestRelease === null) {
// 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'][$dataKey]['releases'] = [$bestRelease];
}

$response['data'] = array_values(array_filter($response['data']));
Expand All @@ -158,12 +173,13 @@ public function get($allowUnstable = false): array {
if (empty($apps)) {
return [];
}
$allowList = $this->config->getSystemValue('appsallowlist');

// If the admin specified a allow list, filter apps from the appstore
$allowList = $this->config->getSystemValue('appsallowlist');
if (is_array($allowList) && $this->registry->delegateHasValidSubscription()) {
return array_filter($apps, function ($app) use ($allowList) {
return in_array($app['id'], $allowList);
$allowSet = array_flip($allowList);
return array_filter($apps, static function ($app) use ($allowSet) {
return isset($allowSet[$app['id']]);
});
}

Expand Down
18 changes: 10 additions & 8 deletions lib/private/App/AppStore/Fetcher/Fetcher.php
Original file line number Diff line number Diff line change
Expand Up @@ -134,13 +134,18 @@

$ETag = '';
$content = '';
$cachedData = null;

try {
// File does already exists
$file = $rootFolder->getFile($this->fileName);
$jsonBlob = json_decode($file->getContent(), true);

if (is_array($jsonBlob)) {
if (isset($jsonBlob['data']) && is_array($jsonBlob['data'])) {
$cachedData = $jsonBlob['data'];
}

// 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
Expand All @@ -151,7 +156,7 @@
}

if ((int)$jsonBlob['timestamp'] > ($this->timeFactory->getTime() - $invalidateAfterSeconds)) {
return $jsonBlob['data'];

Check failure on line 159 in lib/private/App/AppStore/Fetcher/Fetcher.php

View workflow job for this annotation

GitHub Actions / static-code-analysis

NullableReturnStatement

lib/private/App/AppStore/Fetcher/Fetcher.php:159:14: NullableReturnStatement: The declared return type 'array<array-key, mixed>' for OC\App\AppStore\Fetcher\Fetcher::get is not nullable, but the function returns 'array<array-key, mixed>|mixed|null' (see https://psalm.dev/139)
}

if (isset($jsonBlob['ETag'])) {
Expand All @@ -170,20 +175,17 @@
$responseJson = $this->fetch($ETag, $content, $allowUnstable);

if (empty($responseJson) || empty($responseJson['data'])) {
return [];
return is_array($cachedData) ? $cachedData : [];
}

$file->putContent(json_encode($responseJson));
return json_decode($file->getContent(), true)['data'];
return $responseJson['data'];
} catch (ConnectException $e) {
$this->logger->warning('Could not connect to appstore: ' . $e->getMessage(), ['app' => 'appstoreFetcher']);
return [];
return is_array($cachedData) ? $cachedData : [];
} catch (\Exception $e) {
$this->logger->warning($e->getMessage(), [
'exception' => $e,
'app' => 'appstoreFetcher',
]);
return [];
$this->logger->warning($e->getMessage(), ['exception' => $e, 'app' => 'appstoreFetcher',]);
return is_array($cachedData) ? $cachedData : [];
}
}

Expand Down
93 changes: 93 additions & 0 deletions tests/lib/App/AppStore/Fetcher/AppFetcherTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -2250,4 +2250,97 @@ public function testGetAppsAllowlistCustomAppstore(): void {
$this->assertEquals(count($apps), 1);
$this->assertEquals($apps[0]['id'], 'contacts');
}

public function testGetKeepsHighestCompatibleReleaseOnly(): void {
$this->config->method('getSystemValueString')
->willReturnCallback(function ($key, $default) {
if ($key === 'version') {
return '30.0.0';
} elseif ($key === 'appstoreurl' && $default === 'https://apps.nextcloud.com/api/v1') {
return 'https://custom.appsstore.endpoint/api/v1';
}
return $default;
});
$this->config->method('getSystemValueBool')
->willReturnArgument(1);

$file = $this->createMock(ISimpleFile::class);
$folder = $this->createMock(ISimpleFolder::class);
$folder
->expects($this->once())
->method('getFile')
->with('apps.json')
->willThrowException(new NotFoundException());
$folder
->expects($this->once())
->method('newFile')
->with('apps.json')
->willReturn($file);
$this->appData
->expects($this->once())
->method('getFolder')
->with('/')
->willReturn($folder);

$client = $this->createMock(IClient::class);
$this->clientService
->expects($this->once())
->method('newClient')
->willReturn($client);

$response = $this->createMock(IResponse::class);
$client
->expects($this->once())
->method('get')
->with('https://custom.appsstore.endpoint/api/v1/apps.json')
->willReturn($response);

$response
->expects($this->once())
->method('getBody')
->willReturn(json_encode([
[
'id' => 'testapp',
'releases' => [
[
'version' => '1.0.0',
'isNightly' => false,
'rawPhpVersionSpec' => '*',
'rawPlatformVersionSpec' => '>=30 <=30',
],
[
'version' => '1.5.0',
'isNightly' => false,
'rawPhpVersionSpec' => '*',
'rawPlatformVersionSpec' => '>=30 <=30',
],
[
'version' => '2.0.0',
'isNightly' => false,
'rawPhpVersionSpec' => '*',
'rawPlatformVersionSpec' => '>=31 <=31',
],
],
],
], JSON_THROW_ON_ERROR));
$response->method('getHeader')
->with($this->equalTo('ETag'))
->willReturn('"myETag"');

$this->timeFactory
->expects($this->once())
->method('getTime')
->willReturn(1234);

$file
->expects($this->once())
->method('putContent');

$result = $this->fetcher->get();

$this->assertCount(1, $result);
$this->assertSame('testapp', $result[0]['id']);
$this->assertCount(1, $result[0]['releases']);
$this->assertSame('1.5.0', $result[0]['releases'][0]['version']);
}
}
Loading
Loading