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