ASIDownloadCache.m 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514
  1. //
  2. // ASIDownloadCache.m
  3. // Part of ASIHTTPRequest -> http://allseeing-i.com/ASIHTTPRequest
  4. //
  5. // Created by Ben Copsey on 01/05/2010.
  6. // Copyright 2010 All-Seeing Interactive. All rights reserved.
  7. //
  8. #import "ASIDownloadCache.h"
  9. #import "ASIHTTPRequest.h"
  10. #import <CommonCrypto/CommonHMAC.h>
  11. static ASIDownloadCache *sharedCache = nil;
  12. static NSString *sessionCacheFolder = @"SessionStore";
  13. static NSString *permanentCacheFolder = @"PermanentStore";
  14. static NSArray *fileExtensionsToHandleAsHTML = nil;
  15. @interface ASIDownloadCache ()
  16. + (NSString *)keyForURL:(NSURL *)url;
  17. - (NSString *)pathToFile:(NSString *)file;
  18. @end
  19. @implementation ASIDownloadCache
  20. + (void)initialize
  21. {
  22. if (self == [ASIDownloadCache class]) {
  23. // Obviously this is not an exhaustive list, but hopefully these are the most commonly used and this will 'just work' for the widest range of people
  24. // I imagine many web developers probably use url rewriting anyway
  25. fileExtensionsToHandleAsHTML = [[NSArray alloc] initWithObjects:@"asp",@"aspx",@"jsp",@"php",@"rb",@"py",@"pl",@"cgi", nil];
  26. }
  27. }
  28. - (id)init
  29. {
  30. self = [super init];
  31. [self setShouldRespectCacheControlHeaders:YES];
  32. [self setDefaultCachePolicy:ASIUseDefaultCachePolicy];
  33. [self setAccessLock:[[[NSRecursiveLock alloc] init] autorelease]];
  34. return self;
  35. }
  36. + (id)sharedCache
  37. {
  38. if (!sharedCache) {
  39. @synchronized(self) {
  40. if (!sharedCache) {
  41. sharedCache = [[self alloc] init];
  42. [sharedCache setStoragePath:[[NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, YES) objectAtIndex:0] stringByAppendingPathComponent:@"ASIHTTPRequestCache"]];
  43. }
  44. }
  45. }
  46. return sharedCache;
  47. }
  48. - (void)dealloc
  49. {
  50. [storagePath release];
  51. [accessLock release];
  52. [super dealloc];
  53. }
  54. - (NSString *)storagePath
  55. {
  56. [[self accessLock] lock];
  57. NSString *p = [[storagePath retain] autorelease];
  58. [[self accessLock] unlock];
  59. return p;
  60. }
  61. - (void)setStoragePath:(NSString *)path
  62. {
  63. [[self accessLock] lock];
  64. [self clearCachedResponsesForStoragePolicy:ASICacheForSessionDurationCacheStoragePolicy];
  65. [storagePath release];
  66. storagePath = [path retain];
  67. NSFileManager *fileManager = [[[NSFileManager alloc] init] autorelease];
  68. BOOL isDirectory = NO;
  69. NSArray *directories = [NSArray arrayWithObjects:path,[path stringByAppendingPathComponent:sessionCacheFolder],[path stringByAppendingPathComponent:permanentCacheFolder],nil];
  70. for (NSString *directory in directories) {
  71. BOOL exists = [fileManager fileExistsAtPath:directory isDirectory:&isDirectory];
  72. if (exists && !isDirectory) {
  73. [[self accessLock] unlock];
  74. [NSException raise:@"FileExistsAtCachePath" format:@"Cannot create a directory for the cache at '%@', because a file already exists",directory];
  75. } else if (!exists) {
  76. [fileManager createDirectoryAtPath:directory withIntermediateDirectories:NO attributes:nil error:nil];
  77. if (![fileManager fileExistsAtPath:directory]) {
  78. [[self accessLock] unlock];
  79. [NSException raise:@"FailedToCreateCacheDirectory" format:@"Failed to create a directory for the cache at '%@'",directory];
  80. }
  81. }
  82. }
  83. [self clearCachedResponsesForStoragePolicy:ASICacheForSessionDurationCacheStoragePolicy];
  84. [[self accessLock] unlock];
  85. }
  86. - (void)updateExpiryForRequest:(ASIHTTPRequest *)request maxAge:(NSTimeInterval)maxAge
  87. {
  88. NSString *headerPath = [self pathToStoreCachedResponseHeadersForRequest:request];
  89. NSMutableDictionary *cachedHeaders = [NSMutableDictionary dictionaryWithContentsOfFile:headerPath];
  90. if (!cachedHeaders) {
  91. return;
  92. }
  93. NSDate *expires = [self expiryDateForRequest:request maxAge:maxAge];
  94. if (!expires) {
  95. return;
  96. }
  97. [cachedHeaders setObject:[NSNumber numberWithDouble:[expires timeIntervalSince1970]] forKey:@"X-ASIHTTPRequest-Expires"];
  98. [cachedHeaders writeToFile:headerPath atomically:NO];
  99. }
  100. - (NSDate *)expiryDateForRequest:(ASIHTTPRequest *)request maxAge:(NSTimeInterval)maxAge
  101. {
  102. return [ASIHTTPRequest expiryDateForRequest:request maxAge:maxAge];
  103. }
  104. - (void)storeResponseForRequest:(ASIHTTPRequest *)request maxAge:(NSTimeInterval)maxAge
  105. {
  106. [[self accessLock] lock];
  107. if ([request error] || ![request responseHeaders] || ([request cachePolicy] & ASIDoNotWriteToCacheCachePolicy)) {
  108. [[self accessLock] unlock];
  109. return;
  110. }
  111. // We only cache 200/OK or redirect reponses (redirect responses are cached so the cache works better with no internet connection)
  112. int responseCode = [request responseStatusCode];
  113. if (responseCode != 200 && responseCode != 301 && responseCode != 302 && responseCode != 303 && responseCode != 307) {
  114. [[self accessLock] unlock];
  115. return;
  116. }
  117. if ([self shouldRespectCacheControlHeaders] && ![[self class] serverAllowsResponseCachingForRequest:request]) {
  118. [[self accessLock] unlock];
  119. return;
  120. }
  121. NSString *headerPath = [self pathToStoreCachedResponseHeadersForRequest:request];
  122. NSString *dataPath = [self pathToStoreCachedResponseDataForRequest:request];
  123. NSMutableDictionary *responseHeaders = [NSMutableDictionary dictionaryWithDictionary:[request responseHeaders]];
  124. if ([request isResponseCompressed]) {
  125. [responseHeaders removeObjectForKey:@"Content-Encoding"];
  126. }
  127. // Create a special 'X-ASIHTTPRequest-Expires' header
  128. // This is what we use for deciding if cached data is current, rather than parsing the expires / max-age headers individually each time
  129. // We store this as a timestamp to make reading it easier as NSDateFormatter is quite expensive
  130. NSDate *expires = [self expiryDateForRequest:request maxAge:maxAge];
  131. if (expires) {
  132. [responseHeaders setObject:[NSNumber numberWithDouble:[expires timeIntervalSince1970]] forKey:@"X-ASIHTTPRequest-Expires"];
  133. }
  134. // Store the response code in a custom header so we can reuse it later
  135. // We'll change 304/Not Modified to 200/OK because this is likely to be us updating the cached headers with a conditional GET
  136. int statusCode = [request responseStatusCode];
  137. if (statusCode == 304) {
  138. statusCode = 200;
  139. }
  140. [responseHeaders setObject:[NSNumber numberWithInt:statusCode] forKey:@"X-ASIHTTPRequest-Response-Status-Code"];
  141. [responseHeaders writeToFile:headerPath atomically:NO];
  142. if ([request responseData]) {
  143. [[request responseData] writeToFile:dataPath atomically:NO];
  144. } else if ([request downloadDestinationPath] && ![[request downloadDestinationPath] isEqualToString:dataPath]) {
  145. NSError *error = nil;
  146. NSFileManager* manager = [[NSFileManager alloc] init];
  147. if ([manager fileExistsAtPath:dataPath]) {
  148. [manager removeItemAtPath:dataPath error:&error];
  149. }
  150. [manager copyItemAtPath:[request downloadDestinationPath] toPath:dataPath error:&error];
  151. [manager release];
  152. }
  153. [[self accessLock] unlock];
  154. }
  155. - (NSDictionary *)cachedResponseHeadersForURL:(NSURL *)url
  156. {
  157. NSString *path = [self pathToCachedResponseHeadersForURL:url];
  158. if (path) {
  159. return [NSDictionary dictionaryWithContentsOfFile:path];
  160. }
  161. return nil;
  162. }
  163. - (NSData *)cachedResponseDataForURL:(NSURL *)url
  164. {
  165. NSString *path = [self pathToCachedResponseDataForURL:url];
  166. if (path) {
  167. return [NSData dataWithContentsOfFile:path];
  168. }
  169. return nil;
  170. }
  171. - (NSString *)pathToCachedResponseDataForURL:(NSURL *)url
  172. {
  173. // Grab the file extension, if there is one. We do this so we can save the cached response with the same file extension - this is important if you want to display locally cached data in a web view
  174. NSString *extension = [[url path] pathExtension];
  175. // If the url doesn't have an extension, we'll add one so a webview can read it when locally cached
  176. // If the url has the extension of a common web scripting language, we'll change the extension on the cached path to html for the same reason
  177. if (![extension length] || [[[self class] fileExtensionsToHandleAsHTML] containsObject:[extension lowercaseString]]) {
  178. extension = @"html";
  179. }
  180. return [self pathToFile:[[[self class] keyForURL:url] stringByAppendingPathExtension:extension]];
  181. }
  182. + (NSArray *)fileExtensionsToHandleAsHTML
  183. {
  184. return fileExtensionsToHandleAsHTML;
  185. }
  186. - (NSString *)pathToCachedResponseHeadersForURL:(NSURL *)url
  187. {
  188. return [self pathToFile:[[[self class] keyForURL:url] stringByAppendingPathExtension:@"cachedheaders"]];
  189. }
  190. - (NSString *)pathToFile:(NSString *)file
  191. {
  192. [[self accessLock] lock];
  193. if (![self storagePath]) {
  194. [[self accessLock] unlock];
  195. return nil;
  196. }
  197. NSFileManager *fileManager = [[[NSFileManager alloc] init] autorelease];
  198. // Look in the session store
  199. NSString *dataPath = [[[self storagePath] stringByAppendingPathComponent:sessionCacheFolder] stringByAppendingPathComponent:file];
  200. if ([fileManager fileExistsAtPath:dataPath]) {
  201. [[self accessLock] unlock];
  202. return dataPath;
  203. }
  204. // Look in the permanent store
  205. dataPath = [[[self storagePath] stringByAppendingPathComponent:permanentCacheFolder] stringByAppendingPathComponent:file];
  206. if ([fileManager fileExistsAtPath:dataPath]) {
  207. [[self accessLock] unlock];
  208. return dataPath;
  209. }
  210. [[self accessLock] unlock];
  211. return nil;
  212. }
  213. - (NSString *)pathToStoreCachedResponseDataForRequest:(ASIHTTPRequest *)request
  214. {
  215. [[self accessLock] lock];
  216. if (![self storagePath]) {
  217. [[self accessLock] unlock];
  218. return nil;
  219. }
  220. NSString *path = [[self storagePath] stringByAppendingPathComponent:([request cacheStoragePolicy] == ASICacheForSessionDurationCacheStoragePolicy ? sessionCacheFolder : permanentCacheFolder)];
  221. // Grab the file extension, if there is one. We do this so we can save the cached response with the same file extension - this is important if you want to display locally cached data in a web view
  222. NSString *extension = [[[request url] path] pathExtension];
  223. // If the url doesn't have an extension, we'll add one so a webview can read it when locally cached
  224. // If the url has the extension of a common web scripting language, we'll change the extension on the cached path to html for the same reason
  225. if (![extension length] || [[[self class] fileExtensionsToHandleAsHTML] containsObject:[extension lowercaseString]]) {
  226. extension = @"html";
  227. }
  228. path = [path stringByAppendingPathComponent:[[[self class] keyForURL:[request url]] stringByAppendingPathExtension:extension]];
  229. [[self accessLock] unlock];
  230. return path;
  231. }
  232. - (NSString *)pathToStoreCachedResponseHeadersForRequest:(ASIHTTPRequest *)request
  233. {
  234. [[self accessLock] lock];
  235. if (![self storagePath]) {
  236. [[self accessLock] unlock];
  237. return nil;
  238. }
  239. NSString *path = [[self storagePath] stringByAppendingPathComponent:([request cacheStoragePolicy] == ASICacheForSessionDurationCacheStoragePolicy ? sessionCacheFolder : permanentCacheFolder)];
  240. path = [path stringByAppendingPathComponent:[[[self class] keyForURL:[request url]] stringByAppendingPathExtension:@"cachedheaders"]];
  241. [[self accessLock] unlock];
  242. return path;
  243. }
  244. - (void)removeCachedDataForURL:(NSURL *)url
  245. {
  246. [[self accessLock] lock];
  247. if (![self storagePath]) {
  248. [[self accessLock] unlock];
  249. return;
  250. }
  251. NSFileManager *fileManager = [[[NSFileManager alloc] init] autorelease];
  252. NSString *path = [self pathToCachedResponseHeadersForURL:url];
  253. if (path) {
  254. [fileManager removeItemAtPath:path error:NULL];
  255. }
  256. path = [self pathToCachedResponseDataForURL:url];
  257. if (path) {
  258. [fileManager removeItemAtPath:path error:NULL];
  259. }
  260. [[self accessLock] unlock];
  261. }
  262. - (void)removeCachedDataForRequest:(ASIHTTPRequest *)request
  263. {
  264. [self removeCachedDataForURL:[request url]];
  265. }
  266. - (BOOL)isCachedDataCurrentForRequest:(ASIHTTPRequest *)request
  267. {
  268. [[self accessLock] lock];
  269. if (![self storagePath]) {
  270. [[self accessLock] unlock];
  271. return NO;
  272. }
  273. NSDictionary *cachedHeaders = [self cachedResponseHeadersForURL:[request url]];
  274. if (!cachedHeaders) {
  275. [[self accessLock] unlock];
  276. return NO;
  277. }
  278. NSString *dataPath = [self pathToCachedResponseDataForURL:[request url]];
  279. if (!dataPath) {
  280. [[self accessLock] unlock];
  281. return NO;
  282. }
  283. // New content is not different
  284. if ([request responseStatusCode] == 304) {
  285. [[self accessLock] unlock];
  286. return YES;
  287. }
  288. // If we already have response headers for this request, check to see if the new content is different
  289. // We check [request complete] so that we don't end up comparing response headers from a redirection with these
  290. if ([request responseHeaders] && [request complete]) {
  291. // If the Etag or Last-Modified date are different from the one we have, we'll have to fetch this resource again
  292. NSArray *headersToCompare = [NSArray arrayWithObjects:@"Etag",@"Last-Modified",nil];
  293. for (NSString *header in headersToCompare) {
  294. if (![[[request responseHeaders] objectForKey:header] isEqualToString:[cachedHeaders objectForKey:header]]) {
  295. [[self accessLock] unlock];
  296. return NO;
  297. }
  298. }
  299. }
  300. if ([self shouldRespectCacheControlHeaders]) {
  301. // Look for X-ASIHTTPRequest-Expires header to see if the content is out of date
  302. NSNumber *expires = [cachedHeaders objectForKey:@"X-ASIHTTPRequest-Expires"];
  303. if (expires) {
  304. if ([[NSDate dateWithTimeIntervalSince1970:[expires doubleValue]] timeIntervalSinceNow] >= 0) {
  305. [[self accessLock] unlock];
  306. return YES;
  307. }
  308. }
  309. // No explicit expiration time sent by the server
  310. [[self accessLock] unlock];
  311. return NO;
  312. }
  313. [[self accessLock] unlock];
  314. return YES;
  315. }
  316. - (ASICachePolicy)defaultCachePolicy
  317. {
  318. [[self accessLock] lock];
  319. ASICachePolicy cp = defaultCachePolicy;
  320. [[self accessLock] unlock];
  321. return cp;
  322. }
  323. - (void)setDefaultCachePolicy:(ASICachePolicy)cachePolicy
  324. {
  325. [[self accessLock] lock];
  326. if (!cachePolicy) {
  327. defaultCachePolicy = ASIAskServerIfModifiedWhenStaleCachePolicy;
  328. } else {
  329. defaultCachePolicy = cachePolicy;
  330. }
  331. [[self accessLock] unlock];
  332. }
  333. - (void)clearCachedResponsesForStoragePolicy:(ASICacheStoragePolicy)storagePolicy
  334. {
  335. [[self accessLock] lock];
  336. if (![self storagePath]) {
  337. [[self accessLock] unlock];
  338. return;
  339. }
  340. NSString *path = [[self storagePath] stringByAppendingPathComponent:(storagePolicy == ASICacheForSessionDurationCacheStoragePolicy ? sessionCacheFolder : permanentCacheFolder)];
  341. NSFileManager *fileManager = [[[NSFileManager alloc] init] autorelease];
  342. BOOL isDirectory = NO;
  343. BOOL exists = [fileManager fileExistsAtPath:path isDirectory:&isDirectory];
  344. if (!exists || !isDirectory) {
  345. [[self accessLock] unlock];
  346. return;
  347. }
  348. NSError *error = nil;
  349. NSArray *cacheFiles = [fileManager contentsOfDirectoryAtPath:path error:&error];
  350. if (error) {
  351. [[self accessLock] unlock];
  352. [NSException raise:@"FailedToTraverseCacheDirectory" format:@"Listing cache directory failed at path '%@'",path];
  353. }
  354. for (NSString *file in cacheFiles) {
  355. [fileManager removeItemAtPath:[path stringByAppendingPathComponent:file] error:&error];
  356. if (error) {
  357. [[self accessLock] unlock];
  358. [NSException raise:@"FailedToRemoveCacheFile" format:@"Failed to remove cached data at path '%@'",path];
  359. }
  360. }
  361. [[self accessLock] unlock];
  362. }
  363. + (BOOL)serverAllowsResponseCachingForRequest:(ASIHTTPRequest *)request
  364. {
  365. NSString *cacheControl = [[[request responseHeaders] objectForKey:@"Cache-Control"] lowercaseString];
  366. if (cacheControl) {
  367. if ([cacheControl isEqualToString:@"no-cache"] || [cacheControl isEqualToString:@"no-store"]) {
  368. return NO;
  369. }
  370. }
  371. NSString *pragma = [[[request responseHeaders] objectForKey:@"Pragma"] lowercaseString];
  372. if (pragma) {
  373. if ([pragma isEqualToString:@"no-cache"]) {
  374. return NO;
  375. }
  376. }
  377. return YES;
  378. }
  379. + (NSString *)keyForURL:(NSURL *)url
  380. {
  381. NSString *urlString = [url absoluteString];
  382. if ([urlString length] == 0) {
  383. return nil;
  384. }
  385. // Strip trailing slashes so http://allseeing-i.com/ASIHTTPRequest/ is cached the same as http://allseeing-i.com/ASIHTTPRequest
  386. if ([[urlString substringFromIndex:[urlString length]-1] isEqualToString:@"/"]) {
  387. urlString = [urlString substringToIndex:[urlString length]-1];
  388. }
  389. // Borrowed from: http://stackoverflow.com/questions/652300/using-md5-hash-on-a-string-in-cocoa
  390. const char *cStr = [urlString UTF8String];
  391. unsigned char result[16];
  392. CC_MD5(cStr, (CC_LONG)strlen(cStr), result);
  393. return [NSString stringWithFormat:@"%02X%02X%02X%02X%02X%02X%02X%02X%02X%02X%02X%02X%02X%02X%02X%02X",result[0], result[1], result[2], result[3], result[4], result[5], result[6], result[7],result[8], result[9], result[10], result[11],result[12], result[13], result[14], result[15]];
  394. }
  395. - (BOOL)canUseCachedDataForRequest:(ASIHTTPRequest *)request
  396. {
  397. // Ensure the request is allowed to read from the cache
  398. if ([request cachePolicy] & ASIDoNotReadFromCacheCachePolicy) {
  399. return NO;
  400. // If we don't want to load the request whatever happens, always pretend we have cached data even if we don't
  401. } else if ([request cachePolicy] & ASIDontLoadCachePolicy) {
  402. return YES;
  403. }
  404. NSDictionary *headers = [self cachedResponseHeadersForURL:[request url]];
  405. if (!headers) {
  406. return NO;
  407. }
  408. NSString *dataPath = [self pathToCachedResponseDataForURL:[request url]];
  409. if (!dataPath) {
  410. return NO;
  411. }
  412. // If we get here, we have cached data
  413. // If we have cached data, we can use it
  414. if ([request cachePolicy] & ASIOnlyLoadIfNotCachedCachePolicy) {
  415. return YES;
  416. // If we want to fallback to the cache after an error
  417. } else if ([request complete] && [request cachePolicy] & ASIFallbackToCacheIfLoadFailsCachePolicy) {
  418. return YES;
  419. // If we have cached data that is current, we can use it
  420. } else if ([request cachePolicy] & ASIAskServerIfModifiedWhenStaleCachePolicy) {
  421. if ([self isCachedDataCurrentForRequest:request]) {
  422. return YES;
  423. }
  424. // If we've got headers from a conditional GET and the cached data is still current, we can use it
  425. } else if ([request cachePolicy] & ASIAskServerIfModifiedCachePolicy) {
  426. if (![request responseHeaders]) {
  427. return NO;
  428. } else if ([self isCachedDataCurrentForRequest:request]) {
  429. return YES;
  430. }
  431. }
  432. return NO;
  433. }
  434. @synthesize storagePath;
  435. @synthesize defaultCachePolicy;
  436. @synthesize accessLock;
  437. @synthesize shouldRespectCacheControlHeaders;
  438. @end