Got at least one data fetching method working; turns out, we can't use a patched LogicStack to get the data

This commit is contained in:
2026-01-14 22:11:11 +01:00
parent 40a8431464
commit 3f7122d30a
350 changed files with 41444 additions and 119 deletions

View File

@@ -0,0 +1,20 @@
using EmbedIO.Files.Internal;
namespace EmbedIO.Files
{
/// <summary>
/// Provides standard directory listers for <see cref="FileModule"/>.
/// </summary>
/// <seealso cref="IDirectoryLister"/>
public static class DirectoryLister
{
/// <summary>
/// <para>Gets an <see cref="IDirectoryLister"/> interface
/// that produces a HTML listing of a directory.</para>
/// <para>The output of the returned directory lister
/// is the same as a directory listing obtained
/// by EmbedIO version 2.</para>
/// </summary>
public static IDirectoryLister Html => HtmlDirectoryLister.Instance;
}
}

View File

@@ -0,0 +1,185 @@
using System;
using System.Collections.Generic;
using EmbedIO.Files.Internal;
namespace EmbedIO.Files
{
public sealed partial class FileCache
{
internal class Section
{
private readonly object _syncRoot = new object();
private readonly Dictionary<string, FileCacheItem> _items = new Dictionary<string, FileCacheItem>(StringComparer.Ordinal);
private long _totalSize;
private string? _oldestKey;
private string? _newestKey;
public void Clear()
{
lock (_syncRoot)
{
ClearCore();
}
}
public void Add(string path, FileCacheItem item)
{
lock (_syncRoot)
{
AddItemCore(path, item);
}
}
public void Remove(string path)
{
lock (_syncRoot)
{
RemoveItemCore(path);
}
}
public bool TryGet(string path, out FileCacheItem item)
{
lock (_syncRoot)
{
if (!_items.TryGetValue(path, out item))
return false;
RefreshItemCore(path, item);
return true;
}
}
internal long GetLeastRecentUseTime()
{
lock (_syncRoot)
{
return _oldestKey == null ? long.MaxValue : _items[_oldestKey].LastUsedAt;
}
}
// Removes least recently used item.
// Returns size of removed item.
internal long RemoveLeastRecentItem()
{
lock (_syncRoot)
{
return RemoveLeastRecentItemCore();
}
}
internal long GetTotalSize()
{
lock (_syncRoot)
{
return _totalSize;
}
}
internal void UpdateTotalSize(long delta)
{
lock (_syncRoot)
{
_totalSize += delta;
}
}
private void ClearCore()
{
_items.Clear();
_totalSize = 0;
_oldestKey = null;
_newestKey = null;
}
// Adds an item as most recently used.
private void AddItemCore(string path, FileCacheItem item)
{
item.PreviousKey = _newestKey;
item.NextKey = null;
item.LastUsedAt = TimeBase.ElapsedTicks;
if (_newestKey != null)
_items[_newestKey].NextKey = path;
_newestKey = path;
_items[path] = item;
_totalSize += item.SizeInCache;
}
// Removes an item.
private void RemoveItemCore(string path)
{
if (!_items.TryGetValue(path, out var item))
return;
if (_oldestKey == path)
_oldestKey = item.NextKey;
if (_newestKey == path)
_newestKey = item.PreviousKey;
if (item.PreviousKey != null)
_items[item.PreviousKey].NextKey = item.NextKey;
if (item.NextKey != null)
_items[item.NextKey].PreviousKey = item.PreviousKey;
item.PreviousKey = null;
item.NextKey = null;
_items.Remove(path);
_totalSize -= item.SizeInCache;
}
// Removes the least recently used item.
// returns size of removed item.
private long RemoveLeastRecentItemCore()
{
var path = _oldestKey;
if (path == null)
return 0;
var item = _items[path];
if ((_oldestKey = item.NextKey) != null)
_items[_oldestKey].PreviousKey = null;
if (_newestKey == path)
_newestKey = null;
item.PreviousKey = null;
item.NextKey = null;
_items.Remove(path);
_totalSize -= item.SizeInCache;
return item.SizeInCache;
}
// Moves an item to most recently used.
private void RefreshItemCore(string path, FileCacheItem item)
{
item.LastUsedAt = TimeBase.ElapsedTicks;
if (_newestKey == path)
return;
if (_oldestKey == path)
_oldestKey = item.NextKey;
if (item.PreviousKey != null)
_items[item.PreviousKey].NextKey = item.NextKey;
if (item.NextKey != null)
_items[item.NextKey].PreviousKey = item.PreviousKey;
item.PreviousKey = _newestKey;
item.NextKey = null;
_items[_newestKey!].NextKey = path;
_newestKey = path;
}
}
}
}

178
Vendor/EmbedIO-3.5.2/Files/FileCache.cs vendored Normal file
View File

