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