Files
Stationeers-RemoteControl/Vendor/EmbedIO-3.5.2/Files/FileModule.cs

635 lines
28 KiB
C#

using System;
using System.Collections.Concurrent;
using System.IO;
using System.Net;
using System.Threading;
using System.Threading.Tasks;
using EmbedIO.Files.Internal;
using EmbedIO.Internal;
using EmbedIO.Utilities;
namespace EmbedIO.Files
{
/// <summary>
/// A module serving files and directory listings from an <see cref="IFileProvider"/>.
/// </summary>
/// <seealso cref="WebModuleBase" />
public class FileModule : WebModuleBase, IDisposable, IMimeTypeCustomizer
{
/// <summary>
/// <para>Default value for <see cref="DefaultDocument"/>.</para>
/// </summary>
public const string DefaultDocumentName = "index.html";
private readonly string _cacheSectionName = UniqueIdGenerator.GetNext();
private readonly MimeTypeCustomizer _mimeTypeCustomizer = new MimeTypeCustomizer();
private readonly ConcurrentDictionary<string, MappedResourceInfo>? _mappingCache;
private FileCache _cache = FileCache.Default;
private bool _contentCaching = true;
private string? _defaultDocument = DefaultDocumentName;
private string? _defaultExtension;
private IDirectoryLister? _directoryLister;
private FileRequestHandlerCallback _onMappingFailed = FileRequestHandler.ThrowNotFound;
private FileRequestHandlerCallback _onDirectoryNotListable = FileRequestHandler.ThrowUnauthorized;
private FileRequestHandlerCallback _onMethodNotAllowed = FileRequestHandler.ThrowMethodNotAllowed;
private FileCache.Section? _cacheSection;
/// <summary>
/// Initializes a new instance of the <see cref="FileModule"/> class,
/// using the specified cache.
/// </summary>
/// <param name="baseRoute">The base route.</param>
/// <param name="provider">An <see cref="IFileProvider"/> interface that provides access
/// to actual files and directories.</param>
/// <exception cref="ArgumentNullException"><paramref name="provider"/> is <see langword="null"/>.</exception>
public FileModule(string baseRoute, IFileProvider provider)
: base(baseRoute)
{
Provider = Validate.NotNull(nameof(provider), provider);
_mappingCache = Provider.IsImmutable
? new ConcurrentDictionary<string, MappedResourceInfo>()
: null;
}
/// <summary>
/// Finalizes an instance of the <see cref="FileModule"/> class.
/// </summary>
~FileModule()
{
Dispose(false);
}
/// <inheritdoc />
public override bool IsFinalHandler => true;
/// <summary>
/// Gets the <see cref="IFileProvider"/>interface that provides access
/// to actual files and directories served by this module.
/// </summary>
public IFileProvider Provider { get; }
/// <summary>
/// Gets or sets the <see cref="FileCache"/> used by this module to store hashes and,
/// optionally, file contents and rendered directory listings.
/// </summary>
/// <exception cref="InvalidOperationException">The module's configuration is locked.</exception>
/// <exception cref="ArgumentNullException">This property is being set to <see langword="null"/>.</exception>
public FileCache Cache
{
get => _cache;
set
{
EnsureConfigurationNotLocked();
_cache = Validate.NotNull(nameof(value), value);
}
}
/// <summary>
/// <para>Gets or sets a value indicating whether this module caches the contents of files
/// and directory listings.</para>
/// <para>Note that the actual representations of files are stored in <see cref="FileCache"/>;
/// thus, for example, if a file is always requested with an <c>Accept-Encoding</c> of <c>gzip</c>,
/// only the gzipped contents of the file will be cached.</para>
/// </summary>
/// <exception cref="InvalidOperationException">The module's configuration is locked.</exception>
public bool ContentCaching
{
get => _contentCaching;
set
{
EnsureConfigurationNotLocked();
_contentCaching = value;
}
}
/// <summary>
/// <para>Gets or sets the name of the default document served, if it exists, instead of a directory listing
/// when the path of a requested URL maps to a directory.</para>
/// <para>The default value for this property is the <see cref="DefaultDocumentName"/> constant.</para>
/// </summary>
/// <exception cref="InvalidOperationException">The module's configuration is locked.</exception>
public string? DefaultDocument
{
get => _defaultDocument;
set
{
EnsureConfigurationNotLocked();
_defaultDocument = string.IsNullOrEmpty(value) ? null : value;
}
}
/// <summary>
/// <para>Gets or sets the default extension appended to requested URL paths that do not map
/// to any file or directory. Defaults to <see langword="null"/>.</para>
/// </summary>
/// <exception cref="InvalidOperationException">The module's configuration is locked.</exception>
/// <exception cref="ArgumentException">This property is being set to a non-<see langword="null"/>,
/// non-empty string that does not start with a period (<c>.</c>).</exception>
public string? DefaultExtension
{
get => _defaultExtension;
set
{
EnsureConfigurationNotLocked();
if (string.IsNullOrEmpty(value))
{
_defaultExtension = null;
}
else if (value![0] != '.')
{
throw new ArgumentException("Default extension does not start with a period.", nameof(value));
}
else
{
_defaultExtension = value;
}
}
}
/// <summary>
/// <para>Gets or sets the <see cref="IDirectoryLister"/> interface used to generate
/// directory listing in this module.</para>
/// <para>A value of <see langword="null"/> (the default) disables the generation
/// of directory listings.</para>
/// </summary>
/// <exception cref="InvalidOperationException">The module's configuration is locked.</exception>
public IDirectoryLister? DirectoryLister
{
get => _directoryLister;
set
{
EnsureConfigurationNotLocked();
_directoryLister = value;
}
}
/// <summary>
/// <para>Gets or sets a <see cref="FileRequestHandlerCallback"/> that is called whenever
/// the requested URL path could not be mapped to any file or directory.</para>
/// <para>The default is <see cref="FileRequestHandler.ThrowNotFound"/>.</para>
/// </summary>
/// <exception cref="InvalidOperationException">The module's configuration is locked.</exception>
/// <exception cref="ArgumentNullException">This property is being set to <see langword="null"/>.</exception>
/// <seealso cref="FileRequestHandler"/>
public FileRequestHandlerCallback OnMappingFailed
{
get => _onMappingFailed;
set
{
EnsureConfigurationNotLocked();
_onMappingFailed = Validate.NotNull(nameof(value), value);
}
}
/// <summary>
/// <para>Gets or sets a <see cref="FileRequestHandlerCallback"/> that is called whenever
/// the requested URL path has been mapped to a directory, but directory listing has been
/// disabled by setting <see cref="DirectoryLister"/> to <see langword="null"/>.</para>
/// <para>The default is <see cref="FileRequestHandler.ThrowUnauthorized"/>.</para>
/// </summary>
/// <exception cref="InvalidOperationException">The module's configuration is locked.</exception>
/// <exception cref="ArgumentNullException">This property is being set to <see langword="null"/>.</exception>
/// <seealso cref="FileRequestHandler"/>
public FileRequestHandlerCallback OnDirectoryNotListable
{
get => _onDirectoryNotListable;
set
{
EnsureConfigurationNotLocked();
_onDirectoryNotListable = Validate.NotNull(nameof(value), value);
}
}
/// <summary>
/// <para>Gets or sets a <see cref="FileRequestHandlerCallback"/> that is called whenever
/// the requested URL path has been mapped to a file or directory, but the request's
/// HTTP method is neither <c>GET</c> nor <c>HEAD</c>.</para>
/// <para>The default is <see cref="FileRequestHandler.ThrowMethodNotAllowed"/>.</para>
/// </summary>
/// <exception cref="InvalidOperationException">The module's configuration is locked.</exception>
/// <exception cref="ArgumentNullException">This property is being set to <see langword="null"/>.</exception>
/// <seealso cref="FileRequestHandler"/>
public FileRequestHandlerCallback OnMethodNotAllowed
{
get => _onMethodNotAllowed;
set
{
EnsureConfigurationNotLocked();
_onMethodNotAllowed = Validate.NotNull(nameof(value), value);
}
}
/// <inheritdoc />
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
string IMimeTypeProvider.GetMimeType(string extension)
=> _mimeTypeCustomizer.GetMimeType(extension);
bool IMimeTypeProvider.TryDetermineCompression(string mimeType, out bool preferCompression)
=> _mimeTypeCustomizer.TryDetermineCompression(mimeType, out preferCompression);
/// <inheritdoc />
public void AddCustomMimeType(string extension, string mimeType)
=> _mimeTypeCustomizer.AddCustomMimeType(extension, mimeType);
/// <inheritdoc />
public void PreferCompression(string mimeType, bool preferCompression)
=> _mimeTypeCustomizer.PreferCompression(mimeType, preferCompression);
/// <summary>
/// Clears the part of <see cref="Cache"/> used by this module.
/// </summary>
public void ClearCache()
{
_mappingCache?.Clear();
_cacheSection?.Clear();
}
/// <summary>
/// Releases unmanaged and - optionally - managed resources.
/// </summary>
/// <param name="disposing"><see langword="true"/> to release both managed and unmanaged resources;
/// <see langword="false"/> to release only unmanaged resources.</param>
protected virtual void Dispose(bool disposing)
{
if (!disposing)
return;
if (_cacheSection != null)
Provider.ResourceChanged -= _cacheSection.Remove;
if (Provider is IDisposable disposableProvider)
disposableProvider.Dispose();
if (_cacheSection != null)
Cache.RemoveSection(_cacheSectionName);
}
/// <inheritdoc />
protected override void OnBeforeLockConfiguration()
{
base.OnBeforeLockConfiguration();
_mimeTypeCustomizer.Lock();
}
/// <inheritdoc />
protected override void OnStart(CancellationToken cancellationToken)
{
base.OnStart(cancellationToken);
_cacheSection = Cache.AddSection(_cacheSectionName);
Provider.ResourceChanged += _cacheSection.Remove;
Provider.Start(cancellationToken);
}
/// <inheritdoc />
protected override async Task OnRequestAsync(IHttpContext context)
{
MappedResourceInfo? info;
var path = context.RequestedPath;
// Map the URL path to a mapped resource.
// DefaultDocument and DefaultExtension are handled here.
// Use the mapping cache if it exists.
if (_mappingCache == null)
{
info = MapUrlPath(path, context);
}
else if (!_mappingCache.TryGetValue(path, out info))
{
info = MapUrlPath(path, context);
if (info != null)
_ = _mappingCache.AddOrUpdate(path, info, (_, __) => info);
}
if (info == null)
{
// If mapping failed, send a "404 Not Found" response, or whatever OnMappingFailed chooses to do.
// For example, it may return a default resource (think a folder of images and an imageNotFound.jpg),
// or redirect the request.
await OnMappingFailed(context, null).ConfigureAwait(false);
}
else if (!IsHttpMethodAllowed(context.Request, out var sendResponseBody))
{
// If there is a mapped resource, check that the HTTP method is either GET or HEAD.
// Otherwise, send a "405 Method Not Allowed" response, or whatever OnMethodNotAllowed chooses to do.
await OnMethodNotAllowed(context, info).ConfigureAwait(false);
}
else if (info.IsDirectory && DirectoryLister == null)
{
// If a directory listing was requested, but there is no DirectoryLister,
// send a "403 Unauthorized" response, or whatever OnDirectoryNotListable chooses to do.
// For example, one could prefer to send "404 Not Found" instead.
await OnDirectoryNotListable(context, info).ConfigureAwait(false);
}
else
{
await HandleResource(context, info, sendResponseBody).ConfigureAwait(false);
}
}
// Tells whether a request's HTTP method is suitable for processing by FileModule
// and, if so, whether a response body must be sent.
private static bool IsHttpMethodAllowed(IHttpRequest request, out bool sendResponseBody)
{
switch (request.HttpVerb)
{
case HttpVerbs.Head:
sendResponseBody = false;
return true;
case HttpVerbs.Get:
sendResponseBody = true;
return true;
default:
sendResponseBody = default;
return false;
}
}
// Prepares response headers for a "200 OK" or "304 Not Modified" response.
// RFC7232, Section 4.1
private static void PreparePositiveResponse(IHttpResponse response, MappedResourceInfo info, string contentType, string entityTag, Action<IHttpResponse> setCompression)
{
setCompression(response);
response.ContentType = contentType;
response.Headers.Set(HttpHeaderNames.ETag, entityTag);
response.Headers.Set(HttpHeaderNames.LastModified, HttpDate.Format(info.LastModifiedUtc));
response.Headers.Set(HttpHeaderNames.CacheControl, "max-age=0, must-revalidate");
response.Headers.Set(HttpHeaderNames.AcceptRanges, "bytes");
}
// Attempts to map a module-relative URL path to a mapped resource,
// handling DefaultDocument and DefaultExtension.
// Returns null if not found.
// Directories mus be returned regardless of directory listing being enabled.
private MappedResourceInfo? MapUrlPath(string urlPath, IMimeTypeProvider mimeTypeProvider)
{
var result = Provider.MapUrlPath(urlPath, mimeTypeProvider);
// If urlPath maps to a file, no further searching is needed.
if (result?.IsFile ?? false)
return result;
// Look for a default document.
// Don't append an additional slash if the URL path is "/".
// The default document, if found, must be a file, not a directory.
if (DefaultDocument != null)
{
var defaultDocumentPath = urlPath + (urlPath.Length > 1 ? "/" : string.Empty) + DefaultDocument;
var defaultDocumentResult = Provider.MapUrlPath(defaultDocumentPath, mimeTypeProvider);
if (defaultDocumentResult?.IsFile ?? false)
return defaultDocumentResult;
}
// Try to apply default extension (but not if the URL path is "/",
// i.e. the only normalized, non-base URL path that ends in a slash).
// When the default extension is applied, the result must be a file.
if (DefaultExtension != null && urlPath.Length > 1)
{
var defaultExtensionResult = Provider.MapUrlPath(urlPath + DefaultExtension, mimeTypeProvider);
if (defaultExtensionResult?.IsFile ?? false)
return defaultExtensionResult;
}
return result;
}
private async Task HandleResource(IHttpContext context, MappedResourceInfo info, bool sendResponseBody)
{
// Try to extract resource information from cache.
var cachingThreshold = 1024L * Cache.MaxFileSizeKb;
if (!_cacheSection!.TryGet(info.Path, out var cacheItem))
{
// Resource information not yet cached
cacheItem = new FileCacheItem(_cacheSection, info.LastModifiedUtc, info.Length);
_cacheSection.Add(info.Path, cacheItem);
}
else if (!Provider.IsImmutable)
{
// Check whether the resource has changed.
// If so, discard the cache item and create a new one.
if (cacheItem.LastModifiedUtc != info.LastModifiedUtc || cacheItem.Length != info.Length)
{
_cacheSection.Remove(info.Path);
cacheItem = new FileCacheItem(_cacheSection, info.LastModifiedUtc, info.Length);
_cacheSection.Add(info.Path, cacheItem);
}
}
/*
* Now we have a cacheItem for the resource.
* It may have been just created, or it may or may not have a cached content,
* depending upon the value of the ContentCaching property,
* the size of the resource, and the value of the
* MaxFileSizeKb of our Cache.
*/
// If the content type is not a valid MIME type, assume the default.
var contentType = info.ContentType ?? DirectoryLister?.ContentType ?? MimeType.Default;
var mimeType = MimeType.StripParameters(contentType);
if (!MimeType.IsMimeType(mimeType, false))
contentType = mimeType = MimeType.Default;
// Next we're going to apply proactive negotiation
// to determine whether we agree with the client upon the compression
// (or lack of it) to use for the resource.
//
// The combination of partial responses and entity compression
// is not really standardized and could lead to a world of pain.
// Thus, if there is a Range header in the request, try to negotiate for no compression.
// Later, if there is compression anyway, we will ignore the Range header.
if (!context.TryDetermineCompression(mimeType, out var preferCompression))
preferCompression = true;
preferCompression &= context.Request.Headers.Get(HttpHeaderNames.Range) == null;
if (!context.Request.TryNegotiateContentEncoding(preferCompression, out var compressionMethod, out var setCompressionInResponse))
{
// If negotiation failed, the returned callback will do the right thing.
setCompressionInResponse(context.Response);
return;
}
var entityTag = info.GetEntityTag(compressionMethod);
// Send a "304 Not Modified" response if applicable.
//
// RFC7232, Section 3.3: "A recipient MUST ignore If-Modified-Since
// if the request contains an If-None-Match header field."
if (context.Request.CheckIfNoneMatch(entityTag, out var ifNoneMatchExists)
|| (!ifNoneMatchExists && context.Request.CheckIfModifiedSince(info.LastModifiedUtc, out _)))
{
context.Response.StatusCode = (int)HttpStatusCode.NotModified;
PreparePositiveResponse(context.Response, info, contentType, entityTag, setCompressionInResponse);
return;
}
/*
* At this point we know the response is "200 OK",
* unless the request is a range request.
*
* RFC7233, Section 3.1: "The Range header field is evaluated after evaluating the precondition
* header fields defined in RFC7232, and only if the result in absence
* of the Range header field would be a 200 (OK) response. In other
* words, Range is ignored when a conditional GET would result in a 304
* (Not Modified) response."
*/
// Before evaluating ranges, we must know the content length.
// This is easy for files, as it is stored in info.Length.
// Directories always have info.Length == 0; therefore,
// unless the directory listing is cached, we must generate it now
// (and cache it while we're there, if applicable).
var content = cacheItem.GetContent(compressionMethod);
if (info.IsDirectory && content == null)
{
long uncompressedLength;
(content, uncompressedLength) = await GenerateDirectoryListingAsync(context, info, compressionMethod)
.ConfigureAwait(false);
if (ContentCaching && uncompressedLength <= cachingThreshold)
_ = cacheItem.SetContent(compressionMethod, content);
}
var contentLength = content?.Length ?? info.Length;
// Ignore range request is compression is enabled
// (or should I say forced, since negotiation has tried not to use it).
var partialStart = 0L;
var partialUpperBound = contentLength - 1;
var isPartial = compressionMethod == CompressionMethod.None
&& context.Request.IsRangeRequest(contentLength, entityTag, info.LastModifiedUtc, out partialStart, out partialUpperBound);
var responseContentLength = contentLength;
if (isPartial)
{
// Prepare a "206 Partial Content" response.
responseContentLength = partialUpperBound - partialStart + 1;
context.Response.StatusCode = (int)HttpStatusCode.PartialContent;
PreparePositiveResponse(context.Response, info, contentType, entityTag, setCompressionInResponse);
context.Response.Headers.Set(HttpHeaderNames.ContentRange, $"bytes {partialStart}-{partialUpperBound}/{contentLength}");
}
else
{
// Prepare a "200 OK" response.
PreparePositiveResponse(context.Response, info, contentType, entityTag, setCompressionInResponse);
}
// If it's a HEAD request, we're done.
if (!sendResponseBody)
return;
// If content must be sent AND cached, first read it and store it.
// If the requested resource is a directory, we have already listed it by now,
// so it must be a file for content to be null.
if (content == null && ContentCaching && contentLength <= cachingThreshold)
{
using (var memoryStream = new MemoryStream())
{
using (var compressor = new CompressionStream(memoryStream, compressionMethod))
{
using var source = Provider.OpenFile(info.Path);
await source.CopyToAsync(compressor, WebServer.StreamCopyBufferSize, context.CancellationToken)
.ConfigureAwait(false);
}
content = memoryStream.ToArray();
responseContentLength = content.Length;
}
_ = cacheItem.SetContent(compressionMethod, content);
}
// Transfer cached content if present.
if (content != null)
{
context.Response.ContentLength64 = responseContentLength;
var offset = isPartial ? (int) partialStart : 0;
await context.Response.OutputStream.WriteAsync(content, offset, (int)responseContentLength, context.CancellationToken)
.ConfigureAwait(false);
return;
}
// Read and transfer content without caching.
using (var source = Provider.OpenFile(info.Path))
{
context.Response.SendChunked = true;
if (isPartial)
{
var buffer = new byte[WebServer.StreamCopyBufferSize];
if (source.CanSeek)
{
source.Position = partialStart;
}
else
{
var skipLength = (int)partialStart;
while (skipLength > 0)
{
var read = await source.ReadAsync(buffer, 0, Math.Min(skipLength, buffer.Length), context.CancellationToken)
.ConfigureAwait(false);
skipLength -= read;
}
}
var transferSize = responseContentLength;
while (transferSize >= WebServer.StreamCopyBufferSize)
{
var read = await source.ReadAsync(buffer, 0, WebServer.StreamCopyBufferSize, context.CancellationToken)
.ConfigureAwait(false);
await context.Response.OutputStream.WriteAsync(buffer, 0, read, context.CancellationToken)
.ConfigureAwait(false);
transferSize -= read;
}
if (transferSize > 0)
{
var read = await source.ReadAsync(buffer, 0, (int)transferSize, context.CancellationToken)
.ConfigureAwait(false);
await context.Response.OutputStream.WriteAsync(buffer, 0, read, context.CancellationToken)
.ConfigureAwait(false);
}
}
else
{
using var compressor = new CompressionStream(context.Response.OutputStream, compressionMethod);
await source.CopyToAsync(compressor, WebServer.StreamCopyBufferSize, context.CancellationToken)
.ConfigureAwait(false);
}
}
}
// Uses DirectoryLister to generate a directory listing asynchronously.
// Returns a tuple of the generated content and its *uncompressed* length
// (useful to decide whether it can be cached).
private async Task<(byte[], long)> GenerateDirectoryListingAsync(
IHttpContext context,
MappedResourceInfo info,
CompressionMethod compressionMethod)
{
using var memoryStream = new MemoryStream();
using var stream = new CompressionStream(memoryStream, compressionMethod);
await DirectoryLister!.ListDirectoryAsync(
info,
context.Request.Url.AbsolutePath,
Provider.GetDirectoryEntries(info.Path, context),
stream,
context.CancellationToken).ConfigureAwait(false);
return (memoryStream.ToArray(), stream.UncompressedLength);
}
}
}