@@ -0,0 +1,178 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using EmbedIO.Internal;
using Swan.Threading;
using Swan.Logging;
namespace EmbedIO.Files
{
#pragma warning disable CA1001 // Type owns disposable field '_cleaner' but is not disposable - _cleaner has its own dispose semantics.
/// <summary>
/// A cache where one or more instances of <see cref="FileModule"/> can store hashes and file contents.
/// </summary>
public sealed partial class FileCache
#pragma warning restore CA1001
{
/// <summary>
/// The default value for the <see cref="MaxSizeKb"/> property.
/// </summary>
public const int DefaultMaxSizeKb = 10240;
/// <summary>
/// The default value for the <see cref="MaxFileSizeKb"/> property.
/// </summary>
public const int DefaultMaxFileSizeKb = 200;
private static readonly Stopwatch TimeBase = Stopwatch.StartNew();
private static readonly object DefaultSyncRoot = new object();
private static FileCache? _defaultInstance;
private readonly ConcurrentDictionary<string, Section> _sections = new ConcurrentDictionary<string, Section>(StringComparer.Ordinal);
private int _sectionCount; // Because ConcurrentDictionary<,>.Count is locking.
private int _maxSizeKb = DefaultMaxSizeKb;
private int _maxFileSizeKb = DefaultMaxFileSizeKb;
private PeriodicTask? _cleaner;
/// <summary>
/// Gets the default <see cref="FileCache"/> instance used by <see cref="FileModule"/>.
/// </summary>
public static FileCache Default
{
get
{
if (_defaultInstance != null)
return _defaultInstance;
lock (DefaultSyncRoot)
{
if (_defaultInstance == null)
_defaultInstance = new FileCache();
}
return _defaultInstance;
}
}
/// <summary>
/// <para>Gets or sets the maximum total size of cached data in kilobytes (1 kilobyte = 1024 bytes).</para>
/// <para>The default value for this property is stored in the <see cref="DefaultMaxSizeKb"/> constant field.</para>
/// <para>Setting this property to a value less lower han 1 has the same effect as setting it to 1.</para>
/// </summary>
public int MaxSizeKb
{
get => _maxSizeKb;
set => _maxSizeKb = Math.Max(value, 1);
}
/// <summary>
/// <para>Gets or sets the maximum size of a single cached file in kilobytes (1 kilobyte = 1024 bytes).</para>
/// <para>A single file's contents may be present in a cache more than once, if the file
/// is requested with different <c>Accept-Encoding</c> request headers. This property acts as a threshold
/// for the uncompressed size of a file.</para>
/// <para>The default value for this property is stored in the <see cref="DefaultMaxFileSizeKb"/> constant field.</para>
/// <para>Setting this property to a value lower than 0 has the same effect as setting it to 0, in fact
/// completely disabling the caching of file contents for this cache.</para>
/// <para>This property cannot be set to a value higher than 2097151; in other words, it is not possible
/// to cache files bigger than two Gigabytes (1 Gigabyte = 1048576 kilobytes) minus 1 kilobyte.</para>
/// </summary>
public int MaxFileSizeKb
{
get => _maxFileSizeKb;
set => _maxFileSizeKb = Math.Min(Math.Max(value, 0), 2097151);
}
// Cast as IDictionary because we WANT an exception to be thrown if the name exists.
// It would mean that something is very, very wrong.
internal Section AddSection(string name)
{
var section = new Section();
(_sections as IDictionary<string, Section>).Add(name, section);
if (Interlocked.Increment(ref _sectionCount) == 1)
_cleaner = new PeriodicTask(TimeSpan.FromMinutes(1), CheckMaxSize);
return section;
}
internal void RemoveSection(string name)
{
_sections.TryRemove(name, out _);
if (Interlocked.Decrement(ref _sectionCount) == 0)
{
_cleaner?.Dispose();
_cleaner = null;
}
}
private async Task CheckMaxSize(CancellationToken cancellationToken)
{
var timeKeeper = new TimeKeeper();
var maxSizeKb = _maxSizeKb;
var initialSizeKb = ComputeTotalSize() / 1024L;
if (initialSizeKb <= maxSizeKb)
{
$"Total size = {initialSizeKb}/{_maxSizeKb}kb, not purging.".Debug(nameof(FileCache));
return;
}
$"Total size = {initialSizeKb}/{_maxSizeKb}kb, purging...".Debug(nameof(FileCache));
var removedCount = 0;
var removedSize = 0L;
var totalSizeKb = initialSizeKb;
var threshold = 973L * maxSizeKb / 1024L; // About 95% of maximum allowed size
while (totalSizeKb > threshold)
{
if (cancellationToken.IsCancellationRequested)
return;
var section = GetSectionWithLeastRecentItem();
if (section == null)
return;
removedSize += section.RemoveLeastRecentItem();
removedCount++;
await Task.Yield();
totalSizeKb = ComputeTotalSize() / 1024L;
}
$"Purge completed in {timeKeeper.ElapsedTime}ms: removed {removedCount} items ({removedSize / 1024L}kb). Total size is now {totalSizeKb}kb."
.Debug(nameof(FileCache));
}
// Enumerate key / value pairs because the Keys and Values property
// of ConcurrentDictionary<,> have snapshot semantics,
// while GetEnumerator enumerates without locking.
private long ComputeTotalSize()
=> _sections.Sum(pair => pair.Value.GetTotalSize());
private Section? GetSectionWithLeastRecentItem()
{
Section? result = null;
var earliestTime = long.MaxValue;
foreach (var pair in _sections)
{
var section = pair.Value;
var time = section.GetLeastRecentUseTime();
if (time < earliestTime)
{
result = section;
earliestTime = time;
}
}
return result;
}
}
}

635
Vendor/EmbedIO-3.5.2/Files/FileModule.cs vendored Normal file
View File

@@ -0,0 +1,635 @@
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);
}
}
}

View File

