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 experience 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 NSString *cachesDirectory = NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, YES).firstObject; 277 NSString *sourceDirectory = [cachesDirectory stringByAppendingPathComponent:@"Manifests"]; 278 279 BOOL cacheDirectoryExists = [[NSFileManager defaultManager] fileExistsAtPath:sourceDirectory isDirectory:nil]; 280 if (!cacheDirectoryExists) { 281 NSError *error; 282 BOOL created = [[NSFileManager defaultManager] createDirectoryAtPath:sourceDirectory 283 withIntermediateDirectories:YES 284 attributes:nil 285 error:&error]; 286 if (created) { 287 cacheDirectoryExists = YES; 288 } else { 289 DDLogError(@"Could not create source cache directory: %@", error.localizedDescription); 290 } 291 } 292 293 return (cacheDirectoryExists) ? sourceDirectory : nil; 294} 295 296- (BOOL)_isThirdPartyHosted 297{ 298 return (self.remoteUrl && ![EXKernelLinkingManager isExpoHostedUrl:self.remoteUrl]); 299} 300 301- (BOOL)_isManifestVerificationBypassed: (id) manifestObj 302{ 303 bool shouldBypassVerification =( 304 // HACK: because `SecItemCopyMatching` doesn't work in older iOS (see EXApiUtil.m) 305 ([UIDevice currentDevice].systemVersion.floatValue < 10) || 306 307 // the developer disabled manifest verification 308 [EXEnvironment sharedEnvironment].isManifestVerificationBypassed || 309 310 // we're using a copy that came with the NSBundle and was therefore already codesigned 311 [self isUsingEmbeddedResource] || 312 313 // we sandbox third party hosted apps instead of verifying signature 314 [self _isThirdPartyHosted] 315 ); 316 317 return 318 // only consider bypassing if there is no signature provided 319 !((NSString *)manifestObj[@"signature"]) && shouldBypassVerification; 320} 321 322- (NSError *)_validateResponseData:(NSData *)data response:(NSURLResponse *)response 323{ 324 if (response && [response isKindOfClass:[NSHTTPURLResponse class]]) { 325 NSHTTPURLResponse *httpResponse = (NSHTTPURLResponse *)response; 326 NSDictionary *headers = httpResponse.allHeaderFields; 327 328 // pass the Exponent-Server header to Amplitude if it exists. 329 // this is generated only from XDE and exp while serving local bundles. 330 NSString *serverHeaderJson = headers[@"Exponent-Server"]; 331 if (serverHeaderJson) { 332 NSError *jsonError; 333 NSDictionary *serverHeader = [NSJSONSerialization JSONObjectWithData:[serverHeaderJson dataUsingEncoding:NSUTF8StringEncoding] options:0 error:&jsonError]; 334 if (serverHeader && !jsonError) { 335 [[EXAnalytics sharedInstance] logEvent:@"LOAD_DEVELOPER_MANIFEST" manifestUrl:response.URL eventProperties:serverHeader]; 336 } 337 } 338 } 339 // indicate that the response is valid 340 return nil; 341} 342 343- (NSError *)_verifyManifestSdkVersion:(NSDictionary *)maybeManifest 344{ 345 NSString *errorCode; 346 if (maybeManifest && maybeManifest[@"sdkVersion"]) { 347 if (![maybeManifest[@"sdkVersion"] isEqualToString:@"UNVERSIONED"]) { 348 NSInteger manifestSdkVersion = [maybeManifest[@"sdkVersion"] integerValue]; 349 if (manifestSdkVersion) { 350 NSInteger oldestSdkVersion = [[self _earliestSdkVersionSupported] integerValue]; 351 NSInteger newestSdkVersion = [[self _latestSdkVersionSupported] integerValue]; 352 if (manifestSdkVersion < oldestSdkVersion) { 353 errorCode = @"EXPERIENCE_SDK_VERSION_OUTDATED"; 354 } 355 if (manifestSdkVersion > newestSdkVersion) { 356 errorCode = @"EXPERIENCE_SDK_VERSION_TOO_NEW"; 357 } 358 359 if ([[EXVersions sharedInstance].temporarySdkVersion integerValue] == manifestSdkVersion) { 360 // It seems there is no matching versioned SDK, 361 // but version of the unversioned code matches the requested one. That's ok. 362 errorCode = nil; 363 } 364 } else { 365 errorCode = @"MALFORMED_SDK_VERSION"; 366 } 367 } 368 } else { 369 errorCode = @"NO_SDK_VERSION_SPECIFIED"; 370 } 371 if (errorCode) { 372 // will be handled by _validateErrorData: 373 return [self _formatError:[NSError errorWithDomain:EXNetworkErrorDomain code:0 userInfo:@{ 374 @"errorCode": errorCode, 375 }]]; 376 } else { 377 return nil; 378 } 379} 380 381- (NSError *)_validateErrorData:(NSError *)error response:(NSURLResponse *)response 382{ 383 NSError *formattedError; 384 if ([response isKindOfClass:[NSHTTPURLResponse class]]) { 385 // we got back a response from the server, and we can use the info we got back to make a nice 386 // error message for the user 387 388 formattedError = [self _formatError:error]; 389 } else { 390 // was a network error 391 NSMutableDictionary *userInfo = [NSMutableDictionary dictionaryWithDictionary:error.userInfo]; 392 userInfo[@"errorCode"] = @"NETWORK_ERROR"; 393 formattedError = [NSError errorWithDomain:EXNetworkErrorDomain code:error.code userInfo:userInfo]; 394 } 395 396 return [super _validateErrorData:formattedError response:response]; 397} 398 399- (NSString *)_earliestSdkVersionSupported 400{ 401 NSArray *clientSDKVersionsAvailable = [EXVersions sharedInstance].versions[@"sdkVersions"]; 402 return [clientSDKVersionsAvailable firstObject]; // TODO: this is bad, we can't guarantee this array will always be ordered properly. 403} 404 405- (NSString *)_latestSdkVersionSupported 406{ 407 NSArray *clientSDKVersionsAvailable = [EXVersions sharedInstance].versions[@"sdkVersions"]; 408 return [clientSDKVersionsAvailable lastObject]; // TODO: this is bad, we can't guarantee this array will always be ordered properly. 409} 410 411- (NSError *)_formatError:(NSError *)error 412{ 413 NSMutableDictionary *userInfo = [NSMutableDictionary dictionaryWithDictionary:error.userInfo]; 414 NSString *errorCode = userInfo[@"errorCode"]; 415 NSString *rawMessage = [error localizedDescription]; 416 417 NSString *formattedMessage = [NSString stringWithFormat:@"Could not load %@.", self.originalUrl]; 418 if ([errorCode isEqualToString:@"EXPERIENCE_NOT_FOUND"] 419 || [errorCode isEqualToString:@"EXPERIENCE_NOT_PUBLISHED_ERROR"] 420 || [errorCode isEqualToString:@"EXPERIENCE_RELEASE_NOT_FOUND_ERROR"]) { 421 formattedMessage = [NSString stringWithFormat:@"No experience found at %@.", self.originalUrl]; 422 } else if ([errorCode isEqualToString:@"EXPERIENCE_SDK_VERSION_OUTDATED"]) { 423 NSDictionary *metadata = userInfo[@"metadata"]; 424 NSArray *availableSDKVersions = metadata[@"availableSDKVersions"]; 425 NSString *sdkVersionRequired = [availableSDKVersions firstObject]; 426 427 NSString *earliestSDKVersion = [self _earliestSdkVersionSupported]; 428 formattedMessage = [NSString stringWithFormat:@"The experience you requested uses Expo SDK v%@, but this copy of Expo Client " 429 "requires at least v%@. The author should update their experience to a newer Expo SDK version.", sdkVersionRequired, earliestSDKVersion]; 430 } else if ([errorCode isEqualToString:@"EXPERIENCE_SDK_VERSION_TOO_NEW"]) { 431 formattedMessage = @"The experience you requested requires a newer version of the Expo Client app. Please download the latest version from the App Store."; 432 } else if ([errorCode isEqualToString:@"NO_COMPATIBLE_EXPERIENCE_FOUND"]){ 433 formattedMessage = rawMessage; // No compatible experience found at ${originalUrl}. Only ${currentSdkVersions} are supported. 434 } else if ([errorCode isEqualToString:@"EXPERIENCE_NOT_VIEWABLE"]) { 435 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. 436 } else if ([errorCode isEqualToString:@"USER_SNACK_NOT_FOUND"] || [errorCode isEqualToString:@"SNACK_NOT_FOUND"]) { 437 formattedMessage = [NSString stringWithFormat:@"No snack found at %@.", self.originalUrl]; 438 } else if ([errorCode isEqualToString:@"SNACK_RUNTIME_NOT_RELEASE"]) { 439 formattedMessage = rawMessage; // From server: `The Snack runtime for corresponding sdk version of this Snack ("${sdkVersions[0]}") is not released.`, 440 } else if ([errorCode isEqualToString:@"SNACK_NOT_FOUND_FOR_SDK_VERSIONS"]) { 441 formattedMessage = rawMessage; // From server: `The snack "${fullName}" was found, but wasn't released for platform "${platform}" and sdk version "${sdkVersions[0]}".` 442 } 443 userInfo[NSLocalizedDescriptionKey] = formattedMessage; 444 445 return [NSError errorWithDomain:EXNetworkErrorDomain code:error.code userInfo:userInfo]; 446} 447 448@end 449