HTTPCacheFileInfo.cs 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502
  1. #if !BESTHTTP_DISABLE_CACHING
  2. using System;
  3. using System.Collections.Generic;
  4. using System.IO;
  5. namespace BestHTTP.Caching
  6. {
  7. using BestHTTP.Extensions;
  8. using BestHTTP.PlatformSupport.FileSystem;
  9. /// <summary>
  10. /// Holds all metadata that need for efficient caching, so we don't need to touch the disk to load headers.
  11. /// </summary>
  12. public class HTTPCacheFileInfo : IComparable<HTTPCacheFileInfo>
  13. {
  14. #region Properties
  15. /// <summary>
  16. /// The uri that this HTTPCacheFileInfo belongs to.
  17. /// </summary>
  18. public Uri Uri { get; private set; }
  19. /// <summary>
  20. /// The last access time to this cache entity. The date is in UTC.
  21. /// </summary>
  22. public DateTime LastAccess { get; private set; }
  23. /// <summary>
  24. /// The length of the cache entity's body.
  25. /// </summary>
  26. public int BodyLength { get; internal set; }
  27. /// <summary>
  28. /// ETag of the entity.
  29. /// </summary>
  30. public string ETag { get; private set; }
  31. /// <summary>
  32. /// LastModified date of the entity.
  33. /// </summary>
  34. public string LastModified { get; private set; }
  35. /// <summary>
  36. /// When the cache will expire.
  37. /// </summary>
  38. public DateTime Expires { get; private set; }
  39. /// <summary>
  40. /// The age that came with the response
  41. /// </summary>
  42. public long Age { get; private set; }
  43. /// <summary>
  44. /// Maximum how long the entry should served from the cache without revalidation.
  45. /// </summary>
  46. public long MaxAge { get; private set; }
  47. /// <summary>
  48. /// The Date that came with the response.
  49. /// </summary>
  50. public DateTime Date { get; private set; }
  51. /// <summary>
  52. /// Indicates whether the entity must be revalidated with the server or can be serverd directly from the cache without touching the server when the content is considered stale.
  53. /// </summary>
  54. public bool MustRevalidate { get; private set; }
  55. /// <summary>
  56. /// If it's true, the client always have to revalidate the cached content when it's stale.
  57. /// </summary>
  58. public bool NoCache { get; private set; }
  59. /// <summary>
  60. /// It's a grace period to serve staled content without revalidation.
  61. /// </summary>
  62. public long StaleWhileRevalidate { get; private set; }
  63. /// <summary>
  64. /// Allows the client to serve stale content if the server responds with an 5xx error.
  65. /// </summary>
  66. public long StaleIfError { get; private set; }
  67. /// <summary>
  68. /// The date and time when the HTTPResponse received.
  69. /// </summary>
  70. public DateTime Received { get; private set; }
  71. /// <summary>
  72. /// Cached path.
  73. /// </summary>
  74. public string ConstructedPath { get; private set; }
  75. /// <summary>
  76. /// This is the index of the entity. Filenames are generated from this value.
  77. /// </summary>
  78. internal UInt64 MappedNameIDX { get; private set; }
  79. #endregion
  80. #region Constructors
  81. internal HTTPCacheFileInfo(Uri uri)
  82. :this(uri, DateTime.UtcNow, -1)
  83. {
  84. }
  85. internal HTTPCacheFileInfo(Uri uri, DateTime lastAcces, int bodyLength)
  86. {
  87. this.Uri = uri;
  88. this.LastAccess = lastAcces;
  89. this.BodyLength = bodyLength;
  90. this.MaxAge = -1;
  91. this.MappedNameIDX = HTTPCacheService.GetNameIdx();
  92. }
  93. internal HTTPCacheFileInfo(Uri uri, System.IO.BinaryReader reader, int version)
  94. {
  95. this.Uri = uri;
  96. this.LastAccess = DateTime.FromBinary(reader.ReadInt64());
  97. this.BodyLength = reader.ReadInt32();
  98. switch(version)
  99. {
  100. case 3:
  101. this.NoCache = reader.ReadBoolean();
  102. this.StaleWhileRevalidate = reader.ReadInt64();
  103. this.StaleIfError = reader.ReadInt64();
  104. goto case 2;
  105. case 2:
  106. this.MappedNameIDX = reader.ReadUInt64();
  107. goto case 1;
  108. case 1:
  109. {
  110. this.ETag = reader.ReadString();
  111. this.LastModified = reader.ReadString();
  112. this.Expires = DateTime.FromBinary(reader.ReadInt64());
  113. this.Age = reader.ReadInt64();
  114. this.MaxAge = reader.ReadInt64();
  115. this.Date = DateTime.FromBinary(reader.ReadInt64());
  116. this.MustRevalidate = reader.ReadBoolean();
  117. this.Received = DateTime.FromBinary(reader.ReadInt64());
  118. break;
  119. }
  120. }
  121. }
  122. #endregion
  123. #region Helper Functions
  124. internal void SaveTo(System.IO.BinaryWriter writer)
  125. {
  126. // base
  127. writer.Write(this.LastAccess.ToBinary());
  128. writer.Write(this.BodyLength);
  129. // version 3
  130. writer.Write(this.NoCache);
  131. writer.Write(this.StaleWhileRevalidate);
  132. writer.Write(this.StaleIfError);
  133. // version 2
  134. writer.Write(this.MappedNameIDX);
  135. // version 1
  136. writer.Write(this.ETag);
  137. writer.Write(this.LastModified);
  138. writer.Write(this.Expires.ToBinary());
  139. writer.Write(this.Age);
  140. writer.Write(this.MaxAge);
  141. writer.Write(this.Date.ToBinary());
  142. writer.Write(this.MustRevalidate);
  143. writer.Write(this.Received.ToBinary());
  144. }
  145. public string GetPath()
  146. {
  147. if (ConstructedPath != null)
  148. return ConstructedPath;
  149. return ConstructedPath = System.IO.Path.Combine(HTTPCacheService.CacheFolder, MappedNameIDX.ToString("X"));
  150. }
  151. public bool IsExists()
  152. {
  153. if (!HTTPCacheService.IsSupported)
  154. return false;
  155. return HTTPManager.IOService.FileExists(GetPath());
  156. }
  157. internal void Delete()
  158. {
  159. if (!HTTPCacheService.IsSupported)
  160. return;
  161. string path = GetPath();
  162. try
  163. {
  164. HTTPManager.IOService.FileDelete(path);
  165. }
  166. catch
  167. { }
  168. finally
  169. {
  170. Reset();
  171. }
  172. }
  173. private void Reset()
  174. {
  175. // MappedNameIDX will remain the same. When we re-save an entity, it will not reset the MappedNameIDX.
  176. this.BodyLength = -1;
  177. this.ETag = string.Empty;
  178. this.Expires = DateTime.FromBinary(0);
  179. this.LastModified = string.Empty;
  180. this.Age = 0;
  181. this.MaxAge = -1;
  182. this.Date = DateTime.FromBinary(0);
  183. this.MustRevalidate = false;
  184. this.Received = DateTime.FromBinary(0);
  185. this.NoCache = false;
  186. this.StaleWhileRevalidate = 0;
  187. this.StaleIfError = 0;
  188. }
  189. #endregion
  190. #region Caching
  191. internal void SetUpCachingValues(HTTPResponse response)
  192. {
  193. response.CacheFileInfo = this;
  194. this.ETag = response.GetFirstHeaderValue("ETag").ToStr(this.ETag ?? string.Empty);
  195. this.Expires = response.GetFirstHeaderValue("Expires").ToDateTime(this.Expires);
  196. this.LastModified = response.GetFirstHeaderValue("Last-Modified").ToStr(this.LastModified ?? string.Empty);
  197. this.Age = response.GetFirstHeaderValue("Age").ToInt64(this.Age);
  198. this.Date = response.GetFirstHeaderValue("Date").ToDateTime(this.Date);
  199. List<string> cacheControls = response.GetHeaderValues("cache-control");
  200. if (cacheControls != null && cacheControls.Count > 0)
  201. {
  202. // Merge all Cache-Control header values into one
  203. string cacheControl = cacheControls[0];
  204. for (int i = 1; i < cacheControls.Count; ++i)
  205. cacheControl += "," + cacheControls[i];
  206. if (!string.IsNullOrEmpty(cacheControl))
  207. {
  208. HeaderParser parser = new HeaderParser(cacheControl);
  209. if (parser.Values != null)
  210. {
  211. for (int i = 0; i < parser.Values.Count; ++i)
  212. {
  213. var kvp = parser.Values[i];
  214. switch(kvp.Key.ToLowerInvariant())
  215. {
  216. case "max-age":
  217. if (kvp.HasValue)
  218. {
  219. // Some cache proxies will return float values
  220. double maxAge;
  221. if (double.TryParse(kvp.Value, out maxAge))
  222. this.MaxAge = (int)maxAge;
  223. else
  224. this.MaxAge = 0;
  225. }
  226. else
  227. this.MaxAge = 0;
  228. break;
  229. case "stale-while-revalidate":
  230. this.StaleWhileRevalidate = kvp.HasValue ? kvp.Value.ToInt64(0) : 0;
  231. break;
  232. case "stale-if-error":
  233. this.StaleIfError = kvp.HasValue ? kvp.Value.ToInt64(0) : 0;
  234. break;
  235. case "must-revalidate":
  236. this.MustRevalidate = true;
  237. break;
  238. case "no-cache":
  239. this.NoCache = true;
  240. break;
  241. }
  242. }
  243. }
  244. //string[] options = cacheControl.ToLowerInvariant().Split(new char[] { ',' }, StringSplitOptions.RemoveEmptyEntries);
  245. //
  246. //string[] kvp = options.FindOption("max-age");
  247. //if (kvp != null && kvp.Length > 1)
  248. //{
  249. // // Some cache proxies will return float values
  250. // double maxAge;
  251. // if (double.TryParse(kvp[1], out maxAge))
  252. // this.MaxAge = (int)maxAge;
  253. // else
  254. // this.MaxAge = 0;
  255. //}
  256. //else
  257. // this.MaxAge = 0;
  258. //
  259. //kvp = options.FindOption("stale-while-revalidate");
  260. //if (kvp != null && kvp.Length == 2 && !string.IsNullOrEmpty(kvp[1]))
  261. // this.StaleWhileRevalidate = kvp[1].ToInt64(0);
  262. //
  263. //kvp = options.FindOption("stale-if-error");
  264. //if (kvp != null && kvp.Length == 2 && !string.IsNullOrEmpty(kvp[1]))
  265. // this.StaleIfError = kvp[1].ToInt64(0);
  266. //
  267. //this.MustRevalidate = cacheControl.Contains("must-revalidate");
  268. //this.NoCache = cacheControl.Contains("no-cache");
  269. }
  270. }
  271. this.Received = DateTime.UtcNow;
  272. }
  273. /// <summary>
  274. /// isInError should be true if downloading the content fails, and in that case, it might extend the content's freshness
  275. /// </summary>
  276. public bool WillExpireInTheFuture(bool isInError)
  277. {
  278. if (!IsExists())
  279. return false;
  280. // https://csswizardry.com/2019/03/cache-control-for-civilians/#no-cache
  281. // no-cache will always hit the network as it has to revalidate with the server before it can release the browser’s cached copy (unless the server responds with a fresher response),
  282. // but if the server responds favourably, the network transfer is only a file’s headers: the body can be grabbed from cache rather than redownloaded.
  283. if (this.NoCache)
  284. return false;
  285. // http://www.w3.org/Protocols/rfc2616/rfc2616-sec13.html#sec13.2.4 :
  286. // The max-age directive takes priority over Expires
  287. if (MaxAge > 0)
  288. {
  289. // Age calculation:
  290. // http://www.w3.org/Protocols/rfc2616/rfc2616-sec13.html#sec13.2.3
  291. long apparent_age = Math.Max(0, (long)(this.Received - this.Date).TotalSeconds);
  292. long corrected_received_age = Math.Max(apparent_age, this.Age);
  293. long resident_time = (long)(DateTime.UtcNow - this.Date).TotalSeconds;
  294. long current_age = corrected_received_age + resident_time;
  295. long maxAge = this.MaxAge + (this.NoCache ? 0 : this.StaleWhileRevalidate) + (isInError ? this.StaleIfError : 0);
  296. return current_age < maxAge || this.Expires > DateTime.UtcNow;
  297. }
  298. return this.Expires > DateTime.UtcNow;
  299. }
  300. internal void SetUpRevalidationHeaders(HTTPRequest request)
  301. {
  302. if (!IsExists())
  303. return;
  304. // -If an entity tag has been provided by the origin server, MUST use that entity tag in any cache-conditional request (using If-Match or If-None-Match).
  305. // -If only a Last-Modified value has been provided by the origin server, SHOULD use that value in non-subrange cache-conditional requests (using If-Modified-Since).
  306. // -If both an entity tag and a Last-Modified value have been provided by the origin server, SHOULD use both validators in cache-conditional requests. This allows both HTTP/1.0 and HTTP/1.1 caches to respond appropriately.
  307. if (!string.IsNullOrEmpty(ETag))
  308. request.SetHeader("If-None-Match", ETag);
  309. if (!string.IsNullOrEmpty(LastModified))
  310. request.SetHeader("If-Modified-Since", LastModified);
  311. }
  312. public System.IO.Stream GetBodyStream(out int length)
  313. {
  314. if (!IsExists())
  315. {
  316. length = 0;
  317. return null;
  318. }
  319. length = BodyLength;
  320. LastAccess = DateTime.UtcNow;
  321. Stream stream = HTTPManager.IOService.CreateFileStream(GetPath(), FileStreamModes.OpenRead);
  322. stream.Seek(-length, System.IO.SeekOrigin.End);
  323. return stream;
  324. }
  325. internal HTTPResponse ReadResponseTo(HTTPRequest request)
  326. {
  327. if (!IsExists())
  328. return null;
  329. LastAccess = DateTime.UtcNow;
  330. using (Stream stream = HTTPManager.IOService.CreateFileStream(GetPath(), FileStreamModes.OpenRead))
  331. {
  332. var response = new HTTPResponse(request, stream, request.UseStreaming, true);
  333. response.CacheFileInfo = this;
  334. response.Receive(BodyLength);
  335. return response;
  336. }
  337. }
  338. internal void Store(HTTPResponse response)
  339. {
  340. if (!HTTPCacheService.IsSupported)
  341. return;
  342. string path = GetPath();
  343. // Path name too long, we don't want to get exceptions
  344. if (path.Length > HTTPManager.MaxPathLength)
  345. return;
  346. if (HTTPManager.IOService.FileExists(path))
  347. Delete();
  348. using (Stream writer = HTTPManager.IOService.CreateFileStream(GetPath(), FileStreamModes.Create))
  349. {
  350. writer.WriteLine("HTTP/{0}.{1} {2} {3}", response.VersionMajor, response.VersionMinor, response.StatusCode, response.Message);
  351. foreach (var kvp in response.Headers)
  352. {
  353. for (int i = 0; i < kvp.Value.Count; ++i)
  354. writer.WriteLine("{0}: {1}", kvp.Key, kvp.Value[i]);
  355. }
  356. writer.WriteLine();
  357. writer.Write(response.Data, 0, response.Data.Length);
  358. }
  359. BodyLength = response.Data.Length;
  360. LastAccess = DateTime.UtcNow;
  361. SetUpCachingValues(response);
  362. }
  363. internal System.IO.Stream GetSaveStream(HTTPResponse response)
  364. {
  365. if (!HTTPCacheService.IsSupported)
  366. return null;
  367. LastAccess = DateTime.UtcNow;
  368. string path = GetPath();
  369. if (HTTPManager.IOService.FileExists(path))
  370. Delete();
  371. // Path name too long, we don't want to get exceptions
  372. if (path.Length > HTTPManager.MaxPathLength)
  373. return null;
  374. // First write out the headers
  375. using (Stream writer = HTTPManager.IOService.CreateFileStream(GetPath(), FileStreamModes.Create))
  376. {
  377. writer.WriteLine("HTTP/1.1 {0} {1}", response.StatusCode, response.Message);
  378. foreach (var kvp in response.Headers)
  379. {
  380. for (int i = 0; i < kvp.Value.Count; ++i)
  381. writer.WriteLine("{0}: {1}", kvp.Key, kvp.Value[i]);
  382. }
  383. writer.WriteLine();
  384. }
  385. // If caching is enabled and the response is from cache, and no content-length header set, then we set one to the response.
  386. if (response.IsFromCache && !response.HasHeader("content-length"))
  387. response.AddHeader("content-length", BodyLength.ToString());
  388. SetUpCachingValues(response);
  389. // then create the stream with Append FileMode
  390. return HTTPManager.IOService.CreateFileStream(GetPath(), FileStreamModes.Append);
  391. }
  392. #endregion
  393. #region IComparable<HTTPCacheFileInfo>
  394. public int CompareTo(HTTPCacheFileInfo other)
  395. {
  396. return this.LastAccess.CompareTo(other.LastAccess);
  397. }
  398. #endregion
  399. }
  400. }
  401. #endif