@@ -0,0 +1,282 @@
using System;
namespace EmbedIO.Files
{
/// <summary>
/// Provides extension methods for <see cref="FileModule"/> and derived classes.
/// </summary>
public static class FileModuleExtensions
{
/// <summary>
/// Sets the <see cref="FileCache"/> used by a module to store hashes and,
/// optionally, file contents and rendered directory listings.
/// </summary>
/// <typeparam name="TModule">The type of the module on which this method is called.</typeparam>
/// <param name="this">The module on which this method is called.</param>
/// <param name="value">An instance of <see cref="FileCache"/>.</param>
/// <returns><paramref name="this"/> with its <see cref="FileModule.Cache">Cache</see> property
/// set to <paramref name="value"/>.</returns>
/// <exception cref="NullReferenceException"><paramref name="this"/> is <see langword="null"/>.</exception>
/// <exception cref="InvalidOperationException">The configuration of <paramref name="this"/> is locked.</exception>
/// <exception cref="ArgumentNullException"><paramref name="value"/> is <see langword="null"/>.</exception>
/// <seealso cref="FileModule.Cache"/>
public static TModule WithCache<TModule>(this TModule @this, FileCache value)
where TModule : FileModule
{
@this.Cache = value;
return @this;
}
/// <summary>
/// Sets a value indicating whether a module caches the contents of files
/// and directory listings.
/// </summary>
/// <typeparam name="TModule">The type of the module on which this method is called.</typeparam>
/// <param name="this">The module on which this method is called.</param>
/// <param name="value"><see langword="true"/> to enable caching of contents;
/// <see langword="false"/> to disable it.</param>
/// <returns><paramref name="this"/> with its <see cref="FileModule.ContentCaching">ContentCaching</see> property
/// set to <paramref name="value"/>.</returns>
/// <exception cref="NullReferenceException"><paramref name="this"/> is <see langword="null"/>.</exception>
/// <exception cref="InvalidOperationException">The configuration of <paramref name="this"/> is locked.</exception>
/// <seealso cref="FileModule.ContentCaching"/>
public static TModule WithContentCaching<TModule>(this TModule @this, bool value)
where TModule : FileModule
{
@this.ContentCaching = value;
return @this;
}
/// <summary>
/// Enables caching of file contents and directory listings on a module.
/// </summary>
/// <typeparam name="TModule">The type of the module on which this method is called.</typeparam>
/// <param name="this">The module on which this method is called.</param>
/// <returns><paramref name="this"/> with its <see cref="FileModule.ContentCaching">ContentCaching</see> property
/// set to <see langword="true"/>.</returns>
/// <exception cref="NullReferenceException"><paramref name="this"/> is <see langword="null"/>.</exception>
/// <exception cref="InvalidOperationException">The configuration of <paramref name="this"/> is locked.</exception>
/// <seealso cref="FileModule.ContentCaching"/>
public static TModule WithContentCaching<TModule>(this TModule @this)
where TModule : FileModule
{
@this.ContentCaching = true;
return @this;
}
/// <summary>
/// Enables caching of file contents and directory listings on a module.
/// </summary>
/// <typeparam name="TModule">The type of the module on which this method is called.</typeparam>
/// <param name="this">The module on which this method is called.</param>
/// <param name="maxFileSizeKb"><see langword="true"/> sets the maximum size of a single cached file in kilobytes</param>
/// <param name="maxSizeKb"><see langword="true"/> sets the maximum total size of cached data in kilobytes</param>
/// <returns><paramref name="this"/> with its <see cref="FileModule.ContentCaching">ContentCaching</see> property
/// set to <see langword="true"/>.</returns>
/// <exception cref="NullReferenceException"><paramref name="this"/> is <see langword="null"/>.</exception>
/// <exception cref="InvalidOperationException">The configuration of <paramref name="this"/> is locked.</exception>
/// <seealso cref="FileModule.ContentCaching"/>
public static TModule WithContentCaching<TModule>(this TModule @this, int maxFileSizeKb, int maxSizeKb)
where TModule : FileModule
{
@this.ContentCaching = true;
@this.Cache.MaxFileSizeKb = maxFileSizeKb;
@this.Cache.MaxSizeKb = maxSizeKb;
return @this;
}
/// <summary>
/// Disables caching of file contents and directory listings on a module.
/// </summary>
/// <typeparam name="TModule">The type of the module on which this method is called.</typeparam>
/// <param name="this">The module on which this method is called.</param>
/// <returns><paramref name="this"/> with its <see cref="FileModule.ContentCaching">ContentCaching</see> property
/// set to <see langword="false"/>.</returns>
/// <exception cref="NullReferenceException"><paramref name="this"/> is <see langword="null"/>.</exception>
/// <exception cref="InvalidOperationException">The configuration of <paramref name="this"/> is locked.</exception>
/// <seealso cref="FileModule.ContentCaching"/>
public static TModule WithoutContentCaching<TModule>(this TModule @this)
where TModule : FileModule
{
@this.ContentCaching = false;
return @this;
}
/// <summary>
/// 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.
/// </summary>
/// <typeparam name="TModule">The type of the module on which this method is called.</typeparam>
/// <param name="this">The module on which this method is called.</param>
/// <param name="value">The name of the default document.</param>
/// <returns><paramref name="this"/> with its <see cref="FileModule.DefaultDocument">DefaultDocument</see> property
/// set to <paramref name="value"/>.</returns>
/// <exception cref="NullReferenceException"><paramref name="this"/> is <see langword="null"/>.</exception>
/// <exception cref="InvalidOperationException">The configuration of <paramref name="this"/> is locked.</exception>
/// <seealso cref="FileModule.DefaultDocument"/>
public static TModule WithDefaultDocument<TModule>(this TModule @this, string value)
where TModule : FileModule
{
@this.DefaultDocument = value;
return @this;
}
/// <summary>
/// Sets the name of the default document to <see langword="null"/>.
/// </summary>
/// <typeparam name="TModule">The type of the module on which this method is called.</typeparam>
/// <param name="this">The module on which this method is called.</param>
/// <returns><paramref name="this"/> with its <see cref="FileModule.DefaultDocument">DefaultDocument</see> property
/// set to <see langword="null"/>.</returns>
/// <exception cref="NullReferenceException"><paramref name="this"/> is <see langword="null"/>.</exception>
/// <exception cref="InvalidOperationException">The configuration of <paramref name="this"/> is locked.</exception>
/// <seealso cref="FileModule.DefaultDocument"/>
public static TModule WithoutDefaultDocument<TModule>(this TModule @this)
where TModule : FileModule
{
@this.DefaultDocument = null;
return @this;
}
/// <summary>
/// Sets the default extension appended to requested URL paths that do not map
/// to any file or directory.
/// </summary>
/// <typeparam name="TModule">The type of the module on which this method is called.</typeparam>
/// <param name="this">The module on which this method is called.</param>
/// <param name="value">The default extension.</param>
/// <returns><paramref name="this"/> with its <see cref="FileModule.DefaultExtension">DefaultExtension</see> property
/// set to <paramref name="value"/>.</returns>
/// <exception cref="NullReferenceException"><paramref name="this"/> is <see langword="null"/>.</exception>
/// <exception cref="InvalidOperationException">The configuration of <paramref name="this"/> is locked.</exception>
/// <exception cref="ArgumentException"><paramref name="value"/> is a non-<see langword="null"/>,
/// non-empty string that does not start with a period (<c>.</c>).</exception>
/// <seealso cref="FileModule.DefaultExtension"/>
public static TModule WithDefaultExtension<TModule>(this TModule @this, string value)
where TModule : FileModule
{
@this.DefaultExtension = value;
return @this;
}
/// <summary>
/// Sets the default extension to <see langword="null"/>.
/// </summary>
/// <typeparam name="TModule">The type of the module on which this method is called.</typeparam>
/// <param name="this">The module on which this method is called.</param>
/// <returns><paramref name="this"/> with its <see cref="FileModule.DefaultExtension">DefaultExtension</see> property
/// set to <see langword="null"/>.</returns>
/// <exception cref="NullReferenceException"><paramref name="this"/> is <see langword="null"/>.</exception>
/// <exception cref="InvalidOperationException">The configuration of <paramref name="this"/> is locked.</exception>
/// <seealso cref="FileModule.DefaultExtension"/>
public static TModule WithoutDefaultExtension<TModule>(this TModule @this)
where TModule : FileModule
{
@this.DefaultExtension = null;
return @this;
}
/// <summary>
/// Sets the <see cref="IDirectoryLister"/> interface used to generate
/// directory listing in a module.
/// </summary>
/// <typeparam name="TModule">The type of the module on which this method is called.</typeparam>
/// <param name="this">The module on which this method is called.</param>
/// <param name="value">An <see cref="IDirectoryLister"/> interface, or <see langword="null"/>
/// to disable the generation of directory listings.</param>
/// <returns><paramref name="this"/> with its <see cref="FileModule.DirectoryLister">DirectoryLister</see> property
/// set to <paramref name="value"/>.</returns>
/// <exception cref="NullReferenceException"><paramref name="this"/> is <see langword="null"/>.</exception>
/// <exception cref="InvalidOperationException">The configuration of <paramref name="this"/> is locked.</exception>
/// <seealso cref="FileModule.DirectoryLister"/>
public static TModule WithDirectoryLister<TModule>(this TModule @this, IDirectoryLister value)
where TModule : FileModule
{
@this.DirectoryLister = value;
return @this;
}
/// <summary>
/// Sets a module's <see cref="FileModule.DirectoryLister">DirectoryLister</see> property
/// to <see langword="null"/>, disabling the generation of directory listings.
/// </summary>
/// <typeparam name="TModule">The type of the module on which this method is called.</typeparam>
/// <param name="this">The module on which this method is called.</param>
/// <returns><paramref name="this"/> with its <see cref="FileModule.DirectoryLister">DirectoryLister</see> property
/// set to <see langword="null"/>.</returns>
/// <exception cref="NullReferenceException"><paramref name="this"/> is <see langword="null"/>.</exception>
/// <exception cref="InvalidOperationException">The configuration of <paramref name="this"/> is locked.</exception>
/// <seealso cref="FileModule.DirectoryLister"/>
public static TModule WithoutDirectoryLister<TModule>(this TModule @this)
where TModule : FileModule
{
@this.DirectoryLister = null;
return @this;
}
/// <summary>
/// Sets a <see cref="FileRequestHandlerCallback"/> that is called by a module whenever
/// the requested URL path could not be mapped to any file or directory.
/// </summary>
/// <typeparam name="TModule">The type of the module on which this method is called.</typeparam>
/// <param name="this">The module on which this method is called.</param>
/// <param name="callback">The method to call.</param>
/// <returns><paramref name="this"/> with its <see cref="FileModule.OnMappingFailed">OnMappingFailed</see> property
/// set to <paramref name="callback"/>.</returns>
/// <exception cref="NullReferenceException"><paramref name="this"/> is <see langword="null"/>.</exception>
/// <exception cref="InvalidOperationException">The configuration of <paramref name="this"/> is locked.</exception>
/// <exception cref="ArgumentNullException"><paramref name="callback"/> is <see langword="null"/>.</exception>
/// <seealso cref="FileModule.OnMappingFailed"/>
/// <seealso cref="FileRequestHandler"/>
public static TModule HandleMappingFailed<TModule>(this TModule @this, FileRequestHandlerCallback callback)
where TModule : FileModule
{
@this.OnMappingFailed = callback;
return @this;
}
/// <summary>
/// Sets a <see cref="FileRequestHandlerCallback"/> that is called by a module whenever
/// the requested URL path has been mapped to a directory, but directory listing has been
/// disabled.
/// </summary>
/// <typeparam name="TModule">The type of the module on which this method is called.</typeparam>
/// <param name="this">The module on which this method is called.</param>
/// <param name="callback">The method to call.</param>
/// <returns><paramref name="this"/> with its <see cref="FileModule.OnDirectoryNotListable">OnDirectoryNotListable</see> property
/// set to <paramref name="callback"/>.</returns>
/// <exception cref="NullReferenceException"><paramref name="this"/> is <see langword="null"/>.</exception>
/// <exception cref="InvalidOperationException">The configuration of <paramref name="this"/> is locked.</exception>
/// <exception cref="ArgumentNullException"><paramref name="callback"/> is <see langword="null"/>.</exception>
/// <seealso cref="FileModule.OnDirectoryNotListable"/>
/// <seealso cref="FileRequestHandler"/>
public static TModule HandleDirectoryNotListable<TModule>(this TModule @this, FileRequestHandlerCallback callback)
where TModule : FileModule
{
@this.OnDirectoryNotListable = callback;
return @this;
}
/// <summary>
/// Sets a <see cref="FileRequestHandlerCallback"/> that is called by a module 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>.
/// </summary>
/// <typeparam name="TModule">The type of the module on which this method is called.</typeparam>
/// <param name="this">The module on which this method is called.</param>
/// <param name="callback">The method to call.</param>
/// <returns><paramref name="this"/> with its <see cref="FileModule.OnMethodNotAllowed">OnMethodNotAllowed</see> property
/// set to <paramref name="callback"/>.</returns>
/// <exception cref="NullReferenceException"><paramref name="this"/> is <see langword="null"/>.</exception>
/// <exception cref="InvalidOperationException">The configuration of <paramref name="this"/> is locked.</exception>
/// <exception cref="ArgumentNullException"><paramref name="callback"/> is <see langword="null"/>.</exception>
/// <seealso cref="FileModule.OnMethodNotAllowed"/>
/// <seealso cref="FileRequestHandler"/>
public static TModule HandleMethodNotAllowed<TModule>(this TModule @this, FileRequestHandlerCallback callback)
where TModule : FileModule
{
@this.OnMethodNotAllowed = callback;
return @this;
}
}
}

