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