1// Copyright 2015-present 650 Industries. All rights reserved.
2
3#import "EXManifestResource.h"
4#import "EXAnalytics.h"
5#import "EXApiUtil.h"
6#import "EXEnvironment.h"
7#import "EXFileDownloader.h"
8#import "EXKernelLinkingManager.h"
9#import "EXKernelUtil.h"
10#import "EXVersions.h"
11
12#import <React/RCTConvert.h>
13
14NSString * const kEXPublicKeyUrl = @"https://exp.host/--/manifest-public-key";
15
16@interface EXManifestResource ()
17
18@property (nonatomic, strong) NSURL * _Nullable originalUrl;
19@property (nonatomic, strong) NSData *data;
20@property (nonatomic, assign) BOOL canBeWrittenToCache;
21
22// cache this value so we only have to compute it once per instance
23@property (nonatomic, strong) NSNumber * _Nullable isUsingEmbeddedManifest;
24
25@end
26
27@implementation EXManifestResource
28
29- (instancetype)initWithManifestUrl:(NSURL *)url originalUrl:(NSURL * _Nullable)originalUrl
30{
31  _originalUrl = originalUrl;
32  _canBeWrittenToCache = NO;
33
34  NSString *resourceName;
35  if ([EXEnvironment sharedEnvironment].isDetached && [originalUrl.absoluteString isEqual:[EXEnvironment sharedEnvironment].standaloneManifestUrl]) {
36    resourceName = kEXEmbeddedManifestResourceName;
37    if ([EXEnvironment sharedEnvironment].releaseChannel){
38      self.releaseChannel = [EXEnvironment sharedEnvironment].releaseChannel;
39    }
40    NSLog(@"EXManifestResource: Standalone manifest remote url is %@ (%@)", url, originalUrl);
41  } else {
42    resourceName = [EXKernelLinkingManager linkingUriForExperienceUri:url useLegacy:YES];
43  }
44
45  if (self = [super initWithResourceName:resourceName resourceType:@"json" remoteUrl:url cachePath:[[self class] cachePath]]) {
46    self.shouldVersionCache = NO;
47  }
48  return self;
49}
50
51- (NSMutableDictionary * _Nullable) _chooseManifest:(NSArray *)manifestArray error:(NSError **)error {
52  // Find supported sdk versions
53  if (manifestArray) {
54    for (id providedManifest in manifestArray) {
55      if ([providedManifest isKindOfClass:[NSDictionary class]] && providedManifest[@"sdkVersion"]){
56        NSString *sdkVersion = providedManifest[@"sdkVersion"];
57        if ([[EXVersions sharedInstance] supportsVersion:sdkVersion]){
58          return providedManifest;
59        }
60      }
61    }
62  }
63
64  if (error) {
65    * error = [self formatError:[NSError errorWithDomain:EXNetworkErrorDomain code:0 userInfo:@{
66                                                                                       @"errorCode": @"NO_COMPATIBLE_EXPERIENCE_FOUND",
67                                                                                       NSLocalizedDescriptionKey: [NSString stringWithFormat:@"No compatible project found at %@. Only %@ are supported.", self.originalUrl, [[EXVersions sharedInstance].versions[@"sdkVersions"] componentsJoinedByString:@","]]
68                                                                                       }]];
69  }
70  return nil;
71}
72
73- (void)loadResourceWithBehavior:(EXCachedResourceBehavior)behavior
74                   progressBlock:(EXCachedResourceProgressBlock)progressBlock
75                    successBlock:(EXCachedResourceSuccessBlock)successBlock
76                      errorBlock:(EXCachedResourceErrorBlock)errorBlock
77{
78  [super loadResourceWithBehavior:behavior progressBlock:progressBlock successBlock:^(NSData * _Nonnull data) {
79    self->_data = data;
80    if (self->_canBeWrittenToCache) {
81      [self writeToCache];
82    }
83
84    __block NSError *jsonError;
85    id manifestObjOrArray = [NSJSONSerialization JSONObjectWithData:data options:0 error:&jsonError];
86    if (jsonError) {
87      errorBlock(jsonError);
88      return;
89    }
90
91    id manifestObj;
92    // Check if server sent an array of manifests (multi-manifests)
93    if ([manifestObjOrArray isKindOfClass:[NSArray class]]) {
94      NSArray *manifestArray = (NSArray *)manifestObjOrArray;
95      __block NSError *manifestError;
96      manifestObj = [self _chooseManifest:(NSArray *)manifestArray error:&manifestError];
97      if (!manifestObj) {
98        errorBlock(manifestError);
99        return;
100      }
101    } else {
102      manifestObj = manifestObjOrArray;
103    }
104    NSString *innerManifestString = (NSString *)manifestObj[@"manifestString"];
105    NSString *manifestSignature = (NSString *)manifestObj[@"signature"];
106
107    NSMutableDictionary *innerManifestObj;
108    if (!innerManifestString) {
109      // this manifest is not signed
110      innerManifestObj = [manifestObj mutableCopy];
111    } else {
112      @try {
113        innerManifestObj = [NSJSONSerialization JSONObjectWithData:[innerManifestString dataUsingEncoding:NSUTF8StringEncoding]
114                                        options:NSJSONReadingMutableContainers
115                                          error:&jsonError];
116      } @catch (NSException *exception) {
117        errorBlock([NSError errorWithDomain:EXNetworkErrorDomain code:-1 userInfo:@{ NSLocalizedDescriptionKey: exception.reason }]);
118      }
119      if (jsonError) {
120        errorBlock(jsonError);
121        return;
122      }
123    }
124
125    NSError *sdkVersionError = [self verifyManifestSdkVersion:innerManifestObj];
126    if (sdkVersionError) {
127      errorBlock(sdkVersionError);
128      return;
129    }
130
131    EXVerifySignatureSuccessBlock signatureSuccess = ^(BOOL isValid) {
132      [innerManifestObj setObject:@(isValid) forKey:@"isVerified"];
133      successBlock([NSJSONSerialization dataWithJSONObject:innerManifestObj options:0 error:&jsonError]);
134    };
135
136    if ([self _isManifestVerificationBypassed:manifestObj]) {
137      if ([self _isThirdPartyHosted] && ![EXEnvironment sharedEnvironment].isDetached){
138        // the manifest id determines the namespace/experience id an app is sandboxed with
139        // if manifest is hosted by third parties, we sandbox it with the hostname to avoid clobbering exp.host namespaces
140        // for https urls, sandboxed id is of form quinlanj.github.io/myProj-myApp
141        // for http urls, sandboxed id is of form UNVERIFIED-quinlanj.github.io/myProj-myApp
142        NSString * securityPrefix = [self.remoteUrl.scheme isEqualToString:@"https"] ? @"" : @"UNVERIFIED-";
143        NSString * slugSuffix = innerManifestObj[@"slug"] ? [@"-" stringByAppendingString:innerManifestObj[@"slug"]]: @"";
144        innerManifestObj[@"id"] = [NSString stringWithFormat:@"%@%@%@%@", securityPrefix, self.remoteUrl.host, self.remoteUrl.path?:@"", slugSuffix];
145      }
146      signatureSuccess(YES);
147    } else {
148      NSURL *publicKeyUrl = [NSURL URLWithString:kEXPublicKeyUrl];
149      [EXApiUtil verifySignatureWithPublicKeyUrl:publicKeyUrl
150                                           data:innerManifestString
151                                      signature:manifestSignature
152                                   successBlock:signatureSuccess
153                                      errorBlock:^(NSError *error) {
154                                        // ignore network errors in manifest validation,
155                                        // otherwise we can break offline loading for standalone apps when they have a valid manifest cache but no key.
156                                        if (error.domain == NSURLErrorDomain || error.domain == EXNetworkErrorDomain) {
157                                          DDLogWarn(@"EXManifestResource: Ignoring network error when validating manifest");
158                                          signatureSuccess(YES);
159                                        } else {
160                                          errorBlock(error);
161                                        }
162                                      }];
163    }
164  } errorBlock:errorBlock];
165}
166
167- (void)writeToCache
168{
169  if (_data) {
170    NSString *resourceCachePath = [self resourceCachePath];
171    NSLog(@"EXManifestResource: Caching manifest to %@...", resourceCachePath);
172    [_data writeToFile:resourceCachePath atomically:YES];
173  } else {
174    _canBeWrittenToCache = YES;
175  }
176}
177
178- (NSString *)resourceCachePath
179{
180  NSString *resourceCacheFilename = [NSString stringWithFormat:@"%@-%lu", self.resourceName, (unsigned long)[_originalUrl hash]];
181  NSString *versionedResourceFilename = [NSString stringWithFormat:@"%@.%@", resourceCacheFilename, @"json"];
182  return [[[self class] cachePath] stringByAppendingPathComponent:versionedResourceFilename];
183}
184
185- (BOOL)isUsingEmbeddedResource
186{
187  // return cached value if we've already computed it once
188  if (_isUsingEmbeddedManifest != nil) {
189    return [_isUsingEmbeddedManifest boolValue];
190  }
191
192  _isUsingEmbeddedManifest = @NO;
193
194  if ([super isUsingEmbeddedResource]) {
195    _isUsingEmbeddedManifest = @YES;
196  } else {
197    NSString *cachePath = [self resourceCachePath];
198    NSString *bundlePath = [self resourceBundlePath];
199    if (bundlePath) {
200      // we cannot assume the cached manifest is newer than the embedded one, so we need to read both
201      NSData *cachedData = [NSData dataWithContentsOfFile:cachePath];
202      NSData *embeddedData = [NSData dataWithContentsOfFile:bundlePath];
203
204      NSError *jsonErrorCached, *jsonErrorEmbedded;
205      id cachedManifest, embeddedManifest;
206      if (cachedData) {
207        cachedManifest = [NSJSONSerialization JSONObjectWithData:cachedData options:kNilOptions error:&jsonErrorCached];
208      }
209      if (embeddedData) {
210        embeddedManifest = [NSJSONSerialization JSONObjectWithData:embeddedData options:kNilOptions error:&jsonErrorEmbedded];
211      }
212
213      if (!jsonErrorCached && !jsonErrorEmbedded && [self _isUsingEmbeddedManifest:embeddedManifest withCachedManifest:cachedManifest]) {
214        _isUsingEmbeddedManifest = @YES;
215      }
216    }
217  }
218  return [_isUsingEmbeddedManifest boolValue];
219}
220
221- (BOOL)_isUsingEmbeddedManifest:(id)embeddedManifest withCachedManifest:(id)cachedManifest
222{
223  // if there's no cachedManifest at resourceCachePath, we definitely want to use the embedded manifest
224  if (embeddedManifest && !cachedManifest) {
225    return YES;
226  }
227
228  NSDate *embeddedPublishDate = [self _publishedDateFromManifest:embeddedManifest];
229  NSDate *cachedPublishDate;
230
231  if (cachedManifest) {
232    // cached manifests are signed so we have to parse the inner manifest
233    NSString *cachedManifestString = cachedManifest[@"manifestString"];
234    NSDictionary *innerCachedManifest;
235    if (!cachedManifestString) {
236      innerCachedManifest = cachedManifest;
237    } else {
238      NSError *jsonError;
239      innerCachedManifest = [NSJSONSerialization JSONObjectWithData:[cachedManifestString dataUsingEncoding:NSUTF8StringEncoding]
240                                                            options:kNilOptions
241                                                              error:&jsonError];
242      if (jsonError) {
243        // just resolve with NO for now, we'll catch this error later on
244        return NO;
245      }
246    }
247    cachedPublishDate = [self _publishedDateFromManifest:innerCachedManifest];
248  }
249  if (embeddedPublishDate && cachedPublishDate && [embeddedPublishDate compare:cachedPublishDate] == NSOrderedDescending) {
250    return YES;
251  }
252  return NO;
253}
254
255- (NSDate * _Nullable)_publishedDateFromManifest:(id)manifest
256{
257  if (manifest) {
258    // use commitTime instead of publishTime as it is more accurate;
259    // however, fall back to publishedTime in case older cached manifests do not contain
260    // the commitTime key (we have not always served it)
261    NSString *commitDateString = manifest[@"commitTime"];
262    if (commitDateString) {
263      return [RCTConvert NSDate:commitDateString];
264    } else {
265      NSString *publishDateString = manifest[@"publishedTime"];
266      if (publishDateString) {
267        return [RCTConvert NSDate:publishDateString];
268      }
269    }
270  }
271  return nil;
272}
273
274+ (NSString *)cachePath
275{
276  return [[self class] cachePathWithName:@"Manifests"];
277}
278
279- (BOOL)_isThirdPartyHosted
280{
281  return (self.remoteUrl && ![EXKernelLinkingManager isExpoHostedUrl:self.remoteUrl]);
282}
283
284- (BOOL)_isManifestVerificationBypassed: (id) manifestObj
285{
286  bool shouldBypassVerification =(
287                                  // HACK: because `SecItemCopyMatching` doesn't work in older iOS (see EXApiUtil.m)
288                                  ([UIDevice currentDevice].systemVersion.floatValue < 10) ||
289
290                                  // the developer disabled manifest verification
291                                  [EXEnvironment sharedEnvironment].isManifestVerificationBypassed ||
292
293                                  // we're using a copy that came with the NSBundle and was therefore already codesigned
294                                  [self isUsingEmbeddedResource] ||
295
296                                  // we sandbox third party hosted apps instead of verifying signature
297                                  [self _isThirdPartyHosted]
298                                  );
299
300  return
301  // only consider bypassing if there is no signature provided
302  !((NSString *)manifestObj[@"signature"]) && shouldBypassVerification;
303}
304
305- (NSError *)_validateResponseData:(NSData *)data response:(NSURLResponse *)response
306{
307  if (response && [response isKindOfClass:[NSHTTPURLResponse class]]) {
308    NSHTTPURLResponse *httpResponse = (NSHTTPURLResponse *)response;
309    NSDictionary *headers = httpResponse.allHeaderFields;
310
311    // pass the Exponent-Server header to Amplitude if it exists.
312    // this is generated only from XDE and exp while serving local bundles.
313    NSString *serverHeaderJson = headers[@"Exponent-Server"];
314    if (serverHeaderJson) {
315      NSError *jsonError;
316      NSDictionary *serverHeader = [NSJSONSerialization JSONObjectWithData:[serverHeaderJson dataUsingEncoding:NSUTF8StringEncoding] options:0 error:&jsonError];
317      if (serverHeader && !jsonError) {
318        [[EXAnalytics sharedInstance] logEvent:@"LOAD_DEVELOPER_MANIFEST" manifestUrl:response.URL eventProperties:serverHeader];
319      }
320    }
321  }
322  // indicate that the response is valid
323  return nil;
324}
325
326- (NSError *)verifyManifestSdkVersion:(NSDictionary *)maybeManifest
327{
328  NSString *errorCode;
329  NSDictionary *metadata;
330  if (maybeManifest && maybeManifest[@"sdkVersion"]) {
331    if (![maybeManifest[@"sdkVersion"] isEqualToString:@"UNVERSIONED"]) {
332      NSInteger manifestSdkVersion = [maybeManifest[@"sdkVersion"] integerValue];
333      if (manifestSdkVersion) {
334        NSInteger oldestSdkVersion = [[self _earliestSdkVersionSupported] integerValue];
335        NSInteger newestSdkVersion = [[self _latestSdkVersionSupported] integerValue];
336        if (manifestSdkVersion < oldestSdkVersion) {
337          errorCode = @"EXPERIENCE_SDK_VERSION_OUTDATED";
338          // since we are spoofing this error, we put the SDK version of the project as the
339          // "available" SDK version -- it's the only one available from the server
340          metadata = @{@"availableSDKVersions": @[maybeManifest[@"sdkVersion"]]};
341        }
342        if (manifestSdkVersion > newestSdkVersion) {
343          errorCode = @"EXPERIENCE_SDK_VERSION_TOO_NEW";
344        }
345
346        if ([[EXVersions sharedInstance].temporarySdkVersion integerValue] == manifestSdkVersion) {
347          // It seems there is no matching versioned SDK,
348          // but version of the unversioned code matches the requested one. That's ok.
349          errorCode = nil;
350        }
351      } else {
352        errorCode = @"MALFORMED_SDK_VERSION";
353      }
354    }
355  } else {
356    errorCode = @"NO_SDK_VERSION_SPECIFIED";
357  }
358  if (errorCode) {
359    // will be handled by _validateErrorData:
360    return [self formatError:[NSError errorWithDomain:EXNetworkErrorDomain code:0 userInfo:@{
361      @"errorCode": errorCode,
362      @"metadata": metadata ?: @{},
363    }]];
364  } else {
365    return nil;
366  }
367}
368
369- (NSError *)_validateErrorData:(NSError *)error response:(NSURLResponse *)response
370{
371  NSError *formattedError;
372  if ([response isKindOfClass:[NSHTTPURLResponse class]]) {
373    // we got back a response from the server, and we can use the info we got back to make a nice
374    // error message for the user
375
376    formattedError = [self formatError:error];
377  } else {
378    // was a network error
379    NSMutableDictionary *userInfo = [NSMutableDictionary dictionaryWithDictionary:error.userInfo];
380    userInfo[@"errorCode"] = @"NETWORK_ERROR";
381    formattedError = [NSError errorWithDomain:EXNetworkErrorDomain code:error.code userInfo:userInfo];
382  }
383
384  return [super _validateErrorData:formattedError response:response];
385}
386
387- (NSString *)_earliestSdkVersionSupported
388{
389  NSArray *clientSDKVersionsAvailable = [EXVersions sharedInstance].versions[@"sdkVersions"];
390  return [clientSDKVersionsAvailable firstObject]; // TODO: this is bad, we can't guarantee this array will always be ordered properly.
391}
392
393- (NSString *)_latestSdkVersionSupported
394{
395  NSArray *clientSDKVersionsAvailable = [EXVersions sharedInstance].versions[@"sdkVersions"];
396  return [clientSDKVersionsAvailable lastObject]; // TODO: this is bad, we can't guarantee this array will always be ordered properly.
397}
398
399- (NSError *)formatError:(NSError *)error
400{
401  NSMutableDictionary *userInfo = [NSMutableDictionary dictionaryWithDictionary:error.userInfo];
402  NSString *errorCode = userInfo[@"errorCode"];
403  NSString *rawMessage = [error localizedDescription];
404
405  NSString *formattedMessage = [NSString stringWithFormat:@"Could not load %@.", self.originalUrl];
406  if ([errorCode isEqualToString:@"EXPERIENCE_NOT_FOUND"]
407      || [errorCode isEqualToString:@"EXPERIENCE_NOT_PUBLISHED_ERROR"]
408      || [errorCode isEqualToString:@"EXPERIENCE_RELEASE_NOT_FOUND_ERROR"]) {
409    formattedMessage = [NSString stringWithFormat:@"No project found at %@.", self.originalUrl];
410  } else if ([errorCode isEqualToString:@"EXPERIENCE_SDK_VERSION_OUTDATED"]) {
411    NSDictionary *metadata = userInfo[@"metadata"];
412    NSArray *availableSDKVersions = metadata[@"availableSDKVersions"];
413    NSString *sdkVersionRequired = [availableSDKVersions firstObject];
414
415    NSString *earliestSDKVersion = [self _earliestSdkVersionSupported];
416    formattedMessage = [NSString stringWithFormat:@"The project you requested uses Expo SDK v%@, but this copy of Expo Go "
417                        "requires at least v%@. The author should update their experience to a newer Expo SDK version.", sdkVersionRequired, earliestSDKVersion];
418  } else if ([errorCode isEqualToString:@"EXPERIENCE_SDK_VERSION_TOO_NEW"]) {
419    formattedMessage = @"The project you requested requires a newer version of Expo Go. Please download the latest version from the App Store.";
420  } else if ([errorCode isEqualToString:@"NO_COMPATIBLE_EXPERIENCE_FOUND"]){
421    formattedMessage = rawMessage; // No compatible experience found at ${originalUrl}. Only ${currentSdkVersions} are supported.
422  } else if ([errorCode isEqualToString:@"EXPERIENCE_NOT_VIEWABLE"]) {
423    formattedMessage = rawMessage; // From server: The experience you requested is not viewable by you. You will need to log in or ask the owner to grant you access.
424  } else if ([errorCode isEqualToString:@"USER_SNACK_NOT_FOUND"] || [errorCode isEqualToString:@"SNACK_NOT_FOUND"]) {
425    formattedMessage = [NSString stringWithFormat:@"No snack found at %@.", self.originalUrl];
426  } else if ([errorCode isEqualToString:@"SNACK_RUNTIME_NOT_RELEASE"]) {
427    formattedMessage = rawMessage; // From server: `The Snack runtime for corresponding sdk version of this Snack ("${sdkVersions[0]}") is not released.`,
428  } else if ([errorCode isEqualToString:@"SNACK_NOT_FOUND_FOR_SDK_VERSIONS"]) {
429    formattedMessage = rawMessage; // From server: `The snack "${fullName}" was found, but wasn't released for platform "${platform}" and sdk version "${sdkVersions[0]}".`
430  }
431  userInfo[NSLocalizedDescriptionKey] = formattedMessage;
432
433  return [NSError errorWithDomain:EXNetworkErrorDomain code:error.code userInfo:userInfo];
434}
435
436@end
437