View File

@@ -0,0 +1,53 @@
using System.Threading.Tasks;
namespace EmbedIO.Files
{
/// <summary>
/// Provides standard handler callbacks for <see cref="FileModule"/>.
/// </summary>
/// <seealso cref="FileRequestHandlerCallback"/>
public static class FileRequestHandler
{
#pragma warning disable CA1801 // Unused parameters - Must respect FileRequestHandlerCallback signature.
/// <summary>
/// <para>Unconditionally passes a request down the module chain.</para>
/// </summary>
/// <param name="context">An <see cref="IHttpContext"/> interface representing the context of the request.</param>
/// <param name="info">If the requested path has been successfully mapped to a resource (file or directory), the result of the mapping;
/// otherwise, <see langword="null"/>.</param>
/// <returns>This method never returns; it throws an exception instead.</returns>
public static Task PassThrough(IHttpContext context, MappedResourceInfo? info)
=> throw RequestHandler.PassThrough();
/// <summary>
/// <para>Unconditionally sends a <c>403 Unauthorized</c> response.</para>
/// </summary>
/// <param name="context">An <see cref="IHttpContext"/> interface representing the context of the request.</param>
/// <param name="info">If the requested path has been successfully mapped to a resource (file or directory), the result of the mapping;
/// otherwise, <see langword="null"/>.</param>
/// <returns>This method never returns; it throws a <see cref="HttpException"/> instead.</returns>
public static Task ThrowUnauthorized(IHttpContext context, MappedResourceInfo? info)
=> throw HttpException.Unauthorized();
/// <summary>
/// <para>Unconditionally sends a <c>404 Not Found</c> response.</para>
/// </summary>
/// <param name="context">An <see cref="IHttpContext"/> interface representing the context of the request.</param>
/// <param name="info">If the requested path has been successfully mapped to a resource (file or directory), the result of the mapping;
/// otherwise, <see langword="null"/>.</param>
/// <returns>This method never returns; it throws a <see cref="HttpException"/> instead.</returns>
public static Task ThrowNotFound(IHttpContext context, MappedResourceInfo? info)
=> throw HttpException.NotFound();
/// <summary>
/// <para>Unconditionally sends a <c>405 Method Not Allowed</c> response.</para>
/// </summary>
/// <param name="context">An <see cref="IHttpContext"/> interface representing the context of the request.</param>
/// <param name="info">If the requested path has been successfully mapped to a resource (file or directory), the result of the mapping;
/// otherwise, <see langword="null"/>.</param>
/// <returns>This method never returns; it throws a <see cref="HttpException"/> instead.</returns>
public static Task ThrowMethodNotAllowed(IHttpContext context, MappedResourceInfo? info)
=> throw HttpException.MethodNotAllowed();
#pragma warning restore CA1801
}
}

View File

@@ -0,0 +1,13 @@
using System.Threading.Tasks;
namespace EmbedIO.Files
{
/// <summary>
/// A callback used to handle a request in <see cref="FileModule"/>.
/// </summary>
/// <param name="context">An <see cref="IHttpContext"/> interface representing the context of the request.</param>
/// <param name="info">If the requested path has been successfully mapped to a resource (file or directory), the result of the mapping;
/// otherwise, <see langword="null"/>.</param>
/// <returns>A <see cref="Task"/> representing the ongoing operation.</returns>
public delegate Task FileRequestHandlerCallback(IHttpContext context, MappedResourceInfo? info);
}

View File

