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