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