@@ -0,0 +1,202 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices;
using System.Threading;
using EmbedIO.Utilities;
namespace EmbedIO.Files
{
/// <summary>
/// Provides access to the local file system to a <see cref="FileModule"/>.
/// </summary>
/// <seealso cref="IFileProvider" />
public class FileSystemProvider : IDisposable, IFileProvider
{
private readonly FileSystemWatcher? _watcher;
/// <summary>
/// Initializes a new instance of the <see cref="FileSystemProvider"/> class.
/// </summary>
/// <remarks>
/// OSX doesn't support <see cref="FileSystemWatcher" />, the parameter <paramref name="isImmutable" /> will be always <see langword="true"/>.
/// </remarks>
/// <param name="fileSystemPath">The file system path.</param>
/// <param name="isImmutable"><see langword="true"/> if files and directories in
/// <paramref name="fileSystemPath"/> are not expected to change during a web server's
/// lifetime; <see langword="false"/> otherwise.</param>
/// <exception cref="ArgumentNullException"><paramref name="fileSystemPath"/> is <see langword="null"/>.</exception>
/// <exception cref="ArgumentException"><paramref name="fileSystemPath"/> is not a valid local path.</exception>
/// <seealso cref="Validate.LocalPath"/>
public FileSystemProvider(string fileSystemPath, bool isImmutable)
{
FileSystemPath = Validate.LocalPath(nameof(fileSystemPath), fileSystemPath, true);
IsImmutable = isImmutable || RuntimeInformation.IsOSPlatform(OSPlatform.OSX);
try
{
if (!IsImmutable)
_watcher = new FileSystemWatcher(FileSystemPath);
}
catch (PlatformNotSupportedException)
{
IsImmutable = true;
}
}
/// <summary>
/// Finalizes an instance of the <see cref="FileSystemProvider"/> class.
/// </summary>
~FileSystemProvider()
{
Dispose(false);
}
/// <inheritdoc />
public event Action<string>? ResourceChanged;
/// <summary>
/// Gets the file system path from which files are retrieved.
/// </summary>
public string FileSystemPath { get; }
/// <inheritdoc />
public bool IsImmutable { get; }
/// <inheritdoc />
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
/// <inheritdoc />
public void Start(CancellationToken cancellationToken)
{
if (_watcher != null)
{
_watcher.Changed += Watcher_ChangedOrDeleted;
_watcher.Deleted += Watcher_ChangedOrDeleted;
_watcher.Renamed += Watcher_Renamed;
_watcher.EnableRaisingEvents = true;
}
}
/// <inheritdoc />
public MappedResourceInfo? MapUrlPath(string urlPath, IMimeTypeProvider mimeTypeProvider)
{
urlPath = urlPath.Substring(1); // Drop the initial slash
string localPath;
// Disable CA1031 as there's little we can do if IsPathRooted or GetFullPath fails.
#pragma warning disable CA1031
try
{
// Unescape the url before continue
urlPath = Uri.UnescapeDataString(urlPath);
// Bail out early if the path is a rooted path,
// as Path.Combine would ignore our base path.
// See https://docs.microsoft.com/en-us/dotnet/api/system.io.path.combine
// (particularly the Remarks section).
//
// Under Windows, a relative URL path may be a full filesystem path
// (e.g. "D:\foo\bar" or "\\192.168.0.1\Shared\MyDocuments\BankAccounts.docx").
// Under Unix-like operating systems we have no such problems, as relativeUrlPath
// can never start with a slash; however, loading one more class from Swan
// just to check the OS type would probably outweigh calling IsPathRooted.
if (Path.IsPathRooted(urlPath))
return null;
// Convert the relative URL path to a relative filesystem path
// (practically a no-op under Unix-like operating systems)
// and combine it with our base local path to obtain a full path.
localPath = Path.Combine(FileSystemPath, urlPath.Replace('/', Path.DirectorySeparatorChar));
// Use GetFullPath as an additional safety check
// for relative paths that contain a rooted path
// (e.g. "valid/path/C:\Windows\System.ini")
localPath = Path.GetFullPath(localPath);
}
catch
{
// Both IsPathRooted and GetFullPath throw exceptions
// if a path contains invalid characters or is otherwise invalid;
// bail out in this case too, as the path would not exist on disk anyway.
return null;
}
#pragma warning restore CA1031
// As a final precaution, check that the resulting local path
// is inside the folder intended to be served.
if (!localPath.StartsWith(FileSystemPath, StringComparison.Ordinal))
return null;
if (File.Exists(localPath))
return GetMappedFileInfo(mimeTypeProvider, localPath);
if (Directory.Exists(localPath))
return GetMappedDirectoryInfo(localPath);
return null;
}
/// <inheritdoc />
public Stream OpenFile(string path) => new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
/// <inheritdoc />
public IEnumerable<MappedResourceInfo> GetDirectoryEntries(string path, IMimeTypeProvider mimeTypeProvider)
=> new DirectoryInfo(path).EnumerateFileSystemInfos()
.Select(fsi => GetMappedResourceInfo(mimeTypeProvider, fsi));
/// <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)
{
ResourceChanged = null; // Release references to listeners
if (_watcher != null)
{
_watcher.EnableRaisingEvents = false;
_watcher.Changed -= Watcher_ChangedOrDeleted;
_watcher.Deleted -= Watcher_ChangedOrDeleted;
_watcher.Renamed -= Watcher_Renamed;
if (disposing)
_watcher.Dispose();
}
}
private static MappedResourceInfo GetMappedFileInfo(IMimeTypeProvider mimeTypeProvider, string localPath)
=> GetMappedFileInfo(mimeTypeProvider, new FileInfo(localPath));
private static MappedResourceInfo GetMappedFileInfo(IMimeTypeProvider mimeTypeProvider, FileInfo info)
=> MappedResourceInfo.ForFile(
info.FullName,
info.Name,
info.LastWriteTimeUtc,
info.Length,
mimeTypeProvider.GetMimeType(info.Extension));
private static MappedResourceInfo GetMappedDirectoryInfo(string localPath)
=> GetMappedDirectoryInfo(new DirectoryInfo(localPath));
private static MappedResourceInfo GetMappedDirectoryInfo(DirectoryInfo info)
=> MappedResourceInfo.ForDirectory(info.FullName, info.Name, info.LastWriteTimeUtc);
private static MappedResourceInfo GetMappedResourceInfo(IMimeTypeProvider mimeTypeProvider, FileSystemInfo info)
=> info is DirectoryInfo directoryInfo
? GetMappedDirectoryInfo(directoryInfo)
: GetMappedFileInfo(mimeTypeProvider, (FileInfo) info);
private void Watcher_ChangedOrDeleted(object sender, FileSystemEventArgs e)
=> ResourceChanged?.Invoke(e.FullPath);
private void Watcher_Renamed(object sender, RenamedEventArgs e)
=> ResourceChanged?.Invoke(e.OldFullPath);
}
}

View File

