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