tesses.webserver/Tesses.WebServer.NetStandard/SimpleHttpCode.cs

838 lines
31 KiB
C#

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net;
using System.Runtime.CompilerServices;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using HeyRed.Mime;
using Newtonsoft.Json;
namespace Tesses.WebServer
{
//This file contains modified code from https://github.com/dajuric/simple-http
/// <summary>
/// Delegate executed when a file is about to be read from a body stream.
/// </summary>
/// <param name="fieldName">Field name.</param>
/// <param name="fileName">name of the file.</param>
/// <param name="contentType">Content type.</param>
/// <returns>Stream to be populated.</returns>
public delegate Stream OnFile(string fieldName, string fileName, string contentType);
public static class DajuricSimpleHttpExtensions
{
/* Thanks to you we fixed this
public static void Print(string text,[CallerLineNumber] int lineNumber = 0)
{
Console.WriteLine($"[LINE {lineNumber}] {text}");
}*/
static void Deconstruct<T1, T2>(this KeyValuePair<T1, T2> tuple, out T1 key, out T2 value)
{
key = tuple.Key;
value = tuple.Value;
}
const string BYTES_RANGE_HEADER = "Range";
static bool ParseForm(this ServerContext ctx)
{
//Print("Enter ParseForm(this ServerContext ctx)");
var args = ctx.QueryParams;
string content_type = ctx.RequestHeaders.GetFirst("Content-Type");
if (content_type != "application/x-www-form-urlencoded")
return false;
//Print("Before BodyAsString");
var str = ctx.BodyAsString();
//Print("After BodyAsString");
if (str == null)
return false;
//Print("Before For Loop");
foreach (var pair in str.Split('&'))
{
var nameValue = pair.Split('=');
if (nameValue.Length != (1 + 1))
continue;
//Print($"Before Add: {nameValue[0]}: {nameValue[1]}");
args.Add(nameValue[0], WebUtility.UrlDecode(nameValue[1]));
//Print($"After Add: {nameValue[0]}: {nameValue[1]}");
}
return true;
}
private static string BodyAsString(this ServerContext ctx)
{
string str = null;
using (var reader = new StreamReader(ctx.GetRequestStream()))
{
//Print("Before ReadToEnd");
str = reader.ReadToEnd();
//Print("After ReadToEnd");
}
return str;
}
public static void SendNonSeekableStream(this ServerContext ctx,Stream strm,long readFor=-1,string contentType = "application/octet-stream")
{
try
{
var strm2=new ChunkedStream(ctx.NetworkStream,false);
long tread=0;
byte[] buffer=new byte[8*1024*1024];
int read=0;
do
{
if(readFor > -1){
read=(int)Math.Min(buffer.Length,readFor-tread);
}else{
read=buffer.Length;
}
if(read == 0) break;
read = strm.Read(buffer,0,read);
strm2.Write(buffer,0,read);
}while(read > 0);
} finally {
strm.Close();
ctx.NetworkStream.Close();
}
}
public static async Task SendNonSeekableStreamAsync(this ServerContext ctx,Stream strm,long readFor=-1,string contentType="application/octet-stream")
{
try
{
var strm2=new ChunkedStream(ctx.NetworkStream,false);
long tread=0;
byte[] buffer=new byte[8*1024*1024];
int read=0;
do
{
if(readFor > -1){
read=(int)Math.Min(buffer.Length,readFor-tread);
}else{
read=buffer.Length;
}
if(read == 0) break;
read = await strm.ReadAsync(buffer,0,read);
await strm2.WriteAsync(buffer,0,read);
}while(read > 0);
} finally {
strm.Close();
ctx.NetworkStream.Close();
}
}
public static void SendStream(this ServerContext ctx,Stream strm,string contentType="application/octet-stream")
{
//ctx.StatusCode = 200;
int start = 0, end = (int)strm.Length - 1;
if (ctx.RequestHeaders.ContainsKey(BYTES_RANGE_HEADER) && strm.CanSeek)
{
if (ctx.RequestHeaders[BYTES_RANGE_HEADER].Count > 1)
{
throw new NotSupportedException("Multiple 'Range' headers are not supported.");
}
var range = ctx.RequestHeaders[BYTES_RANGE_HEADER][0].Replace("bytes=", String.Empty)
.Split(new string[] { "-" }, StringSplitOptions.RemoveEmptyEntries)
.Select(x => Int32.Parse(x))
.ToArray();
start = (range.Length > 0) ? range[0] : 0;
end = (range.Length > 1) ? range[1] : (int)(strm.Length - 1);
var hdrs = ctx.ResponseHeaders;
hdrs.Add("Accept-Ranges", "bytes");
hdrs.Add("Content-Range", "bytes " + start + "-" + end + "/" + strm.Length);
ctx.StatusCode = 206;
}
ctx.ResponseHeaders.Add("Content-Length", (end - start + 1).ToString());
ctx.WithMimeType(contentType);
ctx.WriteHeaders();
if (!ctx.Method.Equals("HEAD", StringComparison.Ordinal))
{
try
{
if(strm.CanSeek)
strm.Position = start;
strm.CopyTo(ctx.NetworkStream, Math.Min(8 * 1024 * 1024, end - start + 1));
}
finally
{
strm.Close();
ctx.NetworkStream.Close();
}
}
}
public static async Task SendStreamAsync(this ServerContext ctx, Stream strm, string contentType = "application/octet-stream")
{
//ctx.StatusCode = 200;
int start = 0, end = (int)strm.Length - 1;
if (ctx.RequestHeaders.ContainsKey(BYTES_RANGE_HEADER) && strm.CanSeek)
{
if (ctx.RequestHeaders[BYTES_RANGE_HEADER].Count > 1)
{
throw new NotSupportedException("Multiple 'Range' headers are not supported.");
}
var range = ctx.RequestHeaders[BYTES_RANGE_HEADER][0].Replace("bytes=", String.Empty)
.Split(new string[] { "-" }, StringSplitOptions.RemoveEmptyEntries)
.Select(x => Int32.Parse(x))
.ToArray();
start = (range.Length > 0) ? range[0] : 0;
end = (range.Length > 1) ? range[1] : (int)(strm.Length - 1);
var hdrs = ctx.ResponseHeaders;
hdrs.Add("Accept-Ranges", "bytes");
hdrs.Add("Content-Range", "bytes " + start + "-" + end + "/" + strm.Length);
ctx.StatusCode = 206;
}
ctx.ResponseHeaders.Add("Content-Length", (end - start + 1).ToString());
ctx.WithMimeType(contentType);
await ctx.WriteHeadersAsync();
if (!ctx.Method.Equals("HEAD", StringComparison.Ordinal))
{
try
{
if(strm.CanSeek)
strm.Position = start;
await strm.CopyToAsync(ctx.NetworkStream, Math.Min(8 * 1024 * 1024, end - start + 1));
}
finally
{
strm.Close();
ctx.NetworkStream.Close();
}
}
}
/// <summary>
/// Send file to client (supports range partial content)
/// </summary>
/// <param name="ctx">ServerContext</param>
/// <param name="file">the file to serve</param>
public static async Task SendFileAsync(this ServerContext ctx, string file)
{
if(!File.Exists(file))
{
await ctx.SendNotFoundAsync();
return;
}
if(!ctx.RequestHeaders.ContainsKey(BYTES_RANGE_HEADER) && handleIfCached())
return;
using(var f = File.OpenRead(file))
{
await ctx.SendStreamAsync(f,MimeTypesMap.GetMimeType(file));
}
bool handleIfCached()
{
var lastModified = File.GetLastWriteTimeUtc(file);
string etag=lastModified.Ticks.ToString("x");
ctx.ResponseHeaders.Add("ETag",etag);
ctx.ResponseHeaders.Add("Last-Modified",lastModified.ToString("R"));
if(ctx.RequestHeaders.TryGetFirst("If-None-Match",out var ifNoneMatch))
{
var eTags = ifNoneMatch.Split(',').Select(x => x.Trim()).ToArray();
if (eTags.Contains(etag))
{
ctx.StatusCode = 304;
ctx.WriteHeaders();
return true;
}
}
if(ctx.RequestHeaders.TryGetFirst("If-Modified-Since",out var iMs) && DateTime.TryParse(iMs,out DateTime ifModifiedSince))
{
if (lastModified <= ifModifiedSince)
{
ctx.StatusCode = 304;
ctx.WriteHeaders();
return true;
}
}
return false;
}
}
/// <summary>
/// Send file to client (supports range partial content)
/// </summary>
/// <param name="ctx">ServerContext</param>
/// <param name="file">the file to serve</param>
public static void SendFile(this ServerContext ctx, string file)
{
if(!File.Exists(file))
{
ctx.SendNotFound();
return;
}
if(!ctx.RequestHeaders.ContainsKey(BYTES_RANGE_HEADER) && handleIfCached())
return;
using(var f = File.OpenRead(file))
{
ctx.SendStream(f,MimeTypesMap.GetMimeType(file));
}
bool handleIfCached()
{
var lastModified = File.GetLastWriteTimeUtc(file);
string etag=lastModified.Ticks.ToString("x");
ctx.ResponseHeaders.Add("ETag",etag);
ctx.ResponseHeaders.Add("Last-Modified",lastModified.ToString("R"));
if(ctx.RequestHeaders.TryGetFirst("If-None-Match",out var ifNoneMatch))
{
var eTags = ifNoneMatch.Split(',').Select(x => x.Trim()).ToArray();
if (eTags.Contains(etag))
{
ctx.StatusCode = 304;
ctx.WriteHeaders();
return true;
}
}
if(ctx.RequestHeaders.TryGetFirst("If-Modified-Since",out var iMs) && DateTime.TryParse(iMs,out DateTime ifModifiedSince))
{
if (lastModified <= ifModifiedSince)
{
ctx.StatusCode = 304;
ctx.WriteHeaders();
return true;
}
}
return false;
}
}
static Dictionary<string, List<HttpFile>> ParseMultipartForm(ServerContext serverCtx, OnFile onFile)
{
var args = serverCtx.QueryParams;
string content_type=serverCtx.RequestHeaders.GetFirst("Content-Type");
if (content_type.StartsWith("multipart/form-data",StringComparison.Ordinal) == false)
throw new InvalidDataException("Not 'multipart/form-data'.");
var boundary = Regex.Match(content_type, "boundary=(.+)").Groups[1].Value;
boundary = "--" + boundary;
var files = new Dictionary<string, List<HttpFile>>();
var inputStream = new BufferedStream(serverCtx.GetRequestStream());
//Print("Before ParseUntillBoundaryEnd");
parseUntillBoundaryEnd(inputStream, new MemoryStream(), boundary);
//Print("After ParseUntillBoundaryEnd");
while (true)
{
//Print("Before ParseSection");
var (n, v, fn, ct) = parseSection(inputStream, "\r\n" + boundary, onFile);
//Print("After ParseSection");
if (String.IsNullOrEmpty(n)) break;
v.Position = 0;
if (!String.IsNullOrEmpty(fn))
files.Add(n, new HttpFile(fn, v, ct));
else
args.Add(n, readAsString(v));
}
return files;
}
private static (string Name, Stream Value, string FileName, string ContentType) parseSection(Stream source, string boundary, OnFile onFile)
{
//Print("Before ReadContentDisposition");
var (n, fn, ct) = readContentDisposition(source);
//Print("After ReadContentDisposition");
source.ReadByte(); source.ReadByte(); //\r\n (empty row)
var dst = String.IsNullOrEmpty(fn) ? new MemoryStream() : onFile(n, fn, ct);
if (dst == null)
throw new ArgumentException(nameof(onFile), "The on-file callback must return a stream.");
//Print("Before ParseUntillBodyEnd");
parseUntillBoundaryEnd(source, dst, boundary);
//Print("Before ParseUntillBodyEnd");
return (n, dst, fn, ct);
}
private static (string Name, string FileName, string ContentType) readContentDisposition(Stream stream)
{
const string UTF_FNAME = "utf-8''";
var l = readLine(stream);
if (String.IsNullOrEmpty(l))
return (null, null, null);
//(regex matches are taken from NancyFX) and modified
var n = Regex.Match(l, @"name=""?(?<n>[^\""]*)").Groups["n"].Value;
var f = Regex.Match(l, @"filename\*?=""?(?<f>[^\"";]*)").Groups["f"]?.Value;
string cType = null;
if (!String.IsNullOrEmpty(f))
{
if (f.StartsWith(UTF_FNAME))
f = Uri.UnescapeDataString(f.Substring(UTF_FNAME.Length));
l = readLine(stream);
cType = Regex.Match(l, "Content-Type: (?<cType>.+)").Groups["cType"].Value;
}
return (n, f, cType);
}
private static void parseUntillBoundaryEnd(Stream source, Stream destination, string boundary)
{
var checkBuffer = new byte[boundary.Length]; //for boundary checking
int b, i = 0;
while ((b = source.ReadByte()) != -1)
{
if (i == boundary.Length) //boundary found -> go to the end of line
{
if (b == '\n') break;
continue;
}
if (b == boundary[i]) //start filling the check buffer
{
checkBuffer[i] = (byte)b;
i++;
}
else
{
var idx = 0;
while (idx < i) //write the buffer data to stream
{
destination.WriteByte(checkBuffer[idx]);
idx++;
}
i = 0;
destination.WriteByte((byte)b); //write the current byte
}
}
}
private static string readLine(Stream stream)
{
var sb = new StringBuilder();
int b;
while ((b = stream.ReadByte()) != -1 && b != '\n')
sb.Append((char)b);
if (sb.Length > 0 && sb[sb.Length - 1] == '\r')
sb.Remove(sb.Length - 1, 1);
return sb.ToString();
}
private static string readAsString(Stream stream)
{
var sb = new StringBuilder();
int b;
while ((b = stream.ReadByte()) != -1)
sb.Append((char)b);
return sb.ToString();
}
/// <summary>
/// Parses body of the request including form and multi-part form data.
/// </summary>
/// <param name="request">HTTP request.</param>
/// <param name="args">Key-value pairs populated by the form data by this function.</param>
/// <returns>Name-file pair collection.</returns>
public static Dictionary<string, HttpFile> ParseBody(this ServerContext ctx)
{
return ctx.ParseBody( (n, fn, ct) => new MemoryStream());
}
/// <summary>
/// Parses body of the request including form and multi-part form data, allowing multiple file with same key.
/// </summary>
/// <param name="request">HTTP request.</param>
/// <param name="args">Key-value pairs populated by the form data by this function.</param>
/// <param name="onFile">
/// Function called if a file is about to be parsed. The stream is attached to a corresponding <see cref="HttpFile"/>.
/// <para>By default, <see cref="MemoryStream"/> is used, but for large files, it is recommended to open <see cref="FileStream"/> directly.</para>
/// </param>
/// <returns>Name-file pair collection.</returns>
public static Dictionary<string, List<HttpFile>> ParseBodyMultiple(this ServerContext request, OnFile onFile)
{
if (request == null)
throw new ArgumentNullException(nameof(request));
if (!request.RequestHeaders.ContainsKey("Content-Type"))
throw new ArgumentNullException("request.RequestHeaders[\"Content-Type\"]");
if (onFile == null)
throw new ArgumentNullException(nameof(onFile));
string content_type = request.RequestHeaders.GetFirst("Content-Type");
if (content_type.StartsWith("application/x-www-form-urlencoded",StringComparison.Ordinal))
{
ParseForm(request);
var files = new Dictionary<string, List<HttpFile>>();
return files;
}
else if (content_type.StartsWith("multipart/form-data",StringComparison.Ordinal))
{
return ParseMultipartForm(request, onFile);
}
else
throw new NotSupportedException("The body content-type is not supported.");
}
/// <summary>
/// Parses body of the request including form and multi-part form data.
/// </summary>
/// <param name="request">HTTP request.</param>
/// <param name="args">Key-value pairs populated by the form data by this function.</param>
/// <param name="onFile">
/// Function called if a file is about to be parsed. The stream is attached to a corresponding <see cref="HttpFile"/>.
/// <para>By default, <see cref="MemoryStream"/> is used, but for large files, it is recommended to open <see cref="FileStream"/> directly.</para>
/// </param>
/// <returns>Name-file pair collection.</returns>
public static Dictionary<string, HttpFile> ParseBody(this ServerContext request, OnFile onFile)
{
var res=ParseBodyMultiple(request,onFile);
Dictionary<string,HttpFile> files = new Dictionary<string, HttpFile>();
foreach(var item in res)
{
if(item.Value.Count > 0)
{
files.Add(item.Key,item.Value[0]);
}
}
return files;
}
public static HttpFileResponse ParseBodyWithTempDirectory(this ServerContext request)
{
DateTime dt=DateTime.Now;
return request.ParseBodyWithTempDirectory(Path.Combine(Path.GetTempPath(),$"TWSUPLOAD_{dt.ToString("yyyyMMdd_HHmmss")}_{ServerContext.UniqueNumber()}"));
}
/// <summary>
/// Parses body of the request including form and multi-part form data, allowing multiple file with same key and storing the files in a temp directory specified by the user.
/// </summary>
/// <param name="request">HTTP request.</param>
/// <param name="tempDir">The root directory to store all the uploads in</param>
/// <returns>A HttpFileResponse Containing Paths to files, Dispose only deletes the files, so if you want to keep the files don't dispose it.</returns>
public static HttpFileResponse ParseBodyWithTempDirectory(this ServerContext request, string tempDir)
{
Directory.CreateDirectory(tempDir);
Stream Open(string field, string filename, string contentType)
{
string dir=Path.Combine(tempDir,ServerContext.FixFileName(field));
Directory.CreateDirectory(dir);
string filename2 = Path.Combine(dir,ServerContext.FixFileName(filename));
return File.Create(filename2);
}
List<HttpFileResponseEntry> responseEntries=new List<HttpFileResponseEntry>();
foreach(var item in ParseBodyMultiple(request,Open))
{
foreach(var i2 in item.Value)
{
responseEntries.Add(new HttpFileResponseEntry(Path.Combine(tempDir,ServerContext.FixFileName(item.Key),ServerContext.FixFileName(i2.FileName)),i2.FileName,item.Key,i2.ContentType));
i2.Dispose();
}
}
return new HttpFileResponse(tempDir,responseEntries);
}
}
public sealed class HttpFileResponseEntry
{
public HttpFileResponseEntry(string path, string filename, string fieldname, string contype)
{
Path = path;
FileName = filename;
ContentType = contype;
FieldName = fieldname;
}
public string FileName {get;}
public string ContentType {get;}
public string Path {get;}
public string FieldName {get;}
[JsonIgnore]
public FileInfo FileInfo => new FileInfo(Path);
[JsonIgnore]
public object PrivateData {get;set;}=null;
public Stream OpenRead()
{
return File.OpenRead(Path);
}
public void MoveTo(string dest)
{
File.Move(Path,dest);
}
}
public sealed class HttpFileResponse : IDisposable
{
public HttpFileResponse(string dir, IReadOnlyList<HttpFileResponseEntry> entries)
{
Directory = dir;
Files = entries;
}
public IReadOnlyList<HttpFileResponseEntry> Files {get;}
public string Directory {get;}
public void Dispose()
{
if(System.IO.Directory.Exists(Directory))
System.IO.Directory.Delete(Directory,true);
}
~HttpFileResponse()
{
Dispose();
}
}
/// <summary>
/// HTTP file data container.
/// </summary>
public class HttpFile : IDisposable
{
/// <summary>
/// Creates new HTTP file data container.
/// </summary>
/// <param name="fileName">File name.</param>
/// <param name="value">Data.</param>
/// <param name="contentType">Content type.</param>
internal HttpFile(string fileName, Stream value, string contentType)
{
Value = value;
FileName = fileName;
ContentType = contentType;
}
/// <summary>
/// Gets the name of the file.
/// </summary>
public string FileName { get; private set; }
/// <summary>
/// Gets the data.
/// <para>If a stream is created <see cref="OnFile"/> it will be closed when this HttpFile object is disposed.</para>
/// </summary>
public Stream Value { get; private set; }
/// <summary>
/// Content type.
/// </summary>
public string ContentType { get; private set; }
/// <summary>
/// Saves the data into a file.
/// <para>Directory path will be auto created if does not exists.</para>
/// </summary>
/// <param name="fileName">File path with name.</param>
/// <param name="overwrite">True to overwrite the existing file, false otherwise.</param>
/// <returns>True if the file is saved/overwritten, false otherwise.</returns>
public bool Save(string fileName, bool overwrite = false)
{
if (File.Exists(Path.GetFullPath(fileName)))
return false;
var dir = Path.GetDirectoryName(Path.GetFullPath(fileName));
Directory.CreateDirectory(dir);
Value.Position = 0;
using (var outStream = File.OpenWrite(fileName))
Value.CopyTo(outStream);
return true;
}
/// <summary>
/// Disposes the current instance.
/// </summary>
public void Dispose()
{
if (Value != null)
{
Value?.Dispose();
Value = null;
}
}
/// <summary>
/// Disposes the current instance.
/// </summary>
~HttpFile()
{
Dispose();
}
}
/// <summary>
/// Route server, Based on SimpleHTTP (Used most of the Route Source)
/// </summary>
public delegate bool ShouldProcessFunc(ServerContext ctx);
public delegate Task HttpActionAsync(ServerContext ctx);
public delegate void HttpAction(ServerContext ctx);
public class RouteServer : Server
{
public RouteServer() : this(new NotFoundServer())
{
}
public RouteServer(IServer otherServer)
{
this.otherServer=otherServer;
}
public List<(ShouldProcessFunc ShouldProcessFunc, HttpActionAsync Action)> Methods = new List<(ShouldProcessFunc ShouldProcessFunc, HttpActionAsync Action)>();
private IServer otherServer;
public override async Task GetAsync(ServerContext ctx)
{
if(!await Process(ctx))
{
await Guaranteed(otherServer).GetAsync(ctx);
}
}
public override async Task PostAsync(ServerContext ctx)
{
if(!await Process(ctx))
{
await Guaranteed(otherServer).PostAsync(ctx);
}
}
public override async Task OtherAsync(ServerContext ctx)
{
if(!await Process(ctx))
{
await Guaranteed(otherServer).OtherAsync(ctx);
}
}
public override async Task OptionsAsync(ServerContext ctx)
{
if(!await Process(ctx))
{
await Guaranteed(otherServer).OptionsAsync(ctx);
}
}
private async Task<bool> Process(ServerContext ctx)
{
foreach(var (shouldProcessFunc,action) in Methods)
{
if(!shouldProcessFunc(ctx))
{
ctx.ResetQueryParms();
continue;
}
await action(ctx);
return true;
}
return false;
}
/// <summary>
/// Adds the specified action to the route collection.
/// <para>The order of actions defines the priority.</para>
/// </summary>
/// <param name="shouldProcess">Function defining whether the specified action should be executed or not.</param>
/// <param name="action">Action executed if the specified pattern matches the URL path.</param>
public void Add(ShouldProcessFunc shouldProcess, HttpActionAsync action)
{
Methods.Add((shouldProcess, action));
}
/// <summary>
/// Adds the specified action to the route collection.
/// <para>The order of actions defines the priority.</para>
/// </summary>
/// <param name="url">
/// String url
/// </param>
/// <param name="action">Action executed if the specified pattern matches the URL path.</param>
/// <param name="method">HTTP method (GET, POST, DELETE, HEAD).</param>
public void Add(string url,HttpActionAsync action,string method="GET")
{
Add((e) =>
{
if (!e.Method.Equals( method,StringComparison.Ordinal))
return false;
return e.UrlPath.Equals(url,StringComparison.Ordinal);
},
action);
}
/// <summary>
/// Adds the specified action to the route collection.
/// <para>The order of actions defines the priority.</para>
/// </summary>
/// <param name="url">
/// String url
/// </param>
/// <param name="action">Action executed if the specified pattern matches the URL path.</param>
/// <param name="method">HTTP method (GET, POST, DELETE, HEAD).</param>
public void Add(string url, HttpAction action, string method = "GET")
{
Add((e) =>
{
if (!e.Method.Equals(method, StringComparison.Ordinal))
return false;
return e.UrlPath.Equals(url, StringComparison.Ordinal);
},
action);
}
/// <summary>
/// Adds the specified action to the route collection.
/// <para>The order of actions defines the priority.</para>
/// </summary>
/// <param name="shouldProcess">Function defining whether the specified action should be executed or not.</param>
/// <param name="action">Action executed if the specified pattern matches the URL path.</param>
public void Add(ShouldProcessFunc shouldProcess,HttpAction action)
{
Methods.Add((shouldProcess, (e) =>
{
action(e);
return Task.FromResult(true);
}
));
}
}
}