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
14NSString * const kEXPublicKeyUrl = @"https://exp.host/--/manifest-public-key";
15
16@interface EXManifestResource ()
17
18@property (nonatomic, strong) NSURL * _Nullable originalUrl;
19@property (nonatomic, strong) NSData *data;
20@property (nonatomic, assign) BOOL canBeWrittenToCache;
21
22// cache this value so we only have to compute it once per instance
23@property (nonatomic, strong) NSNumber * _Nullable isUsingEmbeddedManifest;
24
25@end
26
27@implementation EXManifestResource
28
29- (instancetype)initWithManifestUrl:(NSURL *)url originalUrl:(NSURL * _Nullable)originalUrl
30{
31  _originalUrl = originalUrl;
32  _canBeWrittenToCache = NO;
33
34  NSString *resourceName;
35  if ([EXEnvironment sharedEnvironment].isDetached && [originalUrl.absoluteString isEqual:[EXEnvironment sharedEnvironment].standaloneManifestUrl]) {
36    resourceName = kEXEmbeddedManifestResourceName;
37    if ([EXEnvironment sharedEnvironment].releaseChannel){
38      self.releaseChannel = [EXEnvironment sharedEnvironment].releaseChannel;
39    }
40    NSLog(@"EXManifestResource: Standalone manifest remote url is %@ (%@)", url, originalUrl);
41  } else {
42    resourceName = [EXKernelLinkingManager linkingUriForExperienceUri:url useLegacy:YES];
43  }
44
45  if (self = [super initWithResourceName:resourceName resourceType:@"json" remoteUrl:url cachePath:[[self class] cachePath]]) {
46    self.shouldVersionCache = NO;
47  }
48  return self;
49}
50
51- (void)loadResourceWithBehavior:(EXCachedResourceBehavior)behavior
52                   progressBlock:(EXCachedResourceProgressBlock)progressBlock
53                    successBlock:(EXCachedResourceSuccessBlock)successBlock
54                      errorBlock:(EXCachedResourceErrorBlock)errorBlock
55{
56  [super loadResourceWithBehavior:behavior progressBlock:progressBlock successBlock:^(NSData * _Nonnull data) {
57    self->_data = data;
58    if (self->_canBeWrittenToCache) {
59      [self writeToCache];
60    }
61
62    __block NSError *jsonError;
63    id manifestObj = [NSJSONSerialization JSONObjectWithData:data options:0 error:&jsonError];
64    if (jsonError) {
65      errorBlock(jsonError);
66      return;
67    }
68
69    NSString *innerManifestString = (NSString *)manifestObj[@"manifestString"];
70    NSString *manifestSignature = (NSString *)manifestObj[@"signature"];
71
72    NSMutableDictionary *innerManifestObj;
73    if (!innerManifestString && [self isUsingEmbeddedResource]) {
74      // locally bundled manifests are not signed
75      innerManifestObj = [manifestObj mutableCopy];
76    } else {
77      @try {
78        innerManifestObj = [NSJSONSerialization JSONObjectWithData:[innerManifestString dataUsingEncoding:NSUTF8StringEncoding]
79                                        options:NSJSONReadingMutableContainers
80                                          error:&jsonError];
81      } @catch (NSException *exception) {
82        errorBlock([NSError errorWithDomain:EXNetworkErrorDomain code:-1 userInfo:@{ NSLocalizedDescriptionKey: exception.reason }]);
83      }
84      if (jsonError) {
85        errorBlock(jsonError);
86        return;
87      }
88    }
89
90    NSError *sdkVersionError = [self _verifyManifestSdkVersion:innerManifestObj];
91    if (sdkVersionError) {
92      errorBlock(sdkVersionError);
93      return;
94    }
95
96    EXVerifySignatureSuccessBlock signatureSuccess = ^(BOOL isValid) {
97      [innerManifestObj setObject:@(isValid) forKey:@"isVerified"];
98      successBlock([NSJSONSerialization dataWithJSONObject:innerManifestObj options:0 error:&jsonError]);
99    };
100
101    if ([self _isManifestVerificationBypassed]) {
102      signatureSuccess(YES);
103    } else {
104      NSURL *publicKeyUrl = [NSURL URLWithString:kEXPublicKeyUrl];
105      [EXApiUtil verifySignatureWithPublicKeyUrl:publicKeyUrl
106                                           data:innerManifestString
107                                      signature:manifestSignature
108                                   successBlock:signatureSuccess
109                                      errorBlock:^(NSError *error) {
110                                        // ignore network errors in manifest validation,
111                                        // otherwise we can break offline loading for standalone apps when they have a valid manifest cache but no key.
112                                        if (error.domain == NSURLErrorDomain || error.domain == EXNetworkErrorDomain) {
113                                          DDLogWarn(@"EXManifestResource: Ignoring network error when validating manifest");
114                                          signatureSuccess(YES);
115                                        } else {
116                                          errorBlock(error);
117                                        }
118                                      }];
119    }
120  } errorBlock:errorBlock];
121}
122
123- (void)writeToCache
124{
125  if (_data) {
126    NSString *resourceCachePath = [self resourceCachePath];
127    NSLog(@"EXManifestResource: Caching manifest to %@...", resourceCachePath);
128    [_data writeToFile:resourceCachePath atomically:YES];
129  } else {
130    _canBeWrittenToCache = YES;
131  }
132}
133
134- (BOOL)isUsingEmbeddedResource
135{
136  // return cached value if we've already computed it once
137  if (_isUsingEmbeddedManifest != nil) {
138    return [_isUsingEmbeddedManifest boolValue];
139  }
140
141  _isUsingEmbeddedManifest = @NO;
142
143  if ([super isUsingEmbeddedResource]) {
144    _isUsingEmbeddedManifest = @YES;
145  } else {
146    NSString *cachePath = [self resourceCachePath];
147    NSString *bundlePath = [self resourceBundlePath];
148    if (bundlePath) {
149      // we cannot assume the cached manifest is newer than the embedded one, so we need to read both
150      NSData *cachedData = [NSData dataWithContentsOfFile:cachePath];
151      NSData *embeddedData = [NSData dataWithContentsOfFile:bundlePath];
152
153      NSError *jsonErrorCached, *jsonErrorEmbedded;
154      id cachedManifest, embeddedManifest;
155      if (cachedData) {
156        cachedManifest = [NSJSONSerialization JSONObjectWithData:cachedData options:kNilOptions error:&jsonErrorCached];
157      }
158      if (embeddedData) {
159        embeddedManifest = [NSJSONSerialization JSONObjectWithData:embeddedData options:kNilOptions error:&jsonErrorEmbedded];
160      }
161
162      if (!jsonErrorCached && !jsonErrorEmbedded && [self _isUsingEmbeddedManifest:embeddedManifest withCachedManifest:cachedManifest]) {
163        _isUsingEmbeddedManifest = @YES;
164      }
165    }
166  }
167  return [_isUsingEmbeddedManifest boolValue];
168}
169
170- (BOOL)_isUsingEmbeddedManifest:(id)embeddedManifest withCachedManifest:(id)cachedManifest
171{
172  NSDate *embeddedPublishDate = [self _publishedDateFromManifest:embeddedManifest];
173  NSDate *cachedPublishDate;
174
175  if (cachedManifest) {
176    // cached manifests are signed so we have to parse the inner manifest
177    NSString *cachedManifestString = cachedManifest[@"manifestString"];
178    NSDictionary *innerCachedManifest;
179    if (!cachedManifestString) {
180      innerCachedManifest = cachedManifest;
181    } else {
182      NSError *jsonError;
183      innerCachedManifest = [NSJSONSerialization JSONObjectWithData:[cachedManifestString dataUsingEncoding:NSUTF8StringEncoding]
184                                                            options:kNilOptions
185                                                              error:&jsonError];
186      if (jsonError) {
187        // just resolve with NO for now, we'll catch this error later on
188        return NO;
189      }
190    }
191    cachedPublishDate = [self _publishedDateFromManifest:innerCachedManifest];
192  }
193  if (embeddedPublishDate && cachedPublishDate && [embeddedPublishDate compare:cachedPublishDate] == NSOrderedDescending) {
194    return YES;
195  }
196  return NO;
197}
198
199- (NSDate * _Nullable)_publishedDateFromManifest:(id)manifest
200{
201  if (manifest) {
202    NSString *publishDateString = manifest[@"publishedTime"];
203    if (publishDateString) {
204      return [RCTConvert NSDate:publishDateString];
205    }
206  }
207  return nil;
208}
209
210+ (NSString *)cachePath
211{
212  NSString *cachesDirectory = NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, YES).firstObject;
213  NSString *sourceDirectory = [cachesDirectory stringByAppendingPathComponent:@"Manifests"];
214
215  BOOL cacheDirectoryExists = [[NSFileManager defaultManager] fileExistsAtPath:sourceDirectory isDirectory:nil];
216  if (!cacheDirectoryExists) {
217    NSError *error;
218    BOOL created = [[NSFileManager defaultManager] createDirectoryAtPath:sourceDirectory
219                                             withIntermediateDirectories:YES
220                                                              attributes:nil
221                                                                   error:&error];
222    if (created) {
223      cacheDirectoryExists = YES;
224    } else {
225      DDLogError(@"Could not create source cache directory: %@", error.localizedDescription);
226    }
227  }
228
229  return (cacheDirectoryExists) ? sourceDirectory : nil;
230}
231
232- (BOOL)_isManifestVerificationBypassed
233{
234  return (
235          // HACK: because `SecItemCopyMatching` doesn't work in older iOS (see EXApiUtil.m)
236          ([UIDevice currentDevice].systemVersion.floatValue < 10) ||
237
238          // the developer disabled manifest verification
239          [EXEnvironment sharedEnvironment].isManifestVerificationBypassed ||
240
241          // we're using a copy that came with the NSBundle and was therefore already codesigned
242          [self isUsingEmbeddedResource]
243  );
244}
245
246- (NSError *)_validateResponseData:(NSData *)data response:(NSURLResponse *)response
247{
248  if (response && [response isKindOfClass:[NSHTTPURLResponse class]]) {
249    NSHTTPURLResponse *httpResponse = (NSHTTPURLResponse *)response;
250    NSDictionary *headers = httpResponse.allHeaderFields;
251
252    // pass the Exponent-Server header to Amplitude if it exists.
253    // this is generated only from XDE and exp while serving local bundles.
254    NSString *serverHeaderJson = headers[@"Exponent-Server"];
255    if (serverHeaderJson) {
256      NSError *jsonError;
257      NSDictionary *serverHeader = [NSJSONSerialization JSONObjectWithData:[serverHeaderJson dataUsingEncoding:NSUTF8StringEncoding] options:0 error:&jsonError];
258      if (serverHeader && !jsonError) {
259        [[EXAnalytics sharedInstance] logEvent:@"LOAD_DEVELOPER_MANIFEST" manifestUrl:response.URL eventProperties:serverHeader];
260      }
261    }
262  }
263  // indicate that the response is valid
264  return nil;
265}
266
267- (NSError *)_verifyManifestSdkVersion:(NSDictionary *)maybeManifest
268{
269  NSString *errorCode;
270  if (maybeManifest && maybeManifest[@"sdkVersion"]) {
271    if (![maybeManifest[@"sdkVersion"] isEqualToString:@"UNVERSIONED"]) {
272      NSInteger manifestSdkVersion = [maybeManifest[@"sdkVersion"] integerValue];
273      if (manifestSdkVersion) {
274        NSInteger oldestSdkVersion = [[self _earliestSdkVersionSupported] integerValue];
275        NSInteger newestSdkVersion = [[self _latestSdkVersionSupported] integerValue];
276        if (manifestSdkVersion < oldestSdkVersion) {
277          errorCode = @"EXPERIENCE_SDK_VERSION_OUTDATED";
278        }
279        if (manifestSdkVersion > newestSdkVersion) {
280          errorCode = @"EXPERIENCE_SDK_VERSION_TOO_NEW";
281        }
282      } else {
283        errorCode = @"MALFORMED_SDK_VERSION";
284      }
285    }
286  } else {
287    errorCode = @"NO_SDK_VERSION_SPECIFIED";
288  }
289  if (errorCode) {
290    // will be handled by _validateErrorData:
291    return [self _formatError:[NSError errorWithDomain:EXNetworkErrorDomain code:0 userInfo:@{
292      @"errorCode": errorCode,
293    }]];
294  } else {
295    return nil;
296  }
297}
298
299- (NSError *)_validateErrorData:(NSError *)error response:(NSURLResponse *)response
300{
301  NSError *formattedError;
302  if ([response isKindOfClass:[NSHTTPURLResponse class]]) {
303    // we got back a response from the server, and we can use the info we got back to make a nice
304    // error message for the user
305
306    formattedError = [self _formatError:error];
307  } else {
308    // was a network error
309    NSMutableDictionary *userInfo = [NSMutableDictionary dictionaryWithDictionary:error.userInfo];
310    userInfo[@"errorCode"] = @"NETWORK_ERROR";
311    formattedError = [NSError errorWithDomain:EXNetworkErrorDomain code:error.code userInfo:userInfo];
312  }
313
314  return [super _validateErrorData:formattedError response:response];
315}
316
317- (NSString *)_earliestSdkVersionSupported
318{
319  NSArray *clientSDKVersionsAvailable = [EXVersions sharedInstance].versions[@"sdkVersions"];
320  return [clientSDKVersionsAvailable firstObject]; // TODO: this is bad, we can't guarantee this array will always be ordered properly.
321}
322
323- (NSString *)_latestSdkVersionSupported
324{
325  NSArray *clientSDKVersionsAvailable = [EXVersions sharedInstance].versions[@"sdkVersions"];
326  return [clientSDKVersionsAvailable lastObject]; // TODO: this is bad, we can't guarantee this array will always be ordered properly.
327}
328
329- (NSError *)_formatError:(NSError *)error
330{
331  NSMutableDictionary *userInfo = [NSMutableDictionary dictionaryWithDictionary:error.userInfo];
332  NSString *errorCode = userInfo[@"errorCode"];
333  NSString *rawMessage = [error localizedDescription];
334
335  NSString *formattedMessage = [NSString stringWithFormat:@"Could not load %@.", self.originalUrl];
336  if ([errorCode isEqualToString:@"EXPERIENCE_NOT_FOUND"]
337      || [errorCode isEqualToString:@"EXPERIENCE_NOT_PUBLISHED_ERROR"]
338      || [errorCode isEqualToString:@"EXPERIENCE_RELEASE_NOT_FOUND_ERROR"]) {
339    formattedMessage = [NSString stringWithFormat:@"No experience found at %@.", self.originalUrl];
340  } else if ([errorCode isEqualToString:@"EXPERIENCE_SDK_VERSION_OUTDATED"]) {
341    NSDictionary *metadata = userInfo[@"metadata"];
342    NSArray *availableSDKVersions = metadata[@"availableSDKVersions"];
343    NSString *sdkVersionRequired = [availableSDKVersions firstObject];
344
345    NSString *earliestSDKVersion = [self _earliestSdkVersionSupported];
346    formattedMessage = [NSString stringWithFormat:@"The experience you requested uses Expo SDK v%@, but this copy of Expo Client "
347                        "requires at least v%@. The author should update their experience to a newer Expo SDK version.", sdkVersionRequired, earliestSDKVersion];
348  } else if ([errorCode isEqualToString:@"EXPERIENCE_SDK_VERSION_TOO_NEW"]) {
349    formattedMessage = @"The experience you requested requires a newer version of the Expo Client app. Please download the latest version from the App Store.";
350  } else if ([errorCode isEqualToString:@"USER_SNACK_NOT_FOUND"] || [errorCode isEqualToString:@"SNACK_NOT_FOUND"]) {
351    formattedMessage = [NSString stringWithFormat:@"No snack found at %@.", self.originalUrl];
352  } else if ([errorCode isEqualToString:@"SNACK_RUNTIME_NOT_RELEASE"]) {
353    formattedMessage = rawMessage; // From server: `The Snack runtime for corresponding sdk version of this Snack ("${sdkVersions[0]}") is not released.`,
354  } else if ([errorCode isEqualToString:@"SNACK_NOT_FOUND_FOR_SDK_VERSIONS"]) {
355    formattedMessage = rawMessage; // From server: `The snack "${fullName}" was found, but wasn't released for platform "${platform}" and sdk version "${sdkVersions[0]}".`
356  }
357  userInfo[NSLocalizedDescriptionKey] = formattedMessage;
358
359  return [NSError errorWithDomain:EXNetworkErrorDomain code:error.code userInfo:userInfo];
360}
361
362@end
363