1// Copyright 2015-present 650 Industries. All rights reserved.
2
3#import "EXManifestResource.h"
4#import "EXApiUtil.h"
5#import "EXEnvironment.h"
6#import "EXFileDownloader.h"
7#import "EXKernelLinkingManager.h"
8#import "EXKernelUtil.h"
9#import "EXVersions.h"
10
11#import <React/RCTConvert.h>
12
13@import EXManifests;
14@import EXUpdates;
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.expoGoSDKVersion;
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 *)verifyManifestSdkVersion:(EXManifestsManifest *)maybeManifest
313{
314  NSString *errorCode;
315  NSDictionary *metadata;
316  if (maybeManifest && maybeManifest.expoGoSDKVersion) {
317    if (![maybeManifest.expoGoSDKVersion isEqualToString:@"UNVERSIONED"]) {
318      NSInteger manifestSdkVersion = [maybeManifest.expoGoSDKVersion integerValue];
319      if (manifestSdkVersion) {
320        NSInteger oldestSdkVersion = [[self _earliestSdkVersionSupported] integerValue];
321        NSInteger newestSdkVersion = [[self _latestSdkVersionSupported] integerValue];
322        if (manifestSdkVersion < oldestSdkVersion) {
323          errorCode = @"EXPERIENCE_SDK_VERSION_OUTDATED";
324          // since we are spoofing this error, we put the SDK version of the project as the
325          // "available" SDK version -- it's the only one available from the server
326          metadata = @{@"availableSDKVersions": @[maybeManifest.expoGoSDKVersion]};
327        }
328        if (manifestSdkVersion > newestSdkVersion) {
329          errorCode = @"EXPERIENCE_SDK_VERSION_TOO_NEW";
330        }
331
332        if ([[EXVersions sharedInstance].temporarySdkVersion integerValue] == manifestSdkVersion) {
333          // It seems there is no matching versioned SDK,
334          // but version of the unversioned code matches the requested one. That's ok.
335          errorCode = nil;
336        }
337      } else {
338        errorCode = @"MALFORMED_SDK_VERSION";
339      }
340    }
341  } else {
342    errorCode = @"NO_SDK_VERSION_SPECIFIED";
343  }
344  if (errorCode) {
345    // will be handled by _validateErrorData:
346    return [self formatError:[NSError errorWithDomain:EXRuntimeErrorDomain code:0 userInfo:@{
347      @"errorCode": errorCode,
348      @"metadata": metadata ?: @{},
349    }]];
350  } else {
351    return nil;
352  }
353}
354
355- (NSError *)_validateErrorData:(NSError *)error response:(NSURLResponse *)response
356{
357  NSError *formattedError;
358  if ([response isKindOfClass:[NSHTTPURLResponse class]]) {
359    // we got back a response from the server, and we can use the info we got back to make a nice
360    // error message for the user
361
362    formattedError = [self formatError:error];
363  } else {
364    // was a network error
365    NSMutableDictionary *userInfo = [NSMutableDictionary dictionaryWithDictionary:error.userInfo];
366    userInfo[@"errorCode"] = @"NETWORK_ERROR";
367    formattedError = [NSError errorWithDomain:EXNetworkErrorDomain code:error.code userInfo:userInfo];
368  }
369
370  return [super _validateErrorData:formattedError response:response];
371}
372
373- (NSString *)_earliestSdkVersionSupported
374{
375  NSArray *clientSDKVersionsAvailable = [EXVersions sharedInstance].versions[@"sdkVersions"];
376  return [clientSDKVersionsAvailable firstObject]; // TODO: this is bad, we can't guarantee this array will always be ordered properly.
377}
378
379- (NSString *)_latestSdkVersionSupported
380{
381  NSArray *clientSDKVersionsAvailable = [EXVersions sharedInstance].versions[@"sdkVersions"];
382  return [clientSDKVersionsAvailable lastObject]; // TODO: this is bad, we can't guarantee this array will always be ordered properly.
383}
384
385- (NSError *)formatError:(NSError *)error
386{
387  NSMutableDictionary *userInfo = [NSMutableDictionary dictionaryWithDictionary:error.userInfo];
388  NSString *errorCode = userInfo[@"errorCode"];
389  NSString *rawMessage = [error localizedDescription];
390
391  NSString *formattedMessage = [NSString stringWithFormat:@"Could not load %@.", self.originalUrl];
392  if ([errorCode isEqualToString:@"EXPERIENCE_NOT_FOUND"]
393      || [errorCode isEqualToString:@"EXPERIENCE_NOT_PUBLISHED_ERROR"]
394      || [errorCode isEqualToString:@"EXPERIENCE_RELEASE_NOT_FOUND_ERROR"]) {
395    formattedMessage = [NSString stringWithFormat:@"No project found at %@.", self.originalUrl];
396  } else if ([errorCode isEqualToString:@"EXPERIENCE_SDK_VERSION_OUTDATED"]) {
397    NSDictionary *metadata = userInfo[@"metadata"];
398    NSArray *availableSDKVersions = metadata[@"availableSDKVersions"];
399    NSString *sdkVersionRequired = [availableSDKVersions firstObject];
400    NSString *supportedSDKVersions = [[EXVersions sharedInstance].versions[@"sdkVersions"] componentsJoinedByString:@", "];
401
402    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];
403  } else if ([errorCode isEqualToString:@"NO_SDK_VERSION_SPECIFIED"]) {
404    NSString *supportedSDKVersions = [[EXVersions sharedInstance].versions[@"sdkVersions"] componentsJoinedByString:@", "];
405    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];
406  } else if ([errorCode isEqualToString:@"EXPERIENCE_SDK_VERSION_TOO_NEW"]) {
407    formattedMessage = @"The project you requested requires a newer version of Expo Go. Please download the latest version from the App Store.";
408  } else if ([errorCode isEqualToString:@"NO_COMPATIBLE_EXPERIENCE_FOUND"]){
409    formattedMessage = rawMessage; // No compatible experience found at ${originalUrl}. Only ${currentSdkVersions} are supported.
410  } else if ([errorCode isEqualToString:@"EXPERIENCE_NOT_VIEWABLE"]) {
411    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.
412  } else if ([errorCode isEqualToString:@"USER_SNACK_NOT_FOUND"] || [errorCode isEqualToString:@"SNACK_NOT_FOUND"]) {
413    formattedMessage = [NSString stringWithFormat:@"No snack found at %@.", self.originalUrl];
414  } else if ([errorCode isEqualToString:@"SNACK_RUNTIME_NOT_RELEASE"]) {
415    formattedMessage = rawMessage; // From server: `The Snack runtime for corresponding sdk version of this Snack ("${sdkVersions[0]}") is not released.`,
416  } else if ([errorCode isEqualToString:@"SNACK_NOT_FOUND_FOR_SDK_VERSIONS"]) {
417    formattedMessage = rawMessage; // From server: `The snack "${fullName}" was found, but wasn't released for platform "${platform}" and sdk version "${sdkVersions[0]}".`
418  }
419  userInfo[NSLocalizedDescriptionKey] = formattedMessage;
420
421  return [NSError errorWithDomain:EXRuntimeErrorDomain code:error.code userInfo:userInfo];
422}
423
424@end
425