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