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