@@ -0,0 +1,35 @@
using System.Collections.Generic;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
namespace EmbedIO.Files
{
/// <summary>
/// Represents an object that can render a directory listing to a stream.
/// </summary>
public interface IDirectoryLister
{
/// <summary>
/// Gets the MIME type of generated directory listings.
/// </summary>
string ContentType { get; }
/// <summary>
/// Asynchronously generate a directory listing.
/// </summary>
/// <param name="info">A <see cref="MappedResourceInfo"/> containing information about
/// the directory which is to be listed.</param>
/// <param name="absoluteUrlPath">The absolute URL path that was mapped to <paramref name="info"/>.</param>
/// <param name="entries">An enumeration of the entries in the directory represented by <paramref name="info"/>.</param>
/// <param name="stream">A <see cref="Stream"/> to which the directory listing must be written.</param>
/// <param name="cancellationToken">A <see cref="CancellationToken"/> used to cancel the operation.</param>
/// <returns>A <see cref="Task"/> representing the ongoing operation.</returns>
Task ListDirectoryAsync(
MappedResourceInfo info,
string absoluteUrlPath,
IEnumerable<MappedResourceInfo> entries,
Stream stream,
CancellationToken cancellationToken);
}
}

View File

@@ -0,0 +1,61 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Threading;
namespace EmbedIO.Files
{
/// <summary>
/// Represents an object that can provide files and/or directories to be served by a <see cref="FileModule"/>.
/// </summary>
public interface IFileProvider
{
/// <summary>
/// <para>Occurs when a file or directory provided by this instance is modified or removed.</para>
/// <para>The event's parameter is the provider-specific path of the resource that changed.</para>
/// </summary>
event Action<string> ResourceChanged;
/// <summary>
/// Gets a value indicating whether the files and directories provided by this instance
/// will never change.
/// </summary>
bool IsImmutable { get; }
/// <summary>
/// Signals a file provider that the web server is starting.
/// </summary>
/// <param name="cancellationToken">A <see cref="CancellationToken"/> used to stop the web server.</param>
void Start(CancellationToken cancellationToken);
/// <summary>
/// Maps a URL path to a provider-specific path.
/// </summary>
/// <param name="urlPath">The URL path.</param>
/// <param name="mimeTypeProvider">An <see cref="IMimeTypeProvider"/> interface to use
/// for determining the MIME type of a file.</param>
/// <returns>A provider-specific path identifying a file or directory,
/// or <see langword="null"/> if this instance cannot provide a resource associated
/// to <paramref name="urlPath"/>.</returns>
MappedResourceInfo? MapUrlPath(string urlPath, IMimeTypeProvider mimeTypeProvider);
/// <summary>
/// Opens a file for reading.
/// </summary>
/// <param name="path">The provider-specific path for the file.</param>
/// <returns>
/// <para>A readable <see cref="Stream"/> of the file's contents.</para>
/// </returns>
Stream OpenFile(string path);
/// <summary>
/// Returns an enumeration of the entries of a directory.
/// </summary>
/// <param name="path">The provider-specific path for the directory.</param>
/// <param name="mimeTypeProvider">An <see cref="IMimeTypeProvider"/> interface to use
/// for determining the MIME type of files.</param>
/// <returns>An enumeration of <see cref="MappedResourceInfo"/> objects identifying the entries
/// in the directory identified by <paramref name="path"/>.</returns>
IEnumerable<MappedResourceInfo> GetDirectoryEntries(string path, IMimeTypeProvider mimeTypeProvider);
}
}

View File

@@ -0,0 +1,12 @@
using System;
namespace EmbedIO.Files.Internal
{
internal static class Base64Utility
{
// long is 8 bytes
// base64 of 8 bytes is 12 chars, but the last one is padding
public static string LongToBase64(long value)
=> Convert.ToBase64String(BitConverter.GetBytes(value)).Substring(0, 11);
}
}

View File

@@ -0,0 +1,28 @@
using System;
using System.Text;
namespace EmbedIO.Files.Internal
{
internal static class EntityTag
{
public static string Compute(DateTime lastModifiedUtc, long length, CompressionMethod compressionMethod)
{
var sb = new StringBuilder()
.Append('"')
.Append(Base64Utility.LongToBase64(lastModifiedUtc.Ticks))
.Append(Base64Utility.LongToBase64(length));
switch (compressionMethod)
{
case CompressionMethod.Deflate:
sb.Append('-').Append(CompressionMethodNames.Deflate);
break;
case CompressionMethod.Gzip:
sb.Append('-').Append(CompressionMethodNames.Gzip);
break;
}
return sb.Append('"').ToString();
}
}
}

View File

@@ -0,0 +1,164 @@
using System;
using EmbedIO.Internal;
namespace EmbedIO.Files.Internal
{
internal sealed class FileCacheItem
{
#pragma warning disable SA1401 // Field should be private - performance is a stronger concern here.
// These fields create a sort of linked list of items
// inside the cache's dictionary.
// Their purpose is to keep track of items
// in order from least to most recently used.
internal string? PreviousKey;
internal string? NextKey;
internal long LastUsedAt;
#pragma warning restore SA1401
// Size of a pointer in bytes
private static readonly long SizeOfPointer = Environment.Is64BitProcess ? 8 : 4;
// Size of a WeakReference<T> in bytes
private static readonly long SizeOfWeakReference = Environment.Is64BitProcess ? 16 : 32;
// Educated guess about the size of an Item in memory (see comments on constructor).
// 3 * SizeOfPointer + total size of fields, rounded up to a multiple of 16.
//
// Computed as follows:
//
// * for 32-bit:
// - initialize count to 3 (number of "hidden" pointers that compose the object header)
// - for every field / auto property, in order of declaration:
// - increment count by 1 for reference types, 2 for long and DateTime
// (as of time of writing there are no fields of other types here)
// - increment again by 1 if this field "weighs" 1 and the next one "weighs" 2
// (padding for field alignment)
// - multiply count by 4 (size of a pointer)
// - if the result is not a multiple of 16, round it up to next multiple of 16
//
// * for 64-bit:
// - initialize count to 3 (number of "hidden" pointers that compose the object header)
// - for every field / auto property, in order of declaration, increment count by 1
// (at the time of writing there are no fields here that need padding on 64-bit)
// - multiply count by 8 (size of a pointer)
// - if the result is not a multiple of 16, round it up to next multiple of 16
private static readonly long SizeOfItem = Environment.Is64BitProcess ? 96 : 128;
private readonly object _syncRoot = new object();
// Used to update total size of section.
// Weak reference avoids circularity.
private readonly WeakReference<FileCache.Section> _section;
// There are only 3 possible compression methods,
// hence a dictionary (or two dictionaries) would be overkill.
private byte[]? _uncompressedContent;
private byte[]? _gzippedContent;
private byte[]? _deflatedContent;
internal FileCacheItem(FileCache.Section section, DateTime lastModifiedUtc, long length)
{
_section = new WeakReference<FileCache.Section>(section);
LastModifiedUtc = lastModifiedUtc;
Length = length;
// There is no way to know the actual size of an object at runtime.
// This method makes some educated guesses, based on the following
// article (among others):
// https://codingsight.com/precise-computation-of-clr-object-size/
// PreviousKey and NextKey values aren't counted in
// because they are just references to existing strings.
SizeInCache = SizeOfItem + SizeOfWeakReference;
}
public DateTime LastModifiedUtc { get; }
public long Length { get; }
// This is the (approximate) in-memory size of this object.
// It is NOT the length of the cache resource!
public long SizeInCache { get; private set; }
public byte[]? GetContent(CompressionMethod compressionMethod)
{
// If there are both entity tag and content, use them.
switch (compressionMethod)
{
case CompressionMethod.Deflate:
if (_deflatedContent != null) return _deflatedContent;
break;
case CompressionMethod.Gzip:
if (_gzippedContent != null) return _gzippedContent;
break;
default:
if (_uncompressedContent != null) return _uncompressedContent;
break;
}
// Try to convert existing content, if any.
byte[]? content;
if (_uncompressedContent != null)
{
content = CompressionUtility.ConvertCompression(_uncompressedContent, CompressionMethod.None, compressionMethod);
}
else if (_gzippedContent != null)
{
content = CompressionUtility.ConvertCompression(_gzippedContent, CompressionMethod.Gzip, compressionMethod);
}
else if (_deflatedContent != null)
{
content = CompressionUtility.ConvertCompression(_deflatedContent, CompressionMethod.Deflate, compressionMethod);
}
else
{
// No content whatsoever.
return null;
}
return SetContent(compressionMethod, content);
}
public byte[]? SetContent(CompressionMethod compressionMethod, byte[]? content)
{
// This is the bare minimum locking we need
// to ensure we don't mess sizes up.
byte[]? oldContent;
lock (_syncRoot)
{
switch (compressionMethod)
{
case CompressionMethod.Deflate:
oldContent = _deflatedContent;
_deflatedContent = content;
break;
case CompressionMethod.Gzip:
oldContent = _gzippedContent;
_gzippedContent = content;
break;
default:
oldContent = _uncompressedContent;
_uncompressedContent = content;
break;
}
}
var sizeDelta = GetSizeOf(content) - GetSizeOf(oldContent);
SizeInCache += sizeDelta;
if (_section.TryGetTarget(out var section))
section.UpdateTotalSize(sizeDelta);
return content;
}
// Round up to a multiple of 16
private static long RoundUpTo16(long n)
{
var remainder = n % 16;
return remainder > 0 ? n + (16 - remainder) : n;
}
// The size of a byte array is 3 * SizeOfPointer + 1 (size of byte) * Length
private static long GetSizeOf(byte[]? arr) => arr == null ? 0 : RoundUpTo16(3 * SizeOfPointer) + arr.Length;
}
}

