Skip to content

Commit 4d8c93c

Browse files
fix(AppStore): resolve Psalm errors in Fetcher and harden cache fallback
Replace the by-reference `$useCachedData` closure with a typed private `useCachedData()` method, fixing Psalm's inferred-type errors around `get()`'s `list<T>` return type and slightly improving readability. Validate that decoded JSON is a list (not merely an array) at every point where untrusted data enters the contract: App Store responses, 304 conditional-request replays, and local cache files. Guard cache re-encoding and writes against failure without discarding valid fresh data, and expand the docblocks to document fallback behavior. Also refactor formatting and guard logic, including ordering and cross-method alignment, for clarity. Signed-off-by: Josh <josh.t.richards@gmail.com>
1 parent a66dfa4 commit 4d8c93c

1 file changed

Lines changed: 170 additions & 82 deletions

File tree

lib/private/App/AppStore/Fetcher/Fetcher.php

Lines changed: 170 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -32,20 +32,17 @@ abstract class Fetcher {
3232
public const INVALIDATE_AFTER_SECONDS = 3600;
3333
public const INVALIDATE_AFTER_SECONDS_UNSTABLE = 900;
3434
public const RETRY_AFTER_FAILURE_SECONDS = 300;
35-
public const MAX_STALE_SECONDS = 604800; // 7 days
35+
/**
36+
* Maximum age of same-version cache data eligible for refresh failure fallback.
37+
*/
38+
public const MAX_STALE_SECONDS = 7 * 24 * 60 * 60;
3639
public const APP_STORE_URL = 'https://apps.nextcloud.com/api/v1';
3740

38-
/** @var IAppData */
39-
protected $appData;
40-
41-
/** @var string */
42-
protected $fileName;
43-
/** @var string */
44-
protected $endpointName;
45-
/** @var ?string */
46-
protected $version = null;
47-
/** @var ?string */
48-
protected $channel = null;
41+
protected IAppData $appData;
42+
protected string $fileName;
43+
protected string $endpointName;
44+
protected ?string $version = null;
45+
protected ?string $channel = null;
4946

5047
public function __construct(
5148
Factory $appDataFactory,
@@ -59,21 +56,32 @@ public function __construct(
5956
}
6057

6158
/**
62-
* Fetches the response from the server
59+
* Fetches and validates the response from the App Store server.
6360
*
64-
* @param string $ETag - The ETag of the cached response
65-
* @param string $content - The content of the response
66-
* @param bool $allowUnstable - Allow unstable releases
61+
* A successful response contains a list of App Store entries and cache
62+
* metadata. A suppressed, failed, or invalid refresh returns an empty
63+
* array, allowing get() to consider an eligible stale cache.
6764
*
68-
* @return array{data: list<T>, ETag?: string, timestamp: int, ncversion: string}|array<never, never>
65+
* @param string $ETag The ETag of the cached response, if available.
66+
* @param string $content The serialized cached response data used for a
67+
* 304 Not Modified response.
68+
* @param bool $allowUnstable Whether unstable releases should be requested.
69+
*
70+
* @return array{
71+
* data: list<T>,
72+
* ETag?: string,
73+
* timestamp: int,
74+
* ncversion: string
75+
* }|array<never, never>
6976
*/
70-
protected function fetch($ETag, $content, $allowUnstable = false): array {
77+
protected function fetch(string $ETag, string $content, bool $allowUnstable = false): array {
7178
$appstoreEnabled = $this->config->getSystemValueBool('appstoreenabled', true);
72-
if ((int)$this->config->getAppValue('settings', 'appstore-fetcher-lastFailure', '0') > time() - self::RETRY_AFTER_FAILURE_SECONDS) {
79+
if (!$appstoreEnabled) {
7380
return [];
7481
}
7582

76-
if (!$appstoreEnabled) {
83+
$lastFailure = (int)$this->config->getAppValue('settings', 'appstore-fetcher-lastFailure', '0');
84+
if ($lastFailure > (time() - self::RETRY_AFTER_FAILURE_SECONDS)) {
7785
return [];
7886
}
7987

@@ -87,10 +95,11 @@ protected function fetch($ETag, $content, $allowUnstable = false): array {
8795
];
8896
}
8997

90-
if ($this->config->getSystemValueString('appstoreurl', self::APP_STORE_URL) === self::APP_STORE_URL) {
91-
// If we have a valid subscription key, send it to the appstore
98+
$appStoreUrl = $this->config->getSystemValueString('appstoreurl', self::APP_STORE_URL);
99+
if ($appStoreUrl === self::APP_STORE_URL && $this->registry->delegateHasValidSubscription()) {
92100
$subscriptionKey = $this->config->getAppValue('support', 'subscription_key');
93-
if ($this->registry->delegateHasValidSubscription() && $subscriptionKey) {
101+
102+
if ($subscriptionKey) {
94103
$options['headers'] ??= [];
95104
$options['headers']['X-NC-Subscription-Key'] = $subscriptionKey;
96105
}
@@ -107,11 +116,25 @@ protected function fetch($ETag, $content, $allowUnstable = false): array {
107116

108117
$responseJson = [];
109118
if ($response->getStatusCode() === Http::STATUS_NOT_MODIFIED) {
110-
$responseJson['data'] = json_decode($content, true);
119+
// Reuse the locally cached data after the server confirms the ETag is unchanged.
120+
$decoded = json_decode($content, true);
121+
if (!is_array($decoded) || !array_is_list($decoded)) {
122+
return [];
123+
}
124+
125+
/** @var list<T> $decoded */
126+
$responseJson['data'] = $decoded;
111127
} else {
112-
$responseJson['data'] = json_decode($response->getBody(), true);
128+
$decoded = json_decode($response->getBody(), true);
129+
if (!is_array($decoded) || !array_is_list($decoded)) {
130+
return [];
131+
}
132+
133+
/** @var list<T> $decoded */
134+
$responseJson['data'] = $decoded;
113135
$ETag = $response->getHeader('ETag');
114136
}
137+
115138
$this->config->deleteAppValue('settings', 'appstore-fetcher-lastFailure');
116139

117140
$responseJson['timestamp'] = $this->timeFactory->getTime();
@@ -124,65 +147,63 @@ protected function fetch($ETag, $content, $allowUnstable = false): array {
124147
}
125148

126149
/**
127-
* Returns the array with the entries on the appstore server
150+
* Returns App Store entries, using the cache when appropriate.
151+
*
152+
* Fresh, same-version cache data is returned immediately. When refreshing
153+
* stale cache data fails, valid same-version data may be used as a
154+
* fallback while it is no older than MAX_STALE_SECONDS.
128155
*
129-
* @param bool $allowUnstable - Allow unstable releases
156+
* Cache data from another Nextcloud version, missing or invalid cache
157+
* data, and cache data older than MAX_STALE_SECONDS are not used as
158+
* fallbacks.
159+
*
160+
* A valid empty response from the App Store is returned and written to
161+
* the cache as an empty list; invalid responses are treated as refresh
162+
* failures.
163+
*
164+
* @param bool $allowUnstable Whether unstable releases should be included
130165
* @return list<T>
131166
*/
132-
public function get($allowUnstable = false): array {
167+
public function get(bool $allowUnstable = false): array {
133168
$appstoreEnabled = $this->config->getSystemValueBool('appstoreenabled', true);
134-
$internetAvailable = $this->config->getSystemValueBool('has_internet_connection', true);
135-
$isDefaultAppStore = $this->config->getSystemValueString('appstoreurl', self::APP_STORE_URL) === self::APP_STORE_URL;
136-
137-
if (!$appstoreEnabled || (!$internetAvailable && $isDefaultAppStore)) {
138-
$this->logger->info('AppStore is disabled or this instance has no Internet connection to access the default app store', ['app' => 'appstoreFetcher']);
169+
if (!$appstoreEnabled) {
170+
$this->logger->info('The appstore is disabled', ['app' => 'appstoreFetcher']);
139171
return [];
140172
}
141173

142-
$rootFolder = $this->appData->getFolder('/');
174+
$internetAvailable = $this->config->getSystemValueBool('has_internet_connection', true);
175+
$appStoreUrl = $this->config->getSystemValueString('appstoreurl', self::APP_STORE_URL);
176+
if (!$internetAvailable && $appStoreUrl === self::APP_STORE_URL) {
177+
$this->logger->info(
178+
'The default app store cannot be accessed since Internet connectivity is disabled on this instance',
179+
['app' => 'appstoreFetcher']
180+
);
181+
return [];
182+
}
143183

144184
$ETag = '';
145185
$content = '';
186+
/** @var ?list<T> $sameVersionCachedData */
146187
$sameVersionCachedData = null;
147188
$sameVersionCacheTimestamp = null;
148189

149-
$useCachedData = function () use (&$sameVersionCachedData, &$sameVersionCacheTimestamp): array {
150-
$now = $this->timeFactory->getTime();
151-
152-
if ($sameVersionCachedData === null || $sameVersionCacheTimestamp === null) {
153-
return [];
154-
}
155-
156-
if ($sameVersionCacheTimestamp >= ($now - self::MAX_STALE_SECONDS)) {
157-
$this->logger->warning(
158-
'Could not refresh appstore cache, using stale data',
159-
['app' => 'appstoreFetcher']
160-
);
161-
162-
return $sameVersionCachedData;
163-
}
164-
165-
$this->logger->warning(
166-
'Could not refresh appstore cache and cached data is too old',
167-
[
168-
'app' => 'appstoreFetcher',
169-
'cacheAge' => $now - $sameVersionCacheTimestamp,
170-
]
171-
);
172-
173-
return [];
174-
};
175-
190+
$rootFolder = $this->appData->getFolder('/');
176191
try {
177-
// File does already exists
192+
// Read the existing cache file.
178193
$file = $rootFolder->getFile($this->fileName);
179194
$jsonBlob = json_decode($file->getContent(), true);
180195

181196
if (is_array($jsonBlob)) {
182-
// No caching when the version has been updated
197+
// Only use cache data generated for the current Nextcloud version.
183198
if (isset($jsonBlob['ncversion']) && $jsonBlob['ncversion'] === $this->getVersion()) {
184-
if (isset($jsonBlob['data']) && is_array($jsonBlob['data'])) {
185-
$sameVersionCachedData = $jsonBlob['data'];
199+
if (
200+
isset($jsonBlob['data'])
201+
&& is_array($jsonBlob['data'])
202+
&& array_is_list($jsonBlob['data'])
203+
) {
204+
/** @var list<T> $cachedData */
205+
$cachedData = $jsonBlob['data'];
206+
$sameVersionCachedData = $cachedData;
186207
}
187208

188209
if (isset($jsonBlob['timestamp']) && is_numeric($jsonBlob['timestamp'])) {
@@ -205,50 +226,117 @@ public function get($allowUnstable = false): array {
205226
return $sameVersionCachedData;
206227
}
207228

208-
if (isset($jsonBlob['ETag'])) {
229+
// Reuse the ETag only when valid same-version cached data is available.
230+
if ($sameVersionCachedData !== null && isset($jsonBlob['ETag'])) {
209231
$ETag = $jsonBlob['ETag'];
210-
$content = json_encode($jsonBlob['data']);
232+
try {
233+
$content = json_encode($sameVersionCachedData, JSON_THROW_ON_ERROR);
234+
} catch (\JsonException $e) {
235+
$this->logger->warning(
236+
'Could not re-encode cached appstore data for conditional request',
237+
['app' => 'appstoreFetcher', 'exception' => $e]
238+
);
239+
$ETag = '';
240+
$content = '';
241+
}
211242
}
212243
}
213244
}
214245
} catch (NotFoundException $e) {
215-
// File does not already exist
246+
// Create the cache file when it does not already exist.
216247
$file = $rootFolder->newFile($this->fileName);
217248
} catch (GenericFileException $e) {
218249
try {
219250
$file->delete();
220251
} catch (\Exception) {
221-
$this->logger->error('Could not read appstore cache file', ['app' => 'appstoreFetcher', 'exception' => $e]);
252+
$this->logger->error(
253+
'Could not read appstore cache file',
254+
['app' => 'appstoreFetcher', 'exception' => $e]
255+
);
222256
return [];
223257
}
224-
$this->logger->warning('Could not read appstore cache file, it will be refreshed', ['app' => 'appstoreFetcher', 'exception' => $e]);
258+
$this->logger->warning(
259+
'Could not read appstore cache file, it will be refreshed',
260+
['app' => 'appstoreFetcher', 'exception' => $e]
261+
);
225262
$file = $rootFolder->newFile($this->fileName);
226263
}
227264

228-
// Refresh the file content
229265
try {
230266
$responseJson = $this->fetch($ETag, $content, $allowUnstable);
231267

232-
// On refresh failure, fallback to the stale but otherwise valid,
233-
// same-version cached data, provided it is no older than
234-
// MAX_STALE_SECONDS. An empty data array is valid and must be
235-
// written to the cache.
236-
if (!isset($responseJson['data']) || !is_array($responseJson['data'])) {
237-
return $useCachedData();
268+
// An empty list is a valid successful response. Missing or invalid response
269+
// data is treated as a failed refresh and falls back to eligible cached data.
270+
if (
271+
!isset($responseJson['data'])
272+
|| !is_array($responseJson['data'])
273+
|| !array_is_list($responseJson['data'])
274+
) {
275+
return $this->useCachedData($sameVersionCachedData, $sameVersionCacheTimestamp);
238276
}
239277

240-
$file->putContent(json_encode($responseJson));
241-
return $responseJson['data'];
278+
/** @var list<T> $responseData */
279+
$responseData = $responseJson['data'];
280+
281+
try {
282+
$file->putContent(json_encode($responseJson, JSON_THROW_ON_ERROR));
283+
} catch (\Exception $e) {
284+
// Return fresh data even when updating the cache fails, but log for admin visibility.
285+
$this->logger->warning(
286+
'Could not write appstore cache file: ' . $e->getMessage(),
287+
['app' => 'appstoreFetcher']
288+
);
289+
}
290+
291+
return $responseData;
242292
} catch (ConnectException $e) {
243-
$this->logger->warning('Could not connect to appstore: ' . $e->getMessage(), ['app' => 'appstoreFetcher']);
244-
return $useCachedData();
293+
// Handle connection exceptions that escape an overridden or future fetch().
294+
$this->logger->warning(
295+
'Could not connect to appstore: ' . $e->getMessage(),
296+
['app' => 'appstoreFetcher']
297+
);
298+
299+
return $this->useCachedData($sameVersionCachedData, $sameVersionCacheTimestamp);
245300
} catch (\Exception $e) {
246301
$this->logger->warning($e->getMessage(), [
247302
'exception' => $e,
248303
'app' => 'appstoreFetcher',
249304
]);
250-
return $useCachedData();
305+
306+
return $this->useCachedData($sameVersionCachedData, $sameVersionCacheTimestamp);
307+
}
308+
}
309+
310+
/**
311+
* @param ?list<T> $sameVersionCachedData
312+
* @param ?int $sameVersionCacheTimestamp
313+
* @return list<T>
314+
*/
315+
private function useCachedData(?array $sameVersionCachedData, ?int $sameVersionCacheTimestamp): array {
316+
$now = $this->timeFactory->getTime();
317+
318+
if ($sameVersionCachedData === null || $sameVersionCacheTimestamp === null) {
319+
return [];
251320
}
321+
322+
if ($sameVersionCacheTimestamp >= ($now - self::MAX_STALE_SECONDS)) {
323+
$this->logger->warning(
324+
'Could not refresh appstore cache, using stale data',
325+
['app' => 'appstoreFetcher']
326+
);
327+
328+
return $sameVersionCachedData;
329+
}
330+
331+
$this->logger->warning(
332+
'Could not refresh appstore cache and cached data is too old',
333+
[
334+
'app' => 'appstoreFetcher',
335+
'cacheAge' => $now - $sameVersionCacheTimestamp,
336+
]
337+
);
338+
339+
return [];
252340
}
253341

254342
/**

0 commit comments

Comments
 (0)