diff --git a/README.md b/README.md index b5e3148..913ed8b 100644 --- a/README.md +++ b/README.md @@ -373,12 +373,24 @@ if (url.ToLower().IndexOf("/api/") == 0) { ret += $"Size of content: {e.Context.Request.ContentLength64}\r\n"; - byte[] buff = new byte[e.Context.Request.ContentLength64]; - e.Context.Request.InputStream.Read(buff, 0, buff.Length); - ret += $"Hex string representation:\r\n"; - for (int i = 0; i < buff.Length; i++) + + var contentTypes = e.Context.Request.Headers?.GetValues("Content-Type"); + var isMultipartForm = contentTypes != null && contentTypes.Length > 0 && contentTypes[0].StartsWith("multipart/form-data;"); + + if(isMultipartForm) { - ret += buff[i].ToString("X") + " "; + var form = e.Context.Request.ReadForm(); + ret += $"Received a form with {form.Parameters.Length} parameters and {form.Files.Length} files."; + } + else + { + var body = e.Context.Request.ReadBody(); + + ret += $"Request body hex string representation:\r\n"; + for (int i = 0; i < body.Length; i++) + { + ret += body[i].ToString("X") + " "; + } } } @@ -391,6 +403,11 @@ This API example is basic but as you get the method, you can choose what to do. As you get the url, you can check for a specific controller called. And you have the parameters and the content payload! +Notice the extension methods to read the body of the request: + +- ReadBody will read the data from the InputStream while the data is flowing in which might be in multiple passes depending on the size of the body +- ReadForm allows to read a multipart/form-data form and returns the text key/value pairs as well as any files in the request + Example of a result with call: ![result](./doc/POSTcapture.jpg) diff --git a/nanoFramework.WebServer.FileSystem/nanoFramework.WebServer.FileSystem.nfproj b/nanoFramework.WebServer.FileSystem/nanoFramework.WebServer.FileSystem.nfproj index a802805..09fd6f6 100644 --- a/nanoFramework.WebServer.FileSystem/nanoFramework.WebServer.FileSystem.nfproj +++ b/nanoFramework.WebServer.FileSystem/nanoFramework.WebServer.FileSystem.nfproj @@ -32,49 +32,30 @@ + + + + + + + + + + + + + + + + + + + + + + + - - Authentication.cs - - - AuthenticationAttirbute.cs - - - AuthenticationType.cs - - - CallbackRoutes.cs - - - CaseSensitiveAttribute.cs - - - HttpProtocol.cs - - - WebServerEventArgs.cs - - - Header.cs - - - MethodAttribute.cs - - - RouteAttribute.cs - - - UrlParameter.cs - - - WebServer.cs - - - WebServerStatus.cs - - - WebServerStatusEventArgs.cs - @@ -118,6 +99,9 @@ + + + diff --git a/nanoFramework.WebServer/AuthenticationAttirbute.cs b/nanoFramework.WebServer/AuthenticationAttribute.cs similarity index 100% rename from nanoFramework.WebServer/AuthenticationAttirbute.cs rename to nanoFramework.WebServer/AuthenticationAttribute.cs diff --git a/nanoFramework.WebServer/HttpConnectionType.cs b/nanoFramework.WebServer/HttpConnectionType.cs deleted file mode 100644 index 3869e45..0000000 --- a/nanoFramework.WebServer/HttpConnectionType.cs +++ /dev/null @@ -1,10 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Text; - -namespace WebServer -{ - class HttpConnectionType - { - } -} diff --git a/nanoFramework.WebServer/HttpListenerRequestExtensions.cs b/nanoFramework.WebServer/HttpListenerRequestExtensions.cs new file mode 100644 index 0000000..c9363fb --- /dev/null +++ b/nanoFramework.WebServer/HttpListenerRequestExtensions.cs @@ -0,0 +1,65 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.IO; +using System.Net; +using System.Threading; +using nanoFramework.WebServer.HttpMultipartParser; + +namespace nanoFramework.WebServer +{ + /// Contains extension methods for HttpListenerRequest + public static class HttpListenerRequestExtensions + { + /// + /// Reads a Multipart form from the request + /// + /// The request to read the form from + /// A MultipartFormDataParser containing a collection of the parameters and files in the form. + public static MultipartFormDataParser ReadForm(this HttpListenerRequest httpListenerRequest) => + MultipartFormDataParser.Parse(httpListenerRequest.InputStream); + + /// + /// Reads a body from the HttpListenerRequest inputstream + /// + /// The request to read the body from + /// A byte[] containing the body of the request + public static byte[] ReadBody(this HttpListenerRequest httpListenerRequest) + { + byte[] body = new byte[httpListenerRequest.ContentLength64]; + byte[] buffer = new byte[4096]; + Stream stream = httpListenerRequest.InputStream; + + int position = 0; + + while (true) + { + // The stream is (should be) a NetworkStream which might still be receiving data while + // we're already processing. Give the stream a chance to receive more data or we might + // end up with "zero bytes read" too soon... + Thread.Sleep(1); + + long length = stream.Length; + + if (length > buffer.Length) + { + length = buffer.Length; + } + + int bytesRead = stream.Read(buffer, 0, (int)length); + + if (bytesRead == 0) + { + break; + } + + Array.Copy(buffer, 0, body, position, bytesRead); + + position += bytesRead; + } + + return body; + } + } +} diff --git a/nanoFramework.WebServer/HttpMultipartParser/FilePart.cs b/nanoFramework.WebServer/HttpMultipartParser/FilePart.cs new file mode 100644 index 0000000..69a193f --- /dev/null +++ b/nanoFramework.WebServer/HttpMultipartParser/FilePart.cs @@ -0,0 +1,80 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections; +using System.IO; + +namespace nanoFramework.WebServer.HttpMultipartParser +{ + /// Represents a single file extracted from a multipart/form-data stream. + public class FilePart + { + /// Initializes a new instance of the class. + /// The name of the input field used for the upload. + /// The name of the file. + /// The file data. + /// Additional properties associated with this file. + /// The content type. + /// The content disposition. + public FilePart(string name, string fileName, Stream data, Hashtable additionalProperties, string contentType, string contentDisposition) + { + string[] parts = fileName?.Split(GetInvalidFileNameChars()); + + Name = name; + FileName = parts != null && parts.Length > 0 ? parts[parts.Length - 1] : string.Empty; + Data = data; + ContentType = contentType; + ContentDisposition = contentDisposition; + AdditionalProperties = additionalProperties; + } + + /// Gets the data. + public Stream Data + { + get; + } + + /// Gets the file name. + public string FileName + { + get; + } + + /// Gets the name. + public string Name + { + get; + } + + /// Gets the content-type. Defaults to text/plain if unspecified. + public string ContentType + { + get; + } + + /// Gets the content-disposition. Defaults to form-data if unspecified. + public string ContentDisposition + { + get; + } + + /// + /// Gets the additional properties associated with this file. + /// An additional property is any property other than the "well known" ones such as name, filename, content-type, etc. + /// + public Hashtable AdditionalProperties + { + get; + private set; + } + + private static char[] GetInvalidFileNameChars() => new char[] + { + '\"', '<', '>', '|', '\0', + (char)1, (char)2, (char)3, (char)4, (char)5, (char)6, (char)7, (char)8, (char)9, (char)10, + (char)11, (char)12, (char)13, (char)14, (char)15, (char)16, (char)17, (char)18, (char)19, (char)20, + (char)21, (char)22, (char)23, (char)24, (char)25, (char)26, (char)27, (char)28, (char)29, (char)30, + (char)31, ':', '*', '?', '\\', '/' + }; + } +} diff --git a/nanoFramework.WebServer/HttpMultipartParser/HashtableUtility.cs b/nanoFramework.WebServer/HttpMultipartParser/HashtableUtility.cs new file mode 100644 index 0000000..c3633aa --- /dev/null +++ b/nanoFramework.WebServer/HttpMultipartParser/HashtableUtility.cs @@ -0,0 +1,23 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections; + +namespace nanoFramework.WebServer.HttpMultipartParser +{ + internal static class HashtableUtility + { + public static bool TryGetValue(this Hashtable hashtable, string key, out string value) + { + if (hashtable != null && hashtable.Contains(key)) + { + var obj = hashtable[key]; + value = obj == null ? string.Empty : obj.ToString(); + return true; + } + + value = null; + return false; + } + } +} diff --git a/nanoFramework.WebServer/HttpMultipartParser/HeaderUtility.cs b/nanoFramework.WebServer/HttpMultipartParser/HeaderUtility.cs new file mode 100644 index 0000000..d62df75 --- /dev/null +++ b/nanoFramework.WebServer/HttpMultipartParser/HeaderUtility.cs @@ -0,0 +1,72 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections; +using System.Text; + +namespace nanoFramework.WebServer.HttpMultipartParser +{ + /// + /// Provides parsing headers from a Http Multipart Form + /// + public static class HeaderUtility + { + /// + /// Reads headers from a line of text. + /// Headers are delimited by a semi-colon ';' + /// Key-value pairs are separated by colon ':' or equals '=' + /// Values can be delimited by quotes '"' or not + /// + /// The line of text containing one or more headers + /// + /// The hashtable that will receive the key values. + /// Passed in since a Multipart Part can contain multiple lines of headers + /// + public static void ParseHeaders(string text, Hashtable headers) + { + bool inQuotes = false; + bool inKey = true; + StringBuilder key = new(); + StringBuilder value = new(); + + foreach (char c in text) + { + if (c == '"') + { + inQuotes = !inQuotes; + } + else if (inQuotes) + { + value.Append(c); + } + else if (c == ';') + { + headers[key.ToString().ToLower()] = value.ToString(); + key.Clear(); + inKey = true; + } + else if (c == '=' || c == ':') + { + value = value.Clear(); + inKey = false; + } + else if (c != ' ') + { + if (inKey) + { + key.Append(c); + } + else + { + value.Append(c); + } + } + } + + if (key.Length > 0) + { + headers.Add(key.ToString().ToLower(), value.ToString()); + } + } + } +} diff --git a/nanoFramework.WebServer/HttpMultipartParser/LineBuffer.cs b/nanoFramework.WebServer/HttpMultipartParser/LineBuffer.cs new file mode 100644 index 0000000..bb83a55 --- /dev/null +++ b/nanoFramework.WebServer/HttpMultipartParser/LineBuffer.cs @@ -0,0 +1,55 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections; + +namespace nanoFramework.WebServer.HttpMultipartParser +{ + internal sealed class LineBuffer : IDisposable + { + private readonly ArrayList _data = new(); + + public void Dispose() => _data.Clear(); + + public void Write(SpanByte spanByte) + { + _data.Add(spanByte.ToArray()); + Length += spanByte.Length; + } + + public int Length { get; private set; } = 0; + + public byte[] ToArray(bool clear = false) + { + byte[] result = new byte[Length]; + int pos = 0; + + foreach (object data in _data) + { + if (data is byte b) + { + result[pos++] = b; + } + else if (data is byte[] array) + { + Array.Copy(array, 0, result, pos, array.Length); + pos += array.Length; + } + } + + if (clear) + { + Clear(); + } + + return result; + } + + public void Clear() + { + _data.Clear(); + Length = 0; + } + } +} diff --git a/nanoFramework.WebServer/HttpMultipartParser/LineReader.cs b/nanoFramework.WebServer/HttpMultipartParser/LineReader.cs new file mode 100644 index 0000000..6842b0a --- /dev/null +++ b/nanoFramework.WebServer/HttpMultipartParser/LineReader.cs @@ -0,0 +1,133 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.IO; +using System.Text; +using System.Threading; + +namespace nanoFramework.WebServer.HttpMultipartParser +{ + /// Provides methods to read a stream line by line while still returning the bytes. + internal class LineReader + { + private readonly Stream _stream; + private readonly byte[] _buffer; + private readonly LineBuffer _lineBuffer = new(); + int _availableBytes = -1; + int _position = 0; + + /// Initializes a new instance of the class. + /// The input stream to read from. + /// The buffer size to use for new buffers. + public LineReader(Stream stream, int bufferSize) + { + _stream = stream; + _buffer = new byte[bufferSize]; + } + + /// + /// Reads a line from the stack delimited by the newline for this platform. + /// The newline characters will not be included in the stream. + /// + /// + /// If true the newline characters will be stripped when the line returns. + /// When reading binary data, newline characters are meaningfull and should be returned + /// + /// The byte[] containing the line or null if end of stream. + public byte[] ReadByteLine(bool excludeNewLine = true) + { + while (ReadFromStream() > 0) + { + for (int i = _position; i < _availableBytes; i++) + { + if (_buffer[i] == '\n') + { + // newline found, time to return the line + int length = GetLength(excludeNewLine, i); + + byte[] line = GetLine(length); + + _position = i + 1; + + return line; + } + } + + // if we get here, no newline found in current buffer + // store what we have left in the buffer into the lineBuffer + _lineBuffer.Write(new SpanByte(_buffer, _position, _availableBytes - _position)); + _position = _availableBytes; + } + +#pragma warning disable S1168 // null and empty do have meaning + // no more bytes available, return what's in the lineBuffer + // if lineBuffer is empty, we're truly done, return null! + return _lineBuffer.Length == 0 ? null : _lineBuffer.ToArray(true); +#pragma warning restore S1168 + } + + private byte[] GetLine(int length) + { + byte[] line; + if (_lineBuffer.Length > 0) + { + _lineBuffer.Write(new SpanByte(_buffer, _position, length)); + line = _lineBuffer.ToArray(true); + } + else + { + line = new byte[length]; + Array.Copy(_buffer, _position, line, 0, length); + } + + return line; + } + + private int GetLength(bool excludeNewLine, int currentPosition) + { + int length = currentPosition - _position + 1; + + if (excludeNewLine) + { + length -= currentPosition > 0 && _buffer[currentPosition - 1] == '\r' ? 2 : 1; + } + + return length; + } + + private int ReadFromStream() + { + if (_position >= _availableBytes) + { + // The stream is (should be) a NetworkStream which might still be receiving data while + // we're already processing. Give the stream a chance to receive more data or we might + // end up with "zero bytes read" too soon... + Thread.Sleep(1); + + long streamLength = _stream.Length; + + if (streamLength > _buffer.Length) + { + streamLength = _buffer.Length; + } + + _availableBytes = _stream.Read(_buffer, 0, (int)streamLength); + _position = 0; + } + + return _availableBytes; + } + + /// + /// Reads a line from the stack delimited by the newline for this platform. + /// The newline characters will not be included in the stream. + /// + /// The containing the line or null if end of stream. + public string ReadLine() + { + byte[] data = ReadByteLine(); + return data == null ? null : Encoding.UTF8.GetString(data, 0, data.Length); + } + } +} diff --git a/nanoFramework.WebServer/HttpMultipartParser/MultipartFormDataParser.cs b/nanoFramework.WebServer/HttpMultipartParser/MultipartFormDataParser.cs new file mode 100644 index 0000000..cb2b2f9 --- /dev/null +++ b/nanoFramework.WebServer/HttpMultipartParser/MultipartFormDataParser.cs @@ -0,0 +1,279 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections; +using System.IO; +using System.Text; + +namespace nanoFramework.WebServer.HttpMultipartParser +{ + /// + /// Provides methods to parse a + /// + /// multipart/form-data + /// + /// stream into it's parameters and file data. + /// + public class MultipartFormDataParser + { + private const int defaultBufferSize = 4096; + + private readonly bool _ignoreInvalidParts; + private readonly int _binaryBufferSize; + private string _boundary; + private byte[] _boundaryBinary; + private readonly Stream _stream; + private bool _readEndBoundary; + private readonly ArrayList _files = new(); + private readonly ArrayList _parameters = new(); + + /// Initializes a new instance of the class + /// The stream containing the multipart data. + /// The size of the buffer to use for parsing the multipart form data. + /// By default the parser will throw an exception if it encounters an invalid part. Set this to true to ignore invalid parts. + public MultipartFormDataParser(Stream stream, int binaryBufferSize = defaultBufferSize, bool ignoreInvalidParts = false) + { + _stream = stream ?? throw new ArgumentNullException(nameof(stream)); + _binaryBufferSize = binaryBufferSize; + _ignoreInvalidParts = ignoreInvalidParts; + } + + /// Gets the mapping of parameters parsed files. The name of a given field maps to the parsed file data. + public FilePart[] Files => _files.ToArray(typeof(FilePart)) as FilePart[]; + + /// Gets the parameters. Several ParameterParts may share the same name. + public ParameterPart[] Parameters => _parameters.ToArray(typeof(ParameterPart)) as ParameterPart[]; + + /// Parse the stream into a new instance of the class + /// The stream containing the multipart data. + /// The size of the buffer to use for parsing the multipart form data. + /// By default the parser will throw an exception if it encounters an invalid part. Set this to true to ignore invalid parts. + /// A new instance of the class. + public static MultipartFormDataParser Parse(Stream stream, int binaryBufferSize = defaultBufferSize, bool ignoreInvalidParts = false) + { + var parser = new MultipartFormDataParser(stream, binaryBufferSize, ignoreInvalidParts); + parser.Run(); + return parser; + } + + private void Run() + { + var reader = new LineReader(_stream, _binaryBufferSize); + + _boundary = DetectBoundary(reader); + _boundaryBinary = Encoding.UTF8.GetBytes(_boundary); + + // we have read until we encountered the boundary so we should be at the first section => parse it! + while (!_readEndBoundary) + { + ParseSection(reader); + } + } + + private static string DetectBoundary(LineReader reader) + { + string line = string.Empty; + while (line == string.Empty) + { + line = reader.ReadLine(); + } + + if (string.IsNullOrEmpty(line)) + { + // EMF001: Unable to determine boundary: either the stream is empty or we reached the end of the stream + throw new MultipartFormDataParserException("EMF001"); + } + else if (!line.StartsWith("--")) + { + // EMF002: Unable to determine boundary: content does not start with a valid multipart boundary + throw new MultipartFormDataParserException("EMF002"); + } + + return line.EndsWith("--") ? line.Substring(0, line.Length - 2) : line; + } + + private void ParseSection(LineReader reader) + { + Hashtable parameters = new(); + + string line = reader.ReadLine(); + while (line != string.Empty) + { + if (line == null || line.StartsWith(_boundary)) + { + // EMF003: Unexpected end of section + throw new MultipartFormDataParserException("EMF003"); + } + + HeaderUtility.ParseHeaders(line, parameters); + + line = reader.ReadLine(); + } + + if (IsFilePart(parameters)) + { + ParseFilePart(parameters, reader); + } + else if (IsParameterPart(parameters)) + { + ParseParameterPart(parameters, reader); + } + else if (_ignoreInvalidParts) + { + SkipPart(reader); + } + else + { + // EMF004: Unable to determine the section type. Some possible reasons include: + // - section is malformed + // - required parameters such as 'name', 'content-type' or 'filename' are missing + // - section contains nothing but empty lines. + throw new MultipartFormDataParserException("EMF004"); + } + } + + private bool IsFilePart(Hashtable parameters) => parameters.Contains("filename") || + parameters.Contains("content-type") || + (!parameters.Contains("name") && parameters.Count > 0); + + private bool IsParameterPart(Hashtable parameters) => parameters.Contains("name"); + + private void ParseFilePart(Hashtable parameters, LineReader reader) + { + // Read the parameters + parameters.TryGetValue("name", out string name); + parameters.TryGetValue("filename", out string filename); + parameters.TryGetValue("content-type", out string contentType); + parameters.TryGetValue("content-disposition", out string contentDisposition); + + RemoveWellKnownParameters(parameters); + + // Default values if expected parameters are missing + contentType ??= "text/plain"; + contentDisposition ??= "form-data"; + + MemoryStream stream = new(); + + while (true) + { + byte[] line = reader.ReadByteLine(false); + + if (CheckForBoundary(line)) + { + TrimEndline(stream); + + stream.Position = 0; + + _files.Add(new FilePart(name, filename, stream, parameters, contentType, contentDisposition)); + break; + } + + stream.Write(line, 0, line.Length); + } + } + + private static void TrimEndline(MemoryStream stream) + { + if (stream.Length == 0) + { + return; + } + + stream.Position = stream.Length - 1; + + int b = stream.ReadByte(); + int clip = 0; + + if (b == '\n') + { + clip = 1; + + if (stream.Length > 1) + { + stream.Position = stream.Length - 2; + b = stream.ReadByte(); + + if (b == '\r') + { + clip = 2; + } + } + } + + if (clip > 0) + { + stream.SetLength(stream.Length - clip); + } + } + + private void ParseParameterPart(Hashtable parameters, LineReader reader) + { + StringBuilder sb = new(); + + while (true) + { + byte[] line = reader.ReadByteLine(); + + if (line == null || CheckForBoundary(line)) + { + _parameters.Add(new ParameterPart(parameters["name"].ToString(), sb.ToString())); + break; + } + + sb.Append(Encoding.UTF8.GetString(line, 0, line.Length)); + } + } + + private void SkipPart(LineReader reader) + { + while (true) + { + byte[] line = reader.ReadByteLine(); + if (line == null || CheckForBoundary(line)) + break; + } + } + + private bool CheckForBoundary(byte[] line) + { + if (line == null) + { + _readEndBoundary = true; + return true; + } + + int length = _boundaryBinary.Length; + + if (line.Length < length) + { + return false; + } + + for (int i = 0; i < length; i++) + { + if (line[i] != _boundaryBinary[i]) + { + return false; + } + } + + // if we get here we have a boundary, check if it is the endboundary + if (line.Length >= length + 2 && line[length] == '-' && line[length + 1] == '-') + { + _readEndBoundary = true; + } + + return true; + } + + private void RemoveWellKnownParameters(Hashtable parameters) + { + string[] wellKnownParameters = new[] { "name", "filename", "filename*", "content-type", "content-disposition" }; + + foreach (string parameter in wellKnownParameters) + if (parameters.Contains(parameter)) + parameters.Remove(parameter); + } + } +} diff --git a/nanoFramework.WebServer/HttpMultipartParser/MultipartFormDataParserException.cs b/nanoFramework.WebServer/HttpMultipartParser/MultipartFormDataParserException.cs new file mode 100644 index 0000000..cb762a9 --- /dev/null +++ b/nanoFramework.WebServer/HttpMultipartParser/MultipartFormDataParserException.cs @@ -0,0 +1,21 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; + +namespace nanoFramework.WebServer.HttpMultipartParser +{ + /// + /// Specific exception while parsing a multipart form + /// + public class MultipartFormDataParserException : Exception + { + /// + /// Initializes a MultipartFormDataParserException + /// + /// + public MultipartFormDataParserException(string message) : base(message) + { + } + } +} diff --git a/nanoFramework.WebServer/HttpMultipartParser/ParameterPart.cs b/nanoFramework.WebServer/HttpMultipartParser/ParameterPart.cs new file mode 100644 index 0000000..409ed85 --- /dev/null +++ b/nanoFramework.WebServer/HttpMultipartParser/ParameterPart.cs @@ -0,0 +1,30 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace nanoFramework.WebServer.HttpMultipartParser +{ + /// Represents a single parameter extracted from a multipart/form-data stream. + public class ParameterPart + { + /// Initializes a new instance of the class. + /// The name. + /// The data. + public ParameterPart(string name, string data) + { + Name = name; + Data = data; + } + + /// Gets the data. + public string Data + { + get; + } + + /// Gets the name. + public string Name + { + get; + } + } +} diff --git a/nanoFramework.WebServer/nanoFramework.WebServer.nfproj b/nanoFramework.WebServer/nanoFramework.WebServer.nfproj index 089b793..c6a4d9f 100644 --- a/nanoFramework.WebServer/nanoFramework.WebServer.nfproj +++ b/nanoFramework.WebServer/nanoFramework.WebServer.nfproj @@ -32,10 +32,19 @@ - + + + + + + + + + + diff --git a/tests/nanoFramework.WebServer.Tests/HttpMultipartParser/FormDataProvider.cs b/tests/nanoFramework.WebServer.Tests/HttpMultipartParser/FormDataProvider.cs new file mode 100644 index 0000000..d5c05dc --- /dev/null +++ b/tests/nanoFramework.WebServer.Tests/HttpMultipartParser/FormDataProvider.cs @@ -0,0 +1,149 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.IO; +using System.Text; +using nanoFramework.Json; + +namespace nanoFramework.WebServer.Tests +{ + internal static class FormDataProvider + { + public static Stream CreateFormWithParameters() + { + string content = @"------WebKitFormBoundarySZFRSm4A2LAZPpUu +Content-Disposition: form-data; name=""param1"" + +value1 +------WebKitFormBoundarySZFRSm4A2LAZPpUu +Content-Disposition: form-data; name=""param2"" + +value2 +------WebKitFormBoundarySZFRSm4A2LAZPpUu--"; + + return new MemoryStream(Encoding.UTF8.GetBytes(content)) { Position = 0 }; + } + + public static Stream CreateFormWithFile(Person person) + { + string content = $@"------WebKitFormBoundarySZFRSm4A2LAZPpUu +Content-Disposition: form-data; name=""file""; filename=""somefile.json"" +Content-Type: application/json + +{JsonConvert.SerializeObject(person)} +------WebKitFormBoundarySZFRSm4A2LAZPpUu--"; + + return new MemoryStream(Encoding.UTF8.GetBytes(content)) { Position = 0 }; + } + + public static Stream CreateFormWithFiles(Person[] persons) + { + string content = $@"------WebKitFormBoundarySZFRSm4A2LAZPpUu +Content-Disposition: form-data; name=""file""; filename=""first.json"" +Content-Type: application/json + +{JsonConvert.SerializeObject(persons[0])} +------WebKitFormBoundarySZFRSm4A2LAZPpUu +Content-Disposition: form-data; name=""file""; filename=""second.json"" +Content-Type: application/json + +{JsonConvert.SerializeObject(persons[1])} +------WebKitFormBoundarySZFRSm4A2LAZPpUu--"; + + return new MemoryStream(Encoding.UTF8.GetBytes(content)) { Position = 0 }; + } + + public static Stream CreateFormWithEverything(Person[] persons) + { + string content = $@"------WebKitFormBoundarySZFRSm4A2LAZPpUu +Content-Disposition: form-data; name=""param1"" + +value1 +------WebKitFormBoundarySZFRSm4A2LAZPpUu +Content-Disposition: form-data; name=""param2"" + +value2 +------WebKitFormBoundarySZFRSm4A2LAZPpUu +Content-Disposition: form-data; name=""file""; filename=""first.json"" +Content-Type: application/json + +{JsonConvert.SerializeObject(persons[0])} +------WebKitFormBoundarySZFRSm4A2LAZPpUu +Content-Disposition: form-data; name=""file""; filename=""second.json"" +Content-Type: application/json + +{JsonConvert.SerializeObject(persons[1])} +------WebKitFormBoundarySZFRSm4A2LAZPpUu--"; + + return new MemoryStream(Encoding.UTF8.GetBytes(content)) { Position = 0 }; + } + + public static string CreateContent(int size) + { + StringBuilder sb = new(size); + + while (sb.Length < size) + sb.Append("HMLTncevuycfsoiS7cAHhiJq8CI2pTnHhJJb3MfwRB9qlK0VryH8AuJAQzhguP1Z"); + + return sb.ToString(); + } + + public static Stream CreateFormWithFile(string file) + { + string content = @$"------WebKitFormBoundarySZFRSm4A2LAZPpUu +Content-Disposition: form-data; name=""file""; filename=""somefile.json"" +Content-Type: application/json + +{file} +------WebKitFormBoundarySZFRSm4A2LAZPpUu--"; + + return new MemoryStream(Encoding.UTF8.GetBytes(content)) { Position = 0 }; + } + + public static Stream CreateEmptyForm() + { + string content = @"------WebKitFormBoundarySZFRSm4A2LAZPpUu + + + +------WebKitFormBoundarySZFRSm4A2LAZPpUu--"; + + return new MemoryStream(Encoding.UTF8.GetBytes(content)) { Position = 0 }; + } + + public static Stream CreateInvalidForm() + { + // missing the name parameter should fail + string content = @"------WebKitFormBoundarySZFRSm4A2LAZPpUu +Content-Disposition: form-data; invalid=""blah"" + +value1 +------WebKitFormBoundarySZFRSm4A2LAZPpUu--"; + + return new MemoryStream(Encoding.UTF8.GetBytes(content)) { Position = 0 }; + } + + public static Person[] CreatePersons() + { + return new Person[] + { + new() + { + Name = "Chuck Norris", + Age = 999 + }, + new() + { + Name = "Darth Vader", + Age = 9999 + } + }; + } + } + + internal class Person + { + public string Name { get; set; } + public int Age { get; set; } + } +} diff --git a/tests/nanoFramework.WebServer.Tests/HttpMultipartParser/MultipartFormDataHeaderTests.cs b/tests/nanoFramework.WebServer.Tests/HttpMultipartParser/MultipartFormDataHeaderTests.cs new file mode 100644 index 0000000..4d06a5d --- /dev/null +++ b/tests/nanoFramework.WebServer.Tests/HttpMultipartParser/MultipartFormDataHeaderTests.cs @@ -0,0 +1,65 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using nanoFramework.TestFramework; +using nanoFramework.WebServer.HttpMultipartParser; +using System.Collections; + +namespace nanoFramework.WebServer.Tests +{ + [TestClass] + public class MultipartFormDataHeaderTests + { + [TestMethod] + public void FormPartHeaderTest() + { + Hashtable headers = new(); + HeaderUtility.ParseHeaders("Content-Disposition: form-data; name=\"paramname\"", headers); + ValidateHeaders(headers, "Content-Disposition", "form-data"); + ValidateHeaders(headers, "name", "paramname"); + + headers.Clear(); + HeaderUtility.ParseHeaders("Content-Disposition: form-data; name=\"param;name\"", headers); + ValidateHeaders(headers, "Content-Disposition", "form-data"); + ValidateHeaders(headers, "name", "param;name"); + + headers.Clear(); + HeaderUtility.ParseHeaders("Content-Disposition: form-data; name=\"param=name\"", headers); + ValidateHeaders(headers, "Content-Disposition", "form-data"); + ValidateHeaders(headers, "name", "param=name"); + + headers.Clear(); + HeaderUtility.ParseHeaders("Content-Disposition: form-data; name=\"param:name\"", headers); + ValidateHeaders(headers, "Content-Disposition", "form-data"); + ValidateHeaders(headers, "name", "param:name"); + + headers.Clear(); + HeaderUtility.ParseHeaders("Content-Disposition: form-data; name=\"param name\"", headers); + ValidateHeaders(headers, "Content-Disposition", "form-data"); + ValidateHeaders(headers, "name", "param name"); + } + + [TestMethod] + public void FilePartHeaderTest() + { + Hashtable headers = new(); + HeaderUtility.ParseHeaders("Content-Disposition: form-data; name=\"file\"; filename=\"somefile.ext\"", headers); + ValidateHeaders(headers, "Content-Disposition", "form-data"); + ValidateHeaders(headers, "name", "file"); + ValidateHeaders(headers, "filename", "somefile.ext"); + + headers.Clear(); + HeaderUtility.ParseHeaders("Content-Disposition: form-data; name=\"f i;l=e\"; filename=\";some=fi-le.ext :\"", headers); + ValidateHeaders(headers, "Content-Disposition", "form-data"); + ValidateHeaders(headers, "name", "f i;l=e"); + ValidateHeaders(headers, "filename", ";some=fi-le.ext :"); + } + + private void ValidateHeaders(Hashtable headers, string key, string value) + { + Assert.IsNotNull(headers); + Assert.IsTrue(headers.Contains(key.ToLower())); + Assert.AreSame(headers[key.ToLower()], value); + } + } +} diff --git a/tests/nanoFramework.WebServer.Tests/HttpMultipartParser/MultipartFormDataParserTests.cs b/tests/nanoFramework.WebServer.Tests/HttpMultipartParser/MultipartFormDataParserTests.cs new file mode 100644 index 0000000..061bb10 --- /dev/null +++ b/tests/nanoFramework.WebServer.Tests/HttpMultipartParser/MultipartFormDataParserTests.cs @@ -0,0 +1,144 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using nanoFramework.Json; +using nanoFramework.TestFramework; +using nanoFramework.WebServer.HttpMultipartParser; +using System; +using System.IO; + +namespace nanoFramework.WebServer.Tests +{ + [TestClass] + public class MultipartFormDataParserTests + { + [TestMethod] + public void FormWithParametersTest() + { + Stream stream = FormDataProvider.CreateFormWithParameters(); + + MultipartFormDataParser parser = MultipartFormDataParser.Parse(stream); + + Assert.IsNotNull(parser); + + ParameterPart[] parameters = parser.Parameters; + Assert.IsTrue(parameters.Length == 2); + Assert.IsTrue(parameters[0].Name == "param1" && parameters[0].Data == "value1"); + Assert.IsTrue(parameters[1].Name == "param2" && parameters[1].Data == "value2"); + } + + [TestMethod] + public void FormWithFileTest() + { + Person[] persons = FormDataProvider.CreatePersons(); + + Stream stream = FormDataProvider.CreateFormWithFile(persons[0]); + + MultipartFormDataParser parser = MultipartFormDataParser.Parse(stream); + + Assert.IsNotNull(parser); + + ParameterPart[] parameters = parser.Parameters; + Assert.IsTrue(parameters.Length == 0); + + FilePart[] files = parser.Files; + Assert.IsTrue(files.Length == 1); + ValidateFile(files[0], "somefile.json", persons[0].Name, persons[0].Age); + } + + [TestMethod] + public void FormWithMultipleFilesTest() + { + Person[] persons = FormDataProvider.CreatePersons(); + + Stream stream = FormDataProvider.CreateFormWithFiles(persons); + + MultipartFormDataParser parser = MultipartFormDataParser.Parse(stream); + + Assert.IsNotNull(parser); + + ParameterPart[] parameters = parser.Parameters; + Assert.IsTrue(parameters.Length == 0); + + FilePart[] files = parser.Files; + Assert.IsTrue(files.Length == 2); + ValidateFile(files[0], "first.json", persons[0].Name, persons[0].Age); + ValidateFile(files[1], "second.json", persons[1].Name, persons[1].Age); + } + + [TestMethod] + public void FormWithEverythingTest() + { + Person[] persons = FormDataProvider.CreatePersons(); + + Stream stream = FormDataProvider.CreateFormWithEverything(persons); + + MultipartFormDataParser parser = MultipartFormDataParser.Parse(stream); + + Assert.IsNotNull(parser); + + ParameterPart[] parameters = parser.Parameters; + Assert.IsTrue(parameters.Length == 2); + Assert.IsTrue(parameters[0].Name == "param1" && parameters[0].Data == "value1"); + Assert.IsTrue(parameters[1].Name == "param2" && parameters[1].Data == "value2"); + + FilePart[] files = parser.Files; + Assert.IsTrue(files.Length == 2); + ValidateFile(files[0], "first.json", persons[0].Name, persons[0].Age); + ValidateFile(files[1], "second.json", persons[1].Name, persons[1].Age); + } + + [TestMethod] + public void FormWithLargeFileTest() + { + string fileIn = FormDataProvider.CreateContent(4096); + Stream stream = FormDataProvider.CreateFormWithFile(fileIn); + + MultipartFormDataParser parser = MultipartFormDataParser.Parse(stream); + + ParameterPart[] parameters = parser.Parameters; + Assert.IsTrue(parameters.Length == 0); + + FilePart[] files = parser.Files; + Assert.IsTrue(files.Length == 1); + + using var sr = new StreamReader(files[0].Data); + string fileOut = sr.ReadToEnd(); + + Assert.AreEqual(fileIn, fileOut); + } + + [TestMethod] + public void EmptyFormTest() + { + Stream stream = FormDataProvider.CreateEmptyForm(); + + MultipartFormDataParser parser = MultipartFormDataParser.Parse(stream, ignoreInvalidParts: true); + Assert.IsNotNull(parser); + + Assert.ThrowsException(typeof(MultipartFormDataParserException), () => MultipartFormDataParser.Parse(stream)); + } + + [TestMethod] + public void InvalidFormTest() + { + Stream stream = FormDataProvider.CreateInvalidForm(); + + MultipartFormDataParser parser = MultipartFormDataParser.Parse(stream, ignoreInvalidParts: true); + Assert.IsNotNull(parser); + + Assert.ThrowsException(typeof(MultipartFormDataParserException), () => MultipartFormDataParser.Parse(stream)); + } + + private void ValidateFile(FilePart file, string filename, string personName, int personAge) + { + Assert.IsTrue(file.FileName == filename); + StreamReader sr = new(file.Data); + string content = sr.ReadToEnd(); + + var person = JsonConvert.DeserializeObject(content, typeof(Person)) as Person; + Assert.IsNotNull(person); + Assert.IsTrue(person.Name == personName && person.Age == personAge); + } + } +} diff --git a/tests/nanoFramework.WebServer.Tests/nanoFramework.WebServer.Tests.nfproj b/tests/nanoFramework.WebServer.Tests/nanoFramework.WebServer.Tests.nfproj index ffc71ec..18d05a6 100644 --- a/tests/nanoFramework.WebServer.Tests/nanoFramework.WebServer.Tests.nfproj +++ b/tests/nanoFramework.WebServer.Tests/nanoFramework.WebServer.Tests.nfproj @@ -27,6 +27,9 @@ $(MSBuildProjectDirectory)\nano.runsettings + + + @@ -34,6 +37,9 @@ ..\..\packages\nanoFramework.CoreLibrary.1.15.5\lib\mscorlib.dll + + ..\..\packages\nanoFramework.Json.2.2.152\lib\nanoFramework.Json.dll + ..\..\packages\nanoFramework.Runtime.Events.1.11.18\lib\nanoFramework.Runtime.Events.dll diff --git a/tests/nanoFramework.WebServer.Tests/packages.config b/tests/nanoFramework.WebServer.Tests/packages.config index 4355ad0..c674580 100644 --- a/tests/nanoFramework.WebServer.Tests/packages.config +++ b/tests/nanoFramework.WebServer.Tests/packages.config @@ -1,6 +1,7 @@  + diff --git a/tests/nanoFramework.WebServer.Tests/packages.lock.json b/tests/nanoFramework.WebServer.Tests/packages.lock.json index 9ae94bc..58962c6 100644 --- a/tests/nanoFramework.WebServer.Tests/packages.lock.json +++ b/tests/nanoFramework.WebServer.Tests/packages.lock.json @@ -8,6 +8,12 @@ "resolved": "1.15.5", "contentHash": "u2+GvAp1uxLrGdILACAZy+EVKOs28EQ52j8Lz7599egXZ3GBGejjnR2ofhjMQwzrJLlgtyrsx8nSLngDfJNsAg==" }, + "nanoFramework.Json": { + "type": "Direct", + "requested": "[2.2.152, 2.2.152]", + "resolved": "2.2.152", + "contentHash": "scImuHOxgKfsoX5i0QfOfpwzBLWFmXyGNN5ICSEO6e4GaVtiBJTWXhxNhwEP8T0MYdIyAJiEZLoIIHjAwm5A9A==" + }, "nanoFramework.Runtime.Events": { "type": "Direct", "requested": "[1.11.18, 1.11.18]",