View File

@@ -0,0 +1,73 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Net;
using System.Threading;
using System.Threading.Tasks;
using EmbedIO.Utilities;
using Swan;
namespace EmbedIO.Files.Internal
{
internal class HtmlDirectoryLister : IDirectoryLister
{
private static readonly Lazy<IDirectoryLister> LazyInstance = new Lazy<IDirectoryLister>(() => new HtmlDirectoryLister());
private HtmlDirectoryLister()
{
}
public static IDirectoryLister Instance => LazyInstance.Value;
public string ContentType { get; } = MimeType.Html + "; encoding=" + WebServer.DefaultEncoding.WebName;
public async Task ListDirectoryAsync(
MappedResourceInfo info,
string absoluteUrlPath,
IEnumerable<MappedResourceInfo> entries,
Stream stream,
CancellationToken cancellationToken)
{
const int MaxEntryLength = 50;
const int SizeIndent = -20; // Negative for right alignment
if (!info.IsDirectory)
throw SelfCheck.Failure($"{nameof(HtmlDirectoryLister)}.{nameof(ListDirectoryAsync)} invoked with a file, not a directory.");
var encodedPath = WebUtility.HtmlEncode(absoluteUrlPath);
using var text = new StreamWriter(stream, WebServer.DefaultEncoding);
text.Write("<html><head><title>Index of ");
text.Write(encodedPath);
text.Write("</title></head><body><h1>Index of ");
text.Write(encodedPath);
text.Write("</h1><hr/><pre>");
if (encodedPath.Length > 1)
text.Write("<a href='../'>../</a>\n");
entries = entries.ToArray();
foreach (var directory in entries.Where(m => m.IsDirectory).OrderBy(e => e.Name))
{
text.Write($"<a href=\"{Uri.EscapeDataString(directory.Name)}\">{WebUtility.HtmlEncode(directory.Name)}</a>");
text.Write(new string(' ', Math.Max(1, MaxEntryLength - directory.Name.Length + 1)));
text.Write(HttpDate.Format(directory.LastModifiedUtc));
text.Write('\n');
await Task.Yield();
}
foreach (var file in entries.Where(m => m.IsFile).OrderBy(e => e.Name))
{
text.Write($"<a href=\"{Uri.EscapeDataString(file.Name)}\">{WebUtility.HtmlEncode(file.Name)}</a>");
text.Write(new string(' ', Math.Max(1, MaxEntryLength - file.Name.Length + 1)));
text.Write(HttpDate.Format(file.LastModifiedUtc));
text.Write($" {file.Length.ToString("#,###", CultureInfo.InvariantCulture),SizeIndent}\n");
await Task.Yield();
}
text.Write("</pre><hr/></body></html>");
}
}
}

View File

@@ -0,0 +1,8 @@
namespace EmbedIO.Files.Internal
{
internal static class MappedResourceInfoExtensions
{
public static string GetEntityTag(this MappedResourceInfo @this, CompressionMethod compressionMethod)
=> EntityTag.Compute(@this.LastModifiedUtc, @this.Length, compressionMethod);
}
}

View File

@@ -0,0 +1,80 @@
using System;
namespace EmbedIO.Files
{
/// <summary>
/// Contains information about a resource served via an <see cref="IFileProvider"/>.
/// </summary>
public sealed class MappedResourceInfo
{
private MappedResourceInfo(string path, string name, DateTime lastModifiedUtc, long length, string? contentType)
{
Path = path;
Name = name;
LastModifiedUtc = lastModifiedUtc;
Length = length;
ContentType = contentType;
}
/// <summary>
/// Gets a value indicating whether this instance represents a directory.
/// </summary>
public bool IsDirectory => ContentType == null;
/// <summary>
/// Gets a value indicating whether this instance represents a file.
/// </summary>
public bool IsFile => ContentType != null;
/// <summary>
/// Gets a unique, provider-specific path for the resource.
/// </summary>
public string Path { get; }
/// <summary>
/// Gets the name of the resource, as it would appear in a directory listing.
/// </summary>
public string Name { get; }
/// <summary>
/// Gets the UTC date and time of the last modification made to the resource.
/// </summary>
public DateTime LastModifiedUtc { get; }
/// <summary>
/// <para>If <see cref="IsDirectory"/> is <see langword="false"/>, gets the length of the file, expressed in bytes.</para>
/// <para>If <see cref="IsDirectory"/> is <see langword="true"/>, this property is always zero.</para>
/// </summary>
public long Length { get; }
/// <summary>
/// <para>If <see cref="IsDirectory"/> is <see langword="false"/>, gets a MIME type describing the kind of contents of the file.</para>
/// <para>If <see cref="IsDirectory"/> is <see langword="true"/>, this property is always <see langword="null"/>.</para>
/// </summary>
public string? ContentType { get; }
/// <summary>
/// Creates and returns a new instance of the <see cref="MappedResourceInfo"/> class,
/// representing a file.
/// </summary>
/// <param name="path">A unique, provider-specific path for the file.</param>
/// <param name="name">The name of the file, as it would appear in a directory listing.</param>
/// <param name="lastModifiedUtc">The UTC date and time of the last modification made to the file.</param>
/// <param name="size">The length of the file, expressed in bytes.</param>
/// <param name="contentType">A MIME type describing the kind of contents of the file.</param>
/// <returns>A newly-constructed instance of <see cref="MappedResourceInfo"/>.</returns>
public static MappedResourceInfo ForFile(string path, string name, DateTime lastModifiedUtc, long size, string contentType)
=> new MappedResourceInfo(path, name, lastModifiedUtc, size, contentType ?? MimeType.Default);
/// <summary>
/// Creates and returns a new instance of the <see cref="MappedResourceInfo"/> class,
/// representing a directory.
/// </summary>
/// <param name="path">A unique, provider-specific path for the directory.</param>
/// <param name="name">The name of the directory, as it would appear in a directory listing.</param>
/// <param name="lastModifiedUtc">The UTC date and time of the last modification made to the directory.</param>
/// <returns>A newly-constructed instance of <see cref="MappedResourceInfo"/>.</returns>
public static MappedResourceInfo ForDirectory(string path, string name, DateTime lastModifiedUtc)
=> new MappedResourceInfo(path, name, lastModifiedUtc, 0, null);
}
}

View File

@@ -0,0 +1,94 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Threading;
using EmbedIO.Utilities;
namespace EmbedIO.Files
{
/// <summary>
/// Provides access to embedded resources to a <see cref="FileModule"/>.
/// </summary>
/// <seealso cref="IFileProvider" />
public class ResourceFileProvider : IFileProvider
{
private readonly DateTime _fileTime = DateTime.UtcNow;
/// <summary>
/// Initializes a new instance of the <see cref="ResourceFileProvider"/> class.
/// </summary>
/// <param name="assembly">The assembly where served files are contained as embedded resources.</param>
/// <param name="pathPrefix">A string to prepend to provider-specific paths
/// to form the name of a manifest resource in <paramref name="assembly"/>.</param>
/// <exception cref="ArgumentNullException"><paramref name="assembly"/> is <see langword="null"/>.</exception>
public ResourceFileProvider(Assembly assembly, string pathPrefix)
{
Assembly = Validate.NotNull(nameof(assembly), assembly);
PathPrefix = pathPrefix ?? string.Empty;
}
/// <inheritdoc />
public event Action<string> ResourceChanged
{
add { }
remove { }
}
/// <summary>
/// Gets the assembly where served files are contained as embedded resources.
/// </summary>
public Assembly Assembly { get; }
/// <summary>
/// Gets a string that is prepended to provider-specific paths to form the name of a manifest resource in <see cref="Assembly"/>.
/// </summary>
public string PathPrefix { get; }
/// <inheritdoc />
public bool IsImmutable => true;
/// <inheritdoc />
public void Start(CancellationToken cancellationToken)
{
}
/// <inheritdoc />
public MappedResourceInfo? MapUrlPath(string urlPath, IMimeTypeProvider mimeTypeProvider)
{
var resourceName = PathPrefix + urlPath.Replace('/', '.');
long size;
try
{
using var stream = Assembly.GetManifestResourceStream(resourceName);
if (stream == null || stream == Stream.Null)
return null;
size = stream.Length;
}
catch (FileNotFoundException)
{
return null;
}
var lastSlashPos = urlPath.LastIndexOf('/');
var name = urlPath.Substring(lastSlashPos + 1);
return MappedResourceInfo.ForFile(
resourceName,
name,
_fileTime,
size,
mimeTypeProvider.GetMimeType(Path.GetExtension(name)));
}
/// <inheritdoc />
public Stream OpenFile(string path) => Assembly.GetManifestResourceStream(path);
/// <inheritdoc />
public IEnumerable<MappedResourceInfo> GetDirectoryEntries(string path, IMimeTypeProvider mimeTypeProvider)
=> Enumerable.Empty<MappedResourceInfo>();
}
}

View File

@@ -0,0 +1,110 @@
using EmbedIO.Utilities;
using System;
using System.Collections.Generic;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Threading;
namespace EmbedIO.Files
{
/// <summary>
/// Provides access to files contained in a <c>.zip</c> file to a <see cref="FileModule"/>.
/// </summary>
/// <seealso cref="IFileProvider" />
public class ZipFileProvider : IDisposable, IFileProvider
{
private readonly ZipArchive _zipArchive;
/// <summary>
/// Initializes a new instance of the <see cref="ZipFileProvider"/> class.
/// </summary>
/// <param name="zipFilePath">The zip file path.</param>
public ZipFileProvider(string zipFilePath)
: this(new FileStream(Validate.LocalPath(nameof(zipFilePath), zipFilePath, true), FileMode.Open))
{
}
/// <summary>
/// Initializes a new instance of the <see cref="ZipFileProvider"/> class.
/// </summary>
/// <param name="stream">The stream that contains the archive.</param>
/// <param name="leaveOpen"><see langword="true"/> to leave the stream open after the web server
/// is disposed; otherwise, <see langword="false"/>.</param>
public ZipFileProvider(Stream stream, bool leaveOpen = false)
{
_zipArchive = new ZipArchive(stream, ZipArchiveMode.Read, leaveOpen);
}
/// <summary>
/// Finalizes an instance of the <see cref="ZipFileProvider"/> class.
/// </summary>
~ZipFileProvider()
{
Dispose(false);
}
/// <inheritdoc />
public event Action<string> ResourceChanged
{
add { }
remove { }
}
/// <inheritdoc />
public bool IsImmutable => true;
/// <inheritdoc />
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
/// <inheritdoc />
public void Start(CancellationToken cancellationToken)
{
}
/// <inheritdoc />
public MappedResourceInfo? MapUrlPath(string urlPath, IMimeTypeProvider mimeTypeProvider)
{
if (urlPath.Length == 1)
return null;
urlPath = Uri.UnescapeDataString(urlPath);
var entry = _zipArchive.GetEntry(urlPath.Substring(1));
if (entry == null)
return null;
return MappedResourceInfo.ForFile(
entry.FullName,
entry.Name,
entry.LastWriteTime.DateTime,
entry.Length,
mimeTypeProvider.GetMimeType(Path.GetExtension(entry.Name)));
}
/// <inheritdoc />
public Stream OpenFile(string path)
=> _zipArchive.GetEntry(path)?.Open() ?? throw new FileNotFoundException($"\"{path}\" cannot be found in Zip archive.");
/// <inheritdoc />
public IEnumerable<MappedResourceInfo> GetDirectoryEntries(string path, IMimeTypeProvider mimeTypeProvider)
=> Enumerable.Empty<MappedResourceInfo>();
/// <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;
_zipArchive.Dispose();
}
}
}