using System; using System.Collections.Generic; using System.Globalization; using System.IO; using System.Net; using ServiceStack.Web; namespace ServiceStack { public class HttpCacheFeature : IPlugin { public TimeSpan DefaultMaxAge { get; set; } public TimeSpan DefaultExpiresIn { get; set; } public Func CacheControlFilter { get; set; } public string CacheControlForOptimizedResults { get; set; } public HttpCacheFeature() { DefaultMaxAge = TimeSpan.FromMinutes(10); DefaultExpiresIn = TimeSpan.FromMinutes(10); CacheControlForOptimizedResults = "max-age=0"; } public void Register(IAppHost appHost) { appHost.GlobalResponseFilters.Add(HandleCacheResponses); } public void HandleCacheResponses(IRequest req, IResponse res, object response) { if (req.IsInProcessRequest()) return; var cacheInfo = req.GetItem(Keywords.CacheInfo) as CacheInfo; if (cacheInfo != null && cacheInfo.CacheKey != null) { if (CacheAndWriteResponse(cacheInfo, req, res, response)) return; } var httpResult = response as HttpResult; if (httpResult == null) return; cacheInfo = httpResult.ToCacheInfo(); if ((req.Verb != HttpMethods.Get && req.Verb != HttpMethods.Head) || (httpResult.StatusCode != HttpStatusCode.OK && httpResult.StatusCode != HttpStatusCode.NotModified)) return; if (httpResult.LastModified != null) httpResult.Headers[HttpHeaders.LastModified] = httpResult.LastModified.Value.ToUniversalTime().ToString("r"); if (httpResult.ETag != null) httpResult.Headers[HttpHeaders.ETag] = httpResult.ETag.Quoted(); if (httpResult.Expires != null) httpResult.Headers[HttpHeaders.Expires] = httpResult.Expires.Value.ToUniversalTime().ToString("r"); if (httpResult.Age != null) httpResult.Headers[HttpHeaders.Age] = httpResult.Age.Value.TotalSeconds.ToString(CultureInfo.InvariantCulture); var alreadySpecifiedCacheControl = httpResult.Headers.ContainsKey(HttpHeaders.CacheControl); if (!alreadySpecifiedCacheControl) { var cacheControl = BuildCacheControlHeader(cacheInfo); if (cacheControl != null) httpResult.Headers[HttpHeaders.CacheControl] = cacheControl; } if (req.ETagMatch(httpResult.ETag) || req.NotModifiedSince(httpResult.LastModified)) { res.EndNotModified(); } } private bool CacheAndWriteResponse(CacheInfo cacheInfo, IRequest req, IResponse res, object response) { var httpResult = response as IHttpResult; var dto = httpResult != null ? httpResult.Response : response; if (dto == null || dto is IPartialWriter || dto is IStreamWriter) return false; var expiresIn = cacheInfo.ExpiresIn.GetValueOrDefault(DefaultExpiresIn); var cache = cacheInfo.LocalCache ? HostContext.LocalCache : HostContext.Cache; var responseBytes = dto as byte[]; if (responseBytes == null) { var rawStr = dto as string; if (rawStr != null) responseBytes = rawStr.ToUtf8Bytes(); else { var stream = dto as Stream; if (stream != null) responseBytes = stream.ReadFully(); } } var encoding = req.GetCompressionType(); var cacheKeyEncoded = encoding != null ? cacheInfo.CacheKey + "." + encoding : null; if (responseBytes != null || req.ResponseContentType.IsBinary()) { if (responseBytes == null) responseBytes = HostContext.ContentTypes.SerializeToBytes(req, dto); cache.Set(cacheInfo.CacheKey, responseBytes, expiresIn); if (encoding != null) { res.AddHeader(HttpHeaders.ContentEncoding, encoding); responseBytes = responseBytes.CompressBytes(encoding); cache.Set(cacheKeyEncoded, responseBytes, expiresIn); } } else { var serializedDto = req.SerializeToString(dto); if (req.ResponseContentType.MatchesContentType(MimeTypes.Json)) { var jsonp = req.GetJsonpCallback(); if (jsonp != null) serializedDto = jsonp + "(" + serializedDto + ")"; } responseBytes = serializedDto.ToUtf8Bytes(); cache.Set(cacheInfo.CacheKey, responseBytes, expiresIn); if (encoding != null) { responseBytes = responseBytes.CompressBytes(encoding); cache.Set(cacheKeyEncoded, responseBytes, expiresIn); res.AddHeader(HttpHeaders.ContentEncoding, encoding); } } var doHttpCaching = cacheInfo.MaxAge != null || cacheInfo.CacheControl != CacheControl.None; if (doHttpCaching) { var cacheControl = BuildCacheControlHeader(cacheInfo); if (cacheControl != null) { var lastModified = cacheInfo.LastModified.GetValueOrDefault(DateTime.UtcNow); cache.Set("date:" + cacheInfo.CacheKey, lastModified, expiresIn); res.AddHeaderLastModified(lastModified); res.AddHeader(HttpHeaders.CacheControl, cacheControl); if (encoding != null) res.AddHeader(HttpHeaders.Vary, "Accept-Encoding"); if (cacheInfo.VaryByUser) res.AddHeader(HttpHeaders.Vary, "Cookie"); } } if (httpResult != null) { foreach (var header in httpResult.Headers) { res.AddHeader(header.Key, header.Value); } } res.WriteBytesToResponse(responseBytes, req.ResponseContentType); return true; } public string BuildCacheControlHeader(CacheInfo cacheInfo) { var maxAge = cacheInfo.MaxAge; if (maxAge == null && (cacheInfo.LastModified != null || cacheInfo.ETag != null)) maxAge = DefaultMaxAge; var cacheHeader = new List(); if (maxAge != null) cacheHeader.Add("max-age=" + maxAge.Value.TotalSeconds); if (cacheInfo.CacheControl != CacheControl.None) { var cache = cacheInfo.CacheControl; if (cache.Has(CacheControl.Public)) cacheHeader.Add("public"); else if (cache.Has(CacheControl.Private)) cacheHeader.Add("private"); if (cache.Has(CacheControl.MustRevalidate)) cacheHeader.Add("must-revalidate"); if (cache.Has(CacheControl.NoCache)) cacheHeader.Add("no-cache"); if (cache.Has(CacheControl.NoStore)) cacheHeader.Add("no-store"); if (cache.Has(CacheControl.NoTransform)) cacheHeader.Add("no-transform"); if (cache.Has(CacheControl.ProxyRevalidate)) cacheHeader.Add("proxy-revalidate"); } if (cacheHeader.Count <= 0) return null; var cacheControl = cacheHeader.ToArray().Join(", "); return CacheControlFilter != null ? CacheControlFilter(cacheControl) : cacheControl; } } public static class HttpCacheExtensions { public static bool Has(this CacheControl cache, CacheControl flag) { return (flag & cache) != 0; } public static void EndNotModified(this IResponse res, string description=null) { res.StatusCode = 304; res.StatusDescription = description ?? HostContext.ResolveLocalizedString(LocalizedStrings.NotModified); res.EndRequest(); } public static bool ETagMatch(this IRequest req, string eTag) { if (string.IsNullOrEmpty(eTag)) return false; return eTag.StripWeakRef().Quoted() == req.Headers[HttpHeaders.IfNoneMatch].StripWeakRef().Quoted(); } public static bool NotModifiedSince(this IRequest req, DateTime? lastModified) { if (lastModified != null) { var ifModifiedSince = req.Headers[HttpHeaders.IfModifiedSince]; if (ifModifiedSince != null) { DateTime modifiedSinceDate; if (DateTime.TryParse(ifModifiedSince, out modifiedSinceDate)) return modifiedSinceDate <= lastModified.Value; } } return false; } public static bool HasValidCache(this IRequest req, string eTag) { return req.ETagMatch(eTag); } public static bool HasValidCache(this IRequest req, DateTime? lastModified) { return req.NotModifiedSince(lastModified); } public static bool HasValidCache(this IRequest req, string eTag, DateTime? lastModified) { return req.ETagMatch(eTag) || req.NotModifiedSince(lastModified); } public static bool ShouldAddLastModifiedToOptimizedResults(this HttpCacheFeature feature) { return feature != null && feature.CacheControlForOptimizedResults != null; } internal static string StripWeakRef(this string eTag) { return eTag != null && eTag.StartsWith("W/") ? eTag.Substring(2) : eTag; } } }