//******************************************************************************************************
// WebServer.cs - Gbtc
//
// Copyright © 2016, Grid Protection Alliance. All Rights Reserved.
//
// Licensed to the Grid Protection Alliance (GPA) under one or more contributor license agreements. See
// the NOTICE file distributed with this work for additional information regarding copyright ownership.
// The GPA licenses this file to you under the MIT License (MIT), the "License"; you may not use this
// file except in compliance with the License. You may obtain a copy of the License at:
//
// http://opensource.org/licenses/MIT
//
// Unless agreed to in writing, the subject software distributed under the License is distributed on an
// "AS-IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. Refer to the
// License for the specific language governing permissions and limitations.
//
// Code Modification History:
// ----------------------------------------------------------------------------------------------------
// 05/10/2016 - J. Ritchie Carroll
// Generated original version of source code.
//
//******************************************************************************************************
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Runtime.CompilerServices;
using System.Security;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using System.Web;
using GSF.Collections;
using GSF.Configuration;
using GSF.Data;
using GSF.IO;
using GSF.IO.Checksums;
using GSF.Reflection;
using GSF.Web.Model;
using GSF.Web.Security;
using Microsoft.Ajax.Utilities;
#pragma warning disable SG0018 // Path traversal prevented by web api controller
namespace GSF.Web.Hosting
{
///
/// Represents the web server engine for instances.
///
public class WebServer
{
#region [ Members ]
// Events
///
/// Exposes execution exceptions that occur in instances.
///
public event EventHandler> ExecutionException;
///
/// Exposes status messages that get reported from instances.
///
public event EventHandler> StatusMessage;
// Fields
private readonly WebServerOptions m_options;
private readonly bool m_releaseMode;
private readonly ConcurrentDictionary m_etagCache;
private readonly ConcurrentDictionary m_handlerTypeCache;
private readonly SafeFileWatcher m_fileWatcher;
private bool m_disposed;
#endregion
#region [ Constructors ]
///
/// Creates a new .
///
/// Web server options to use; set to null for defaults.
/// Razor engine instance for .cshtml templates; uses default instance if not provided.
/// Razor engine instance for .vbhtml templates; uses default instance if not provided.
public WebServer(WebServerOptions options = null, IRazorEngine razorEngineCS = null, IRazorEngine razorEngineVB = null)
{
m_releaseMode = !AssemblyInfo.EntryAssembly.Debuggable;
if (options is null)
options = new WebServerOptions();
m_options = options;
RazorEngineCS = razorEngineCS ?? (m_releaseMode ? RazorEngine.Default : RazorEngine.Default as IRazorEngine);
RazorEngineVB = razorEngineVB ?? (m_releaseMode ? RazorEngine.Default : RazorEngine.Default as IRazorEngine);
PagedViewModelTypes = new ConcurrentDictionary>(StringComparer.InvariantCultureIgnoreCase);
m_etagCache = new ConcurrentDictionary(StringComparer.InvariantCultureIgnoreCase);
m_handlerTypeCache = new ConcurrentDictionary(StringComparer.InvariantCultureIgnoreCase);
// Validate web root path
m_options.WebRootPath = FilePath.AddPathSuffix(m_options.WebRootPath ?? RazorEngineCS.TemplatePath);
m_fileWatcher = new SafeFileWatcher(m_options.WebRootPath)
{
IncludeSubdirectories = true,
EnableRaisingEvents = true
};
m_fileWatcher.Changed += m_fileWatcher_FileChange;
m_fileWatcher.Deleted += m_fileWatcher_FileChange;
m_fileWatcher.Renamed += m_fileWatcher_FileChange;
}
#endregion
#region [ Properties ]
///
/// Gets options in use for this .
///
public ReadonlyWebServerOptions Options => m_options.Readonly;
///
/// Gets the C# Razor engine instance used by this .
///
public IRazorEngine RazorEngineCS { get; }
///
/// Gets the Visual Basic Razor engine instance used by this .
///
public IRazorEngine RazorEngineVB { get; }
///
/// Defines associated page view model types and data hub types for Razor pages, if any.
///
///
/// This dictionary associates Razor views based on a paged view model with associated values.
///
public ConcurrentDictionary> PagedViewModelTypes { get; }
#endregion
#region [ Methods ]
///
/// Releases all the resources used by the object.
///
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
///
/// Releases the unmanaged resources used by the object and optionally releases the managed resources.
///
/// true to release both managed and unmanaged resources; false to release only unmanaged resources.
protected virtual void Dispose(bool disposing)
{
if (m_disposed)
return;
try
{
if (disposing)
m_fileWatcher?.Dispose();
}
finally
{
m_disposed = true; // Prevent duplicate dispose.
}
}
///
/// Renders an HTTP response for a given request.
///
/// HTTP request message.
/// Name of page to render.
/// Propagates notification from client that operations should be canceled.
/// Reference to model to use when rendering Razor templates, if any.
/// Type of , if any.
/// to use, if any.
/// HTTP response for provided request.
public async Task RenderResponse(HttpRequestMessage request, string pageName, CancellationToken cancellationToken, object model = null, Type modelType = null, AdoDataConnection database = null)
{
HttpResponseMessage response = new HttpResponseMessage(HttpStatusCode.OK);
string content, fileExtension = FilePath.GetExtension(pageName).ToLowerInvariant();
bool embeddedResource = pageName.StartsWith("@");
Tuple pagedViewModelTypes;
if (embeddedResource)
pageName = pageName.Substring(1).Replace('/', '.');
response.RequestMessage = request;
if (pageName.Equals(m_options.AuthTestPage, StringComparison.OrdinalIgnoreCase))
return CreateAuthTestResponse(request, response);
switch (fileExtension)
{
case ".cshtml":
PagedViewModelTypes.TryGetValue(pageName, out pagedViewModelTypes);
content = await new RazorView(embeddedResource ? RazorEngine.Default : RazorEngineCS, pageName, model, modelType, pagedViewModelTypes?.Item1, pagedViewModelTypes?.Item2, database, OnExecutionException, Options).ExecuteAsync(request, response, cancellationToken);
response.Content = new StringContent(content, Encoding.UTF8, "text/html");
break;
case ".vbhtml":
PagedViewModelTypes.TryGetValue(pageName, out pagedViewModelTypes);
content = await new RazorView(embeddedResource ? RazorEngine.Default : RazorEngineVB, pageName, model, modelType, pagedViewModelTypes?.Item1, pagedViewModelTypes?.Item2, database, OnExecutionException, Options).ExecuteAsync(request, response, cancellationToken);
response.Content = new StringContent(content, Encoding.UTF8, "text/html");
break;
case ".ashx":
await ProcessHTTPHandlerAsync(pageName, embeddedResource, request, response, cancellationToken);
break;
default:
string fileName = GetResourceFileName(pageName, embeddedResource);
if (m_options.ClientCacheEnabled)
{
if (!m_etagCache.TryGetValue(fileName, out long responseHash))
{
if (!ResourceExists(fileName, embeddedResource))
{
response.StatusCode = HttpStatusCode.NotFound;
break;
}
await Task.Run(async () =>
{
using Stream source = await OpenResourceAsync(pageName, fileName, embeddedResource);
// Calculate check-sum for file
const int BufferSize = 32768;
byte[] buffer = new byte[BufferSize];
Crc32 calculatedHash = new Crc32();
int bytesRead = await source.ReadAsync(buffer, 0, BufferSize, cancellationToken);
while (bytesRead > 0)
{
calculatedHash.Update(buffer, 0, bytesRead);
bytesRead = await source.ReadAsync(buffer, 0, BufferSize, cancellationToken);
}
responseHash = calculatedHash.Value;
m_etagCache.TryAdd(fileName, responseHash);
OnStatusMessage($"Cache [{responseHash}] added for file \"{fileName}\"");
}, cancellationToken);
}
if (PublishResponseContent(request, response, responseHash))
{
response.Content = new StreamContent(await OpenResourceAsync(pageName, fileName, embeddedResource));
response.Content.Headers.ContentType = new MediaTypeHeaderValue(MimeMapping.GetMimeMapping(pageName));
}
}
else
{
if (!ResourceExists(fileName, embeddedResource))
{
response.StatusCode = HttpStatusCode.NotFound;
break;
}
response.Content = new StreamContent(await OpenResourceAsync(pageName, fileName, embeddedResource));
response.Content.Headers.ContentType = new MediaTypeHeaderValue(MimeMapping.GetMimeMapping(pageName));
}
break;
}
return response;
}
private async Task ProcessHTTPHandlerAsync(string pageName, bool embeddedResource, HttpRequestMessage request, HttpResponseMessage response, CancellationToken cancellationToken)
{
string fileName = GetResourceFileName(pageName, embeddedResource);
if (!m_handlerTypeCache.TryGetValue(fileName, out Type handlerType))
{
if (!ResourceExists(fileName, embeddedResource))
{
response.StatusCode = HttpStatusCode.NotFound;
return;
}
using Stream source = await OpenResourceAsync(pageName, fileName, embeddedResource);
string handlerHeader;
// Parse class name from ASHX handler header parameters
using (StreamReader reader = new StreamReader(source))
handlerHeader = (await reader.ReadToEndAsync()).RemoveCrLfs().Trim();
// Clean up header formatting to make parsing easier
handlerHeader = handlerHeader.RemoveDuplicateWhiteSpace().Replace(" =", "=").Replace("= ", "=");
string[] tokens = handlerHeader.Split(' ');
if (!tokens.Any(token => token.Equals("WebHandler", StringComparison.OrdinalIgnoreCase)))
throw new InvalidOperationException($"Expected \"WebHandler\" file type not found in ASHX file header: {handlerHeader}");
Dictionary parameters = handlerHeader.ReplaceCaseInsensitive("WebHandler", "").Replace("<%", "").Replace("%>", "").Replace("@", "").Trim().ParseKeyValuePairs(' ');
if (!parameters.TryGetValue("Class", out string className))
throw new InvalidOperationException($"Missing \"Class\" parameter in ASHX file header: {handlerHeader}");
// Remove quotes from class name
className = className.Substring(1, className.Length - 2).Trim();
handlerType = AssemblyInfo.FindType(className);
if (m_handlerTypeCache.TryAdd(fileName, handlerType))
OnStatusMessage($"Cached handler type [{handlerType?.FullName}] for file \"{fileName}\"");
}
IHostedHttpHandler handler = null;
if (!(handlerType is null))
handler = Activator.CreateInstance(handlerType) as IHostedHttpHandler;
if (handler is null)
throw new InvalidOperationException($"Failed to create hosted HTTP handler \"{handlerType?.FullName}\" - make sure class implements IHostedHttpHandler interface.");
if (m_options.ClientCacheEnabled && handler.UseClientCache)
{
if (PublishResponseContent(request, response, handler.GetContentHash(request)))
await handler.ProcessRequestAsync(request, response, cancellationToken);
}
else
{
await handler.ProcessRequestAsync(request, response, cancellationToken);
}
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private string GetResourceFileName(string pageName, bool embeddedResource) =>
embeddedResource ? pageName : $"{m_options.PhysicalWebRootPath}{pageName.Replace('/', Path.DirectorySeparatorChar)}";
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private async Task OpenResourceAsync(string pageName, string fileName, bool embeddedResource)
{
Stream stream = embeddedResource ? WebExtensions.OpenEmbeddedResourceStream(fileName) : File.OpenRead(fileName);
if (!(m_releaseMode || m_options.UseMinifyInDebug) || !(m_options.MinifyJavascript || m_options.MinifyStyleSheets))
return stream;
string extension = FilePath.GetExtension(fileName).Trim().ToLowerInvariant();
if (string.IsNullOrWhiteSpace(extension))
return stream;
Stream minimizedStream = null;
switch (extension)
{
case ".js":
if (m_options.MinifyJavascriptResource(pageName))
{
using StreamReader reader = new StreamReader(stream);
minimizedStream = await new Minifier().MinifyJavaScript(await reader.ReadToEndAsync()).ToStreamAsync();
}
break;
case ".css":
if (m_options.MinifyStyleSheetResource(pageName))
{
using StreamReader reader = new StreamReader(stream);
minimizedStream = await new Minifier().MinifyStyleSheet(await reader.ReadToEndAsync()).ToStreamAsync();
}
break;
}
return minimizedStream ?? stream;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private bool PublishResponseContent(HttpRequestMessage request, HttpResponseMessage response, long responseHash)
{
if (!m_options.ClientCacheEnabled)
return true;
// See if client's version of cached resource is up to date
foreach (EntityTagHeaderValue headerValue in request.Headers.IfNoneMatch)
{
if (long.TryParse(headerValue.Tag?.Substring(1, headerValue.Tag.Length - 2), out long requestHash) && responseHash == requestHash)
{
response.StatusCode = HttpStatusCode.NotModified;
return false;
}
}
response.Headers.CacheControl = new CacheControlHeaderValue
{
NoCache = true,
MustRevalidate = true
};
response.Headers.ETag = new EntityTagHeaderValue($"\"{responseHash}\"");
return true;
}
private void OnExecutionException(Exception exception) =>
ExecutionException?.Invoke(this, new EventArgs(exception));
private void OnStatusMessage(string message) =>
StatusMessage?.Invoke(this, new EventArgs(message));
private void m_fileWatcher_FileChange(object sender, FileSystemEventArgs e)
{
if (m_etagCache.TryRemove(e.FullPath, out long responseHash))
OnStatusMessage($"Cache [{responseHash}] cleared for file \"{e.FullPath}\"");
if (m_handlerTypeCache.TryRemove(e.FullPath, out Type handlerType))
OnStatusMessage($"Cleared handler type [{handlerType?.FullName}] from cache for file \"{e.FullPath}\"");
}
#endregion
#region [ Static ]
// Static Fields
private static WebServer s_defaultServer;
private static readonly Dictionary s_configuredServers;
// Static Properties
///
/// Gets default configured web server instance.
///
public static WebServer Default => s_defaultServer ??= GetConfiguredServer();
// Static Constructor
static WebServer()
{
s_configuredServers = new Dictionary(StringComparer.OrdinalIgnoreCase);
}
// Static Methods
///
/// Gets a shared instance created based on configured options defined in the specified .
///
/// Settings category to use for web server options; defaults to "systemSettings".
/// Razor engine instance for .cshtml templates; uses default instance if not provided.
/// Razor engine instance for .vbhtml templates; uses default instance if not provided.
/// Shared instance created based on configured options.
[SuppressMessage("Microsoft.Reliability", "CA2002:DoNotLockOnObjectsWithWeakIdentity")]
public static WebServer GetConfiguredServer(string settingsCategory = null, IRazorEngine razorEngineCS = null, IRazorEngine razorEngineVB = null)
{
lock (typeof(WebServer))
{
if (string.IsNullOrWhiteSpace(settingsCategory))
settingsCategory = "systemSettings";
return s_configuredServers.GetOrAdd(settingsCategory, category =>
{
// Get configured web server settings
CategorizedSettingsElementCollection settings = ConfigurationFile.Current.Settings[category];
settings.Add(nameof(WebServerOptions.WebRootPath), WebServerOptions.DefaultWebRootPath, "The root path for the hosted web server files. Location will be relative to install folder if full path is not specified.");
settings.Add(nameof(WebServerOptions.ClientCacheEnabled), WebServerOptions.DefaultClientCacheEnabled, "Determines if cache control is enabled for web server when rendering content to browser clients.");
settings.Add(nameof(WebServerOptions.MinifyJavascript), WebServerOptions.DefaultMinifyJavascript, "Determines if minification should be applied to rendered Javascript files.");
settings.Add(nameof(WebServerOptions.MinifyJavascriptExclusionExpression), WebServerOptions.DefaultMinifyJavascriptExclusionExpression, "Defines the regular expression that will exclude Javascript files from being minified. Empty value will target all files for minification.");
settings.Add(nameof(WebServerOptions.MinifyStyleSheets), WebServerOptions.DefaultMinifyStyleSheets, "Determines if minification should be applied to rendered CSS files.");
settings.Add(nameof(WebServerOptions.MinifyStyleSheetsExclusionExpression), WebServerOptions.DefaultMinifyStyleSheetsExclusionExpression, "Defines the regular expression that will exclude CSS files from being minified. Empty value will target all files for minification.");
settings.Add(nameof(WebServerOptions.UseMinifyInDebug), WebServerOptions.DefaultUseMinifyInDebug, "Determines if minification should be applied when running a Debug build.");
settings.Add(nameof(SessionHandler.SessionToken), SessionHandler.DefaultSessionToken, "Defines the token used for identifying the session ID in cookie headers.");
settings.Add(nameof(AuthenticationOptions.AuthTestPage), AuthenticationOptions.DefaultAuthTestPage, "Defines the page name for the web server to test if a user is authenticated.");
settings.Add(nameof(WebServerOptions.ErrorTemplateName), WebServerOptions.DefaultErrorTemplateName, "Defines the template file name to use when a Razor compile or execution exception occurs. Leave blank for none.");
WebServerOptions options = new WebServerOptions
{
WebRootPath = settings[nameof(WebServerOptions.WebRootPath)].ValueAs(WebServerOptions.DefaultWebRootPath),
ClientCacheEnabled = settings[nameof(WebServerOptions.ClientCacheEnabled)].ValueAsBoolean(WebServerOptions.DefaultClientCacheEnabled),
MinifyJavascript = settings[nameof(WebServerOptions.MinifyJavascript)].ValueAsBoolean(WebServerOptions.DefaultMinifyJavascript),
MinifyJavascriptExclusionExpression = settings[nameof(WebServerOptions.MinifyJavascriptExclusionExpression)].ValueAs(WebServerOptions.DefaultMinifyJavascriptExclusionExpression),
MinifyStyleSheets = settings[nameof(WebServerOptions.MinifyStyleSheets)].ValueAsBoolean(WebServerOptions.DefaultMinifyStyleSheets),
MinifyStyleSheetsExclusionExpression = settings[nameof(WebServerOptions.MinifyStyleSheetsExclusionExpression)].ValueAs(WebServerOptions.DefaultMinifyStyleSheetsExclusionExpression),
UseMinifyInDebug = settings[nameof(WebServerOptions.UseMinifyInDebug)].ValueAsBoolean(WebServerOptions.DefaultUseMinifyInDebug),
SessionToken = settings[nameof(SessionHandler.SessionToken)].ValueAs(SessionHandler.DefaultSessionToken),
AuthTestPage = settings[nameof(AuthenticationOptions.AuthTestPage)].ValueAs(AuthenticationOptions.DefaultAuthTestPage),
ErrorTemplateName = settings[nameof(WebServerOptions.ErrorTemplateName)].ValueAs(WebServerOptions.DefaultErrorTemplateName)
};
return new WebServer(options, razorEngineCS, razorEngineVB);
});
}
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static bool ResourceExists(string fileName, bool embeddedResource) =>
embeddedResource ? WebExtensions.EmbeddedResourceExists(fileName) : File.Exists(fileName);
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static HttpResponseMessage CreateAuthTestResponse(HttpRequestMessage request, HttpResponseMessage response)
{
if (request.Method != HttpMethod.Post)
throw new SecurityException("Authorization test only operates with validated HTTP post operation");
response.Content = new StringContent($@"
Authentication Test
{(int)response.StatusCode} ({response.StatusCode}) for user
{request.GetRequestContext().Principal?.Identity.Name ?? "Undefined"}
");
response.Content.Headers.ContentType = new MediaTypeHeaderValue("text/html");
return response;
}
#endregion
}
}