@@ -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