public async Task InvokeAsync(HttpContext context) { var remoteServer = myRemoteServers.LookupRemoteServer(context.Request.Path, out var remainingPath); if (remoteServer == null) { await myNext(context); return; } await myStaticFileMiddleware.Invoke(context); var isHead = context.Request.Method.Equals("HEAD", StringComparison.Ordinal); var isGet = context.Request.Method.Equals("GET", StringComparison.Ordinal); if (!isHead && !isGet) { return; } if (context.Response.StatusCode != StatusCodes.Status404NotFound) { return; } var requestPath = context.Request.Path.ToString().Replace('\\', '/').TrimStart('/'); if (requestPath.Contains("..", StringComparison.Ordinal) || !ourGoodPathChars.IsMatch(requestPath)) { await SetStatus(context, CachingProxyStatus.BAD_REQUEST, HttpStatusCode.BadRequest, "Invalid request path"); return; } var upstreamUri = new Uri(remoteServer.RemoteUri, remainingPath.ToString().TrimStart('/')); if (myBlacklistRegex != null && myBlacklistRegex.IsMatch(requestPath)) { await SetStatus(context, CachingProxyStatus.BLACKLISTED, HttpStatusCode.NotFound, "Blacklisted"); return; } var isRedirectToRemoteUrl = myRedirectToRemoteUrlsRegex != null && myRedirectToRemoteUrlsRegex.IsMatch(requestPath); var emptyFileExtension = Path.GetExtension(requestPath).Length == 0; if (isRedirectToRemoteUrl || emptyFileExtension) { await SetStatus(context, CachingProxyStatus.ALWAYS_REDIRECT, HttpStatusCode.TemporaryRedirect); context.Response.Headers.Add("Location", upstreamUri.ToString()); return; } var cachePath = myCacheFileProvider.GetFileInfo(requestPath).PhysicalPath; if (cachePath == null) { await SetStatus(context, CachingProxyStatus.BAD_REQUEST, HttpStatusCode.BadRequest, "Invalid cache path"); return; } var cachedResponse = myResponseCache.GetCachedStatusCode(requestPath); if (cachedResponse != null && !cachedResponse.StatusCode.IsSuccessStatusCode()) { SetCachedResponseHeader(context, cachedResponse); await SetStatus(context, CachingProxyStatus.NEGATIVE_HIT, HttpStatusCode.NotFound); return; } // Positive caching for GET handled in static files // We handle positive caching for HEAD here if (cachedResponse != null && cachedResponse.StatusCode.IsSuccessStatusCode() && isHead) { SetCachedResponseHeader(context, cachedResponse); await SetStatus(context, CachingProxyStatus.HIT, HttpStatusCode.OK); return; } myLogger.LogDebug("Downloading from {0}", upstreamUri); var request = new HttpRequestMessage(isHead ? HttpMethod.Head : HttpMethod.Get, upstreamUri); HttpResponseMessage response; try { response = await myHttpClient.Client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, context.RequestAborted); } catch (OperationCanceledException canceledException) { if (context.RequestAborted == canceledException.CancellationToken) { return; } // Canceled by internal token, means timeout myLogger.LogWarning($"Timeout requesting {upstreamUri}"); var entry = myResponseCache.PutStatusCode(requestPath, HttpStatusCode.GatewayTimeout); SetCachedResponseHeader(context, entry); await SetStatus(context, CachingProxyStatus.NEGATIVE_MISS, HttpStatusCode.NotFound); return; } catch (Exception e) { myLogger.LogWarning(e, $"Exception requesting {upstreamUri}: {e.Message}"); var entry = myResponseCache.PutStatusCode(requestPath, HttpStatusCode.ServiceUnavailable); SetCachedResponseHeader(context, entry); await SetStatus(context, CachingProxyStatus.NEGATIVE_MISS, HttpStatusCode.NotFound); return; } using (response) { if (!response.IsSuccessStatusCode) { var entry = myResponseCache.PutStatusCode(requestPath, response.StatusCode); SetCachedResponseHeader(context, entry); await SetStatus(context, CachingProxyStatus.NEGATIVE_MISS, HttpStatusCode.NotFound); return; } if (response.IsSuccessStatusCode && isHead) { var entry = myResponseCache.PutStatusCode(requestPath, response.StatusCode); SetCachedResponseHeader(context, entry); await SetStatus(context, CachingProxyStatus.MISS, HttpStatusCode.OK); return; } var contentLength = response.Content.Headers.ContentLength; context.Response.ContentLength = contentLength; if (myContentTypeProvider.TryGetContentType(requestPath, out var contentType)) { context.Response.ContentType = contentType; } await SetStatus(context, CachingProxyStatus.MISS, HttpStatusCode.OK); // Cache successful responses indefinitely // as we assume content won't be changed under a fixed url AddEternalCachingControl(context); var tempFile = cachePath + ".tmp." + Guid.NewGuid(); try { var parent = Directory.GetParent(cachePath); Directory.CreateDirectory(parent.FullName); using (var stream = new FileStream( tempFile, FileMode.CreateNew, FileAccess.Write, FileShare.None, BUFFER_SIZE, FileOptions.Asynchronous)) { using (var sourceStream = await response.Content.ReadAsStreamAsync()) await CopyToTwoStreamsAsync(sourceStream, context.Response.Body, stream, context.RequestAborted); } var tempFileInfo = new FileInfo(tempFile); if (contentLength != null && tempFileInfo.Length != contentLength) { myLogger.LogWarning($"Expected {contentLength} bytes from Content-Length, but downloaded {tempFileInfo.Length}: {upstreamUri}"); context.Abort(); return; } try { File.Move(tempFile, cachePath); } catch (IOException) { if (File.Exists(cachePath)) { // It's ok, parallel request cached it before us } else { throw; } } } catch (OperationCanceledException) { // Probable cause: OperationCanceledException from http client myHttpClient // Probable cause: OperationCanceledException from this service's client (context.RequestAborted) // ref: https://github.com/aspnet/StaticFiles/commit/bbf1478821c11ecdcad776dad085d6ee09d8f8ee#diff-991aec26255237cd6dbfa787d0995a2aR85 // ref: https://github.com/aspnet/StaticFiles/issues/150 // Don't throw this exception, it's most likely caused by the client disconnecting. // However, if it was cancelled for any other reason we need to prevent empty responses. context.Abort(); } finally { CatchSilently(() => { if (File.Exists(tempFile)) { File.Delete(tempFile); } }); } } }
public async Task InvokeAsync(HttpContext context) { if (context.Request.Path == "/health") { var availableFreeSpaceMb = new DriveInfo(myLocalCachePath).AvailableFreeSpace / (1024 * 1024); if (availableFreeSpaceMb < myMinimumFreeDiskSpaceMb) { context.Response.StatusCode = 500; await context.Response.WriteAsync($"Not Enough Free Disk Space. {availableFreeSpaceMb} MB is free at {myLocalCachePath}, " + $"but minimum is {myMinimumFreeDiskSpaceMb} MB"); return; } context.Response.StatusCode = 200; await context.Response.WriteAsync("OK"); return; } var remoteServer = myRemoteServers.LookupRemoteServer(context.Request.Path, out var remainingPath); if (remoteServer == null) { await myNext(context); return; } await myStaticFileMiddleware.Invoke(context); var isHead = context.Request.Method.Equals("HEAD", StringComparison.Ordinal); var isGet = context.Request.Method.Equals("GET", StringComparison.Ordinal); if (!isHead && !isGet) { return; } if (context.Response.StatusCode != StatusCodes.Status404NotFound) { return; } var requestPath = context.Request.Path.ToString().Replace('\\', '/').TrimStart('/'); if (requestPath.Contains("..", StringComparison.Ordinal) || !ourGoodPathChars.IsMatch(requestPath)) { await SetStatus(context, CachingProxyStatus.BAD_REQUEST, HttpStatusCode.BadRequest, "Invalid request path"); return; } var upstreamUri = new Uri(remoteServer.RemoteUri, remainingPath.ToString().TrimStart('/')); if (myBlacklistRegex != null && myBlacklistRegex.IsMatch(requestPath)) { await SetStatus(context, CachingProxyStatus.BLACKLISTED, HttpStatusCode.NotFound, "Blacklisted"); return; } var isRedirectToRemoteUrl = myRedirectToRemoteUrlsRegex != null && myRedirectToRemoteUrlsRegex.IsMatch(requestPath); var requestPathExtension = Path.GetExtension(requestPath); var emptyFileExtension = requestPathExtension.Length == 0; if (isRedirectToRemoteUrl || emptyFileExtension) { await SetStatus(context, CachingProxyStatus.ALWAYS_REDIRECT, HttpStatusCode.TemporaryRedirect); context.Response.Headers.Add("Location", upstreamUri.ToString()); return; } var cachePath = myCacheFileProvider.GetFileInfo(requestPath).PhysicalPath; if (cachePath == null) { await SetStatus(context, CachingProxyStatus.BAD_REQUEST, HttpStatusCode.BadRequest, "Invalid cache path"); return; } var cachedResponse = myResponseCache.GetCachedStatusCode(requestPath); if (cachedResponse != null && !cachedResponse.StatusCode.IsSuccessStatusCode()) { SetCachedResponseHeader(context, cachedResponse); await SetStatus(context, CachingProxyStatus.NEGATIVE_HIT, HttpStatusCode.NotFound); return; } // Positive caching for GET handled in static files // We handle positive caching for HEAD here if (cachedResponse != null && cachedResponse.StatusCode.IsSuccessStatusCode() && isHead) { var responseHeaders = context.Response.GetTypedHeaders(); responseHeaders.LastModified = cachedResponse.LastModified; responseHeaders.ContentLength = cachedResponse.ContentLength; context.Response.Headers[HeaderNames.ContentType] = cachedResponse.ContentType; SetCachedResponseHeader(context, cachedResponse); await SetStatus(context, CachingProxyStatus.HIT, HttpStatusCode.OK); return; } myLogger.LogDebug("Downloading from {0}", upstreamUri); var request = new HttpRequestMessage(isHead ? HttpMethod.Head : HttpMethod.Get, upstreamUri); HttpResponseMessage response; try { response = await myHttpClient.Client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, context.RequestAborted); } catch (OperationCanceledException canceledException) { if (context.RequestAborted == canceledException.CancellationToken) { return; } // Canceled by internal token, means timeout myLogger.LogWarning($"Timeout requesting {upstreamUri}"); var entry = myResponseCache.PutStatusCode(requestPath, HttpStatusCode.GatewayTimeout, lastModified: null, contentType: null, contentLength: null); SetCachedResponseHeader(context, entry); await SetStatus(context, CachingProxyStatus.NEGATIVE_MISS, HttpStatusCode.NotFound); return; } catch (Exception e) { myLogger.LogWarning(e, $"Exception requesting {upstreamUri}: {e.Message}"); var entry = myResponseCache.PutStatusCode(requestPath, HttpStatusCode.ServiceUnavailable, lastModified: null, contentType: null, contentLength: null); SetCachedResponseHeader(context, entry); await SetStatus(context, CachingProxyStatus.NEGATIVE_MISS, HttpStatusCode.NotFound); return; } using (response) { if (!response.IsSuccessStatusCode) { var entry = myResponseCache.PutStatusCode(requestPath, response.StatusCode, lastModified: null, contentType: null, contentLength: null); SetCachedResponseHeader(context, entry); await SetStatus(context, CachingProxyStatus.NEGATIVE_MISS, HttpStatusCode.NotFound); return; } // If content type validation is enabled, only .html, .htm and .txt files may have text/* content type // This prevents e.g. caching of error pages with 200 OK code (jcenter) var responseContentType = response.Content.Headers.ContentType?.MediaType; if (requestPathExtension != ".html" && requestPathExtension != ".txt" && requestPathExtension != ".htm") { if (responseContentType is MediaTypeNames.Text.Html or MediaTypeNames.Text.Plain) { myLogger.LogWarning($"{upstreamUri} returned content type '{responseContentType}' which is possibly wrong for file extension '{requestPathExtension}'"); if (remoteServer.ValidateContentTypes) { // return 503 Service Unavailable, since the client will most likely retry it with 5xx error codes context.Response.StatusCode = (int)HttpStatusCode.ServiceUnavailable; context.Response.ContentType = MediaTypeNames.Text.Plain; await context.Response.WriteAsync( $"{upstreamUri} returned content type '{responseContentType}' which is forbidden by content type validation for file extension '{requestPathExtension}'"); return; } } } var contentLength = response.Content.Headers.ContentLength; context.Response.ContentLength = contentLength; var contentLastModified = response.Content.Headers.LastModified; if (contentLastModified != null) { context.Response.GetTypedHeaders().LastModified = contentLastModified; } if (myContentTypeProvider.TryGetContentType(requestPath, out var contentType)) { context.Response.ContentType = contentType; } if (isHead) { var entry = myResponseCache.PutStatusCode( requestPath, response.StatusCode, lastModified: contentLastModified, contentType: contentType, contentLength: contentLength); SetCachedResponseHeader(context, entry); await SetStatus(context, CachingProxyStatus.MISS, HttpStatusCode.OK); return; } await SetStatus(context, CachingProxyStatus.MISS, HttpStatusCode.OK); // Cache successful responses indefinitely // as we assume content won't be changed under a fixed url AddEternalCachingControl(context); var tempFile = cachePath + ".tmp." + Guid.NewGuid(); try { var parent = Directory.GetParent(cachePath); Directory.CreateDirectory(parent !.FullName); await using (var stream = new FileStream( tempFile, FileMode.CreateNew, FileAccess.Write, FileShare.None, BUFFER_SIZE, FileOptions.Asynchronous)) { await using (var sourceStream = await response.Content.ReadAsStreamAsync()) await CopyToTwoStreamsAsync(sourceStream, context.Response.Body, stream, context.RequestAborted); } var tempFileInfo = new FileInfo(tempFile); if (contentLength != null && tempFileInfo.Length != contentLength) { myLogger.LogWarning($"Expected {contentLength} bytes from Content-Length, but downloaded {tempFileInfo.Length}: {upstreamUri}"); context.Abort(); return; } if (contentLastModified.HasValue) { File.SetLastWriteTimeUtc(tempFile, contentLastModified.Value.UtcDateTime); } try { File.Move(tempFile, cachePath); } catch (IOException) { if (File.Exists(cachePath)) { // It's ok, parallel request cached it before us } else { throw; } } } catch (OperationCanceledException) { // Probable cause: OperationCanceledException from http client myHttpClient // Probable cause: OperationCanceledException from this service's client (context.RequestAborted) // ref: https://github.com/aspnet/StaticFiles/commit/bbf1478821c11ecdcad776dad085d6ee09d8f8ee#diff-991aec26255237cd6dbfa787d0995a2aR85 // ref: https://github.com/aspnet/StaticFiles/issues/150 // Don't throw this exception, it's most likely caused by the client disconnecting. // However, if it was cancelled for any other reason we need to prevent empty responses. context.Abort(); } finally { CatchSilently(() => { if (File.Exists(tempFile)) { File.Delete(tempFile); } }); } } }