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:

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]",