1 /**
2  * Concepts shared between HTTP clients and servers.
3  *
4  * License:
5  *   This Source Code Form is subject to the terms of
6  *   the Mozilla Public License, v. 2.0. If a copy of
7  *   the MPL was not distributed with this file, You
8  *   can obtain one at http://mozilla.org/MPL/2.0/.
9  *
10  * Authors:
11  *   Stéphan Kochen <stephan@kochen.nl>
12  *   Vladimir Panteleev <ae@cy.md>
13  *   Simon Arlott
14  */
15 
16 module ae.net.http.common;
17 
18 import core.time;
19 
20 import std.algorithm;
21 import std.array;
22 import std..string;
23 import std.conv;
24 import std.ascii;
25 import std.exception;
26 import std.datetime;
27 import std.typecons : tuple;
28 
29 import ae.net.ietf.headers;
30 import ae.sys.data;
31 import ae.utils.array : amap, afilter, auniq, asort;
32 import ae.utils.text;
33 import ae.utils.time;
34 import zlib = ae.utils.zlib;
35 import gzip = ae.utils.gzip;
36 
37 /// Base HTTP message class
38 private abstract class HttpMessage
39 {
40 public:
41 	string protocol = "http";
42 	string protocolVersion = "1.0";
43 	Headers headers;
44 	DataVec data;
45 	SysTime creationTime;
46 
47 	this()
48 	{
49 		creationTime = Clock.currTime();
50 	}
51 
52 	@property Duration age()
53 	{
54 		return Clock.currTime() - creationTime;
55 	}
56 }
57 
58 /// HTTP request class
59 class HttpRequest : HttpMessage
60 {
61 public:
62 	/// HTTP method, e.g., "GET".
63 	string method = "GET";
64 
65 	/// If this request is going through a HTTP proxy server, this
66 	/// should be set to its address.
67 	string proxy;
68 
69 	this()
70 	{
71 	} ///
72 
73 	this(string url)
74 	{
75 		this.resource = url;
76 	} ///
77 
78 	/// Resource part of URL (everything after the hostname)
79 	@property string resource()
80 	{
81 		return _resource;
82 	}
83 
84 	/// Set the resource part of the URL, or the entire URL.
85 	/// Setting the resource to a full URL will fill in the Host header, as well.
86 	@property void resource(string value)
87 	{
88 		_resource = value;
89 
90 		// applies to both Client/Server as some clients put a full URL in the GET line instead of using a "Host" header
91 		string protocol;
92 		if (_resource.asciiStartsWith("http://"))
93 			protocol = "http";
94 		else
95 		if (_resource.asciiStartsWith("https://"))
96 			protocol = "https";
97 
98 		if (protocol)
99 		{
100 			this.protocol = protocol;
101 
102 			value = value[protocol.length+3..$];
103 			auto pathstart = value.indexOf('/');
104 			if (pathstart == -1)
105 			{
106 				host = value;
107 				_resource = "/";
108 			}
109 			else
110 			{
111 				host = value[0..pathstart];
112 				_resource = value[pathstart..$];
113 			}
114 			auto portstart = host().indexOf(':');
115 			if (portstart != -1)
116 			{
117 				port = to!ushort(host[portstart+1..$]);
118 				host = host[0..portstart];
119 			}
120 		}
121 	}
122 
123 	/// The hostname, without the port number
124 	@property string host()
125 	{
126 		string _host = headers.get("Host", null);
127 		auto colon = _host.lastIndexOf(":");
128 		return colon<0 ? _host : _host[0..colon];
129 	}
130 
131 	/// Sets the hostname (and the `"Host"` header).
132 	/// Must not include a port number.
133 	/// Does not change the previously-set port number.
134 	@property void host(string _host)
135 	{
136 		auto _port = this.port;
137 		headers["Host"] = _port==protocolDefaultPort ? _host : _host ~ ":" ~ text(_port);
138 	}
139 
140 	/// Retrieves the default port number for the currently set `protocol`.
141 	@property ushort protocolDefaultPort()
142 	{
143 		switch (protocol)
144 		{
145 			case "http":
146 				return 80;
147 			case "https":
148 				return 443;
149 			default:
150 				throw new Exception("Unknown protocol: " ~ protocol);
151 		}
152 	}
153 
154 	/// Port number, from `"Host"` header.
155 	/// Defaults to `protocolDefaultPort`.
156 	@property ushort port()
157 	{
158 		if ("Host" in headers)
159 		{
160 			string _host = headers["Host"];
161 			auto colon = _host.lastIndexOf(":");
162 			return colon<0 ? protocolDefaultPort : to!ushort(_host[colon+1..$]);
163 		}
164 		else
165 			return _port ? _port : protocolDefaultPort;
166 	}
167 
168 	/// Sets the port number.
169 	/// If it is equal to `protocolDefaultPort`, then it is not
170 	/// included in the `"Host"` header.
171 	@property void port(ushort _port)
172 	{
173 		if ("Host" in headers)
174 		{
175 			if (_port == protocolDefaultPort)
176 				headers["Host"] = this.host;
177 			else
178 				headers["Host"] = this.host ~ ":" ~ text(_port);
179 		}
180 		else
181 			this._port = _port;
182 	}
183 
184 	/// Path part of request (until the `'?'`).
185 	@property string path()
186 	{
187 		auto p = resource.indexOf('?');
188 		if (p >= 0)
189 			return resource[0..p];
190 		else
191 			return resource;
192 	}
193 
194 	/// Query string part of request (atfer the `'?'`).
195 	@property string queryString()
196 	{
197 		auto p = resource.indexOf('?');
198 		if (p >= 0)
199 			return resource[p+1..$];
200 		else
201 			return null;
202 	}
203 
204 	/// ditto
205 	@property void queryString(string value)
206 	{
207 		auto p = resource.indexOf('?');
208 		if (p >= 0)
209 			resource = resource[0..p];
210 		if (value)
211 			resource = resource ~ '?' ~ value;
212 	}
213 
214 	/// The query string parameters.
215 	@property UrlParameters urlParameters()
216 	{
217 		return decodeUrlParameters(queryString);
218 	}
219 
220 	/// ditto
221 	@property void urlParameters(UrlParameters parameters)
222 	{
223 		queryString = encodeUrlParameters(parameters);
224 	}
225 
226 	/// URL without resource (protocol, host and port).
227 	@property string root()
228 	{
229 		return protocol ~ "://" ~ host ~ (port==protocolDefaultPort ? null : ":" ~ to!string(port));
230 	}
231 
232 	/// Full URL.
233 	@property string url()
234 	{
235 		return root ~ resource;
236 	}
237 
238 	/// Full URL without query parameters or fragment.
239 	@property string baseURL()
240 	{
241 		return root ~ resource.findSplit("?")[0];
242 	}
243 
244 	/// The hostname part of the proxy address, if any.
245 	@property string proxyHost()
246 	{
247 		auto portstart = proxy.indexOf(':');
248 		if (portstart != -1)
249 			return proxy[0..portstart];
250 		return proxy;
251 	}
252 
253 	/// The port number of the proxy address if it specified, otherwise `80`.
254 	@property ushort proxyPort()
255 	{
256 		auto portstart = proxy.indexOf(':');
257 		if (portstart != -1)
258 			return to!ushort(proxy[portstart+1..$]);
259 		return 80;
260 	}
261 
262 	/// Parse the first line in a HTTP request ("METHOD /resource HTTP/1.x").
263 	void parseRequestLine(string reqLine)
264 	{
265 		enforce(reqLine.length > 10, "Request line too short");
266 		auto methodEnd = reqLine.indexOf(' ');
267 		enforce(methodEnd > 0, "Malformed request line");
268 		method = reqLine[0 .. methodEnd];
269 		reqLine = reqLine[methodEnd + 1 .. reqLine.length];
270 
271 		auto resourceEnd = reqLine.lastIndexOf(' ');
272 		enforce(resourceEnd > 0, "Malformed request line");
273 		resource = reqLine[0 .. resourceEnd];
274 
275 		string protocol = reqLine[resourceEnd+1..$];
276 		enforce(protocol.startsWith("HTTP/"));
277 		protocolVersion = protocol[5..$];
278 	}
279 
280 	/// Decodes submitted form data, and returns an AA of values.
281 	UrlParameters decodePostData()
282 	{
283 		auto contentType = headers.get("Content-Type", "").decodeTokenHeader;
284 
285 		switch (contentType.value)
286 		{
287 			case "application/x-www-form-urlencoded":
288 				return decodeUrlParameters(cast(string)data.joinToHeap());
289 			case "multipart/form-data":
290 				return decodeMultipart(data.joinData, contentType.properties.get("boundary", null))
291 					.map!(part => tuple(part.headers.get("Content-Disposition", null).decodeTokenHeader.properties.get("name", null), cast(string)part.data.toHeap()))
292 					.UrlParameters;
293 			case "":
294 				throw new Exception("No Content-Type");
295 			default:
296 				throw new Exception("Unknown Content-Type: " ~ contentType.value);
297 		}
298 	}
299 
300 	/// Get list of hosts as specified in headers (e.g. X-Forwarded-For).
301 	/// First item in returned array is the node furthest away.
302 	/// Duplicates are removed.
303 	/// Specify socket remote address in remoteHost to add it to the list.
304 	deprecated("Insecure, use HttpServer.remoteIPHeader")
305 	string[] remoteHosts(string remoteHost = null)
306 	{
307 		return
308 			(headers.get("X-Forwarded-For", null).split(",").amap!(std..string.strip)() ~
309 			 headers.get("X-Forwarded-Host", null) ~
310 			 remoteHost)
311 			.afilter!`a && a != "unknown"`()
312 			.auniq();
313 	}
314 
315 	deprecated unittest
316 	{
317 		auto req = new HttpRequest();
318 		assert(req.remoteHosts() == []);
319 		assert(req.remoteHosts("3.3.3.3") == ["3.3.3.3"]);
320 
321 		req.headers["X-Forwarded-For"] = "1.1.1.1, 2.2.2.2";
322 		req.headers["X-Forwarded-Host"] = "2.2.2.2";
323 		assert(req.remoteHosts("3.3.3.3") == ["1.1.1.1", "2.2.2.2", "3.3.3.3"]);
324 	}
325 
326 	/// Basic cookie parsing
327 	string[string] getCookies()
328 	{
329 		string[string] cookies;
330 		foreach (segment; headers.get("Cookie", null).split(";"))
331 		{
332 			segment = segment.strip();
333 			auto p = segment.indexOf('=');
334 			if (p > 0)
335 				cookies[segment[0..p]] = segment[p+1..$];
336 		}
337 		return cookies;
338 	}
339 
340 private:
341 	string _resource;
342 	ushort _port = 0; // used only when no "Host" in headers; otherwise, taken from there
343 }
344 
345 /// HTTP response status codes
346 enum HttpStatusCode : ushort
347 {
348 	None                         =   0,  ///
349 
350 	Continue                     = 100,  ///
351 	SwitchingProtocols           = 101,  ///
352 
353 	OK                           = 200,  ///
354 	Created                      = 201,  ///
355 	Accepted                     = 202,  ///
356 	NonAuthoritativeInformation  = 203,  ///
357 	NoContent                    = 204,  ///
358 	ResetContent                 = 205,  ///
359 	PartialContent               = 206,  ///
360 
361 	MultipleChoices              = 300,  ///
362 	MovedPermanently             = 301,  ///
363 	Found                        = 302,  ///
364 	SeeOther                     = 303,  ///
365 	NotModified                  = 304,  ///
366 	UseProxy                     = 305,  ///
367 	//(Unused)                   = 306,  ///
368 	TemporaryRedirect            = 307,  ///
369 
370 	BadRequest                   = 400,  ///
371 	Unauthorized                 = 401,  ///
372 	PaymentRequired              = 402,  ///
373 	Forbidden                    = 403,  ///
374 	NotFound                     = 404,  ///
375 	MethodNotAllowed             = 405,  ///
376 	NotAcceptable                = 406,  ///
377 	ProxyAuthenticationRequired  = 407,  ///
378 	RequestTimeout               = 408,  ///
379 	Conflict                     = 409,  ///
380 	Gone                         = 410,  ///
381 	LengthRequired               = 411,  ///
382 	PreconditionFailed           = 412,  ///
383 	RequestEntityTooLarge        = 413,  ///
384 	RequestUriTooLong            = 414,  ///
385 	UnsupportedMediaType         = 415,  ///
386 	RequestedRangeNotSatisfiable = 416,  ///
387 	ExpectationFailed            = 417,  ///
388 
389 	InternalServerError          = 500,  ///
390 	NotImplemented               = 501,  ///
391 	BadGateway                   = 502,  ///
392 	ServiceUnavailable           = 503,  ///
393 	GatewayTimeout               = 504,  ///
394 	HttpVersionNotSupported      = 505,  ///
395 }
396 
397 /// HTTP reply class
398 class HttpResponse : HttpMessage
399 {
400 public:
401 	HttpStatusCode status; /// HTTP status code
402 	string statusMessage; /// HTTP status message, if one was supplied
403 
404 	/// What Zlib compression level to use when compressing the reply.
405 	/// Set to a negative value to disable compression.
406 	int compressionLevel = 1;
407 
408 	/// Returns the message corresponding to the given `HttpStatusCode`,
409 	/// or `null` if the code is unknown.
410 	static string getStatusMessage(HttpStatusCode code)
411 	{
412 		switch(code)
413 		{
414 			case 100: return "Continue";
415 			case 101: return "Switching Protocols";
416 
417 			case 200: return "OK";
418 			case 201: return "Created";
419 			case 202: return "Accepted";
420 			case 203: return "Non-Authoritative Information";
421 			case 204: return "No Content";
422 			case 205: return "Reset Content";
423 			case 206: return "Partial Content";
424 			case 300: return "Multiple Choices";
425 			case 301: return "Moved Permanently";
426 			case 302: return "Found";
427 			case 303: return "See Other";
428 			case 304: return "Not Modified";
429 			case 305: return "Use Proxy";
430 			case 306: return "(Unused)";
431 			case 307: return "Temporary Redirect";
432 
433 			case 400: return "Bad Request";
434 			case 401: return "Unauthorized";
435 			case 402: return "Payment Required";
436 			case 403: return "Forbidden";
437 			case 404: return "Not Found";
438 			case 405: return "Method Not Allowed";
439 			case 406: return "Not Acceptable";
440 			case 407: return "Proxy Authentication Required";
441 			case 408: return "Request Timeout";
442 			case 409: return "Conflict";
443 			case 410: return "Gone";
444 			case 411: return "Length Required";
445 			case 412: return "Precondition Failed";
446 			case 413: return "Request Entity Too Large";
447 			case 414: return "Request-URI Too Long";
448 			case 415: return "Unsupported Media Type";
449 			case 416: return "Requested Range Not Satisfiable";
450 			case 417: return "Expectation Failed";
451 
452 			case 500: return "Internal Server Error";
453 			case 501: return "Not Implemented";
454 			case 502: return "Bad Gateway";
455 			case 503: return "Service Unavailable";
456 			case 504: return "Gateway Timeout";
457 			case 505: return "HTTP Version Not Supported";
458 			default: return null;
459 		}
460 	}
461 
462 	/// Set the response status code and message
463 	void setStatus(HttpStatusCode code)
464 	{
465 		status = code;
466 		statusMessage = getStatusMessage(code);
467 	}
468 
469 	/// Initializes this `HttpResponse` with the given `statusLine`.
470 	final void parseStatusLine(string statusLine)
471 	{
472 		auto versionEnd = statusLine.indexOf(' ');
473 		if (versionEnd == -1)
474 			throw new Exception("Malformed status line");
475 		protocolVersion = statusLine[0..versionEnd];
476 		statusLine = statusLine[versionEnd+1..statusLine.length];
477 
478 		auto statusEnd = statusLine.indexOf(' ');
479 		string statusCode;
480 		if (statusEnd >= 0)
481 		{
482 			statusCode = statusLine[0 .. statusEnd];
483 			statusMessage = statusLine[statusEnd+1..statusLine.length];
484 		}
485 		else
486 		{
487 			statusCode = statusLine;
488 			statusMessage = null;
489 		}
490 		status = cast(HttpStatusCode)to!ushort(statusCode);
491 	}
492 
493 	/// If the data is compressed, return the decompressed data
494 	// this is not a property on purpose - to avoid using it multiple times as it will unpack the data on every access
495 	// TODO: there is no reason for above limitation
496 	Data getContent()
497 	{
498 		if ("Content-Encoding" in headers && headers["Content-Encoding"]=="deflate")
499 			return zlib.uncompress(data[]).joinData();
500 		else
501 		if ("Content-Encoding" in headers && headers["Content-Encoding"]=="gzip")
502 			return gzip.uncompress(data[]).joinData();
503 		else
504 			return data.joinData();
505 		assert(0);
506 	}
507 
508 	protected void compressWithDeflate()
509 	{
510 		assert(compressionLevel >= 0);
511 		data = zlib.compress(data[], zlib.ZlibOptions(compressionLevel));
512 	}
513 
514 	protected void compressWithGzip()
515 	{
516 		assert(compressionLevel >= 0);
517 		data = gzip.compress(data[], zlib.ZlibOptions(compressionLevel));
518 	}
519 
520 	/// Called by the server to compress content, if possible/appropriate
521 	final package void optimizeData(ref const Headers requestHeaders)
522 	{
523 		if (compressionLevel < 0)
524 			return;
525 		auto acceptEncoding = requestHeaders.get("Accept-Encoding", null);
526 		if (acceptEncoding && "Content-Encoding" !in headers)
527 		{
528 			auto contentType = headers.get("Content-Type", null);
529 			if (contentType.startsWith("text/")
530 			 || contentType == "application/json"
531 			 || contentType == "image/vnd.microsoft.icon"
532 			 || contentType == "image/svg+xml")
533 			{
534 				auto supported = parseItemList(acceptEncoding) ~ ["*"];
535 				foreach (method; supported)
536 					switch (method)
537 					{
538 						case "deflate":
539 							headers["Content-Encoding"] = method;
540 							headers.add("Vary", "Accept-Encoding");
541 							compressWithDeflate();
542 							return;
543 						case "gzip":
544 							headers["Content-Encoding"] = method;
545 							headers.add("Vary", "Accept-Encoding");
546 							compressWithGzip();
547 							return;
548 						case "*":
549 							if("Content-Encoding" in headers)
550 								headers.remove("Content-Encoding");
551 							return;
552 						default:
553 							break;
554 					}
555 				assert(0);
556 			}
557 		}
558 	}
559 
560 	/// Called by the server to apply range request.
561 	final package void sliceData(ref const Headers requestHeaders)
562 	{
563 		if (status == HttpStatusCode.OK)
564 		{
565 			if ("If-Modified-Since" in requestHeaders &&
566 				"Last-Modified" in headers &&
567 				headers["Last-Modified"].parseTime!(TimeFormats.HTTP) <= requestHeaders["If-Modified-Since"].parseTime!(TimeFormats.HTTP))
568 			{
569 				setStatus(HttpStatusCode.NotModified);
570 				data = null;
571 				return;
572 			}
573 
574 			headers["Accept-Ranges"] = "bytes";
575 			auto prange = "Range" in requestHeaders;
576 			if (prange && (*prange).startsWith("bytes="))
577 			{
578 				auto ranges = (*prange)[6..$].split(",")[0].split("-").map!(s => s.length ? s.to!size_t : size_t.max)().array();
579 				enforce(ranges.length == 2, "Bad range request");
580 				ranges[1]++;
581 				auto datum = this.data.bytes;
582 				auto datumLength = datum.length;
583 				if (ranges[1] == size_t.min) // was not specified (size_t.max overflowed into 0)
584 					ranges[1] = datumLength;
585 				if (ranges[0] >= datumLength || ranges[0] >= ranges[1] || ranges[1] > datumLength)
586 				{
587 					//writeError(HttpStatusCode.RequestedRangeNotSatisfiable);
588 					setStatus(HttpStatusCode.RequestedRangeNotSatisfiable);
589 					data = DataVec(Data(statusMessage));
590 					return;
591 				}
592 				else
593 				{
594 					setStatus(HttpStatusCode.PartialContent);
595 					this.data = datum[ranges[0]..ranges[1]];
596 					headers["Content-Range"] = "bytes %d-%d/%d".format(ranges[0], ranges[0] + this.data.bytes.length - 1, datumLength);
597 				}
598 			}
599 		}
600 	}
601 }
602 
603 /// Sets headers to request clients to not cache a response.
604 void disableCache(ref Headers headers)
605 {
606 	headers["Expires"] = "Mon, 26 Jul 1997 05:00:00 GMT";  // disable IE caching
607 	//headers["Last-Modified"] = "" . gmdate( "D, d M Y H:i:s" ) . " GMT";
608 	headers["Cache-Control"] = "no-cache, must-revalidate";
609 	headers["Pragma"] = "no-cache";
610 }
611 
612 /// Sets headers to request clients to cache a response indefinitely.
613 void cacheForever(ref Headers headers)
614 {
615 	headers["Expires"] = httpTime(Clock.currTime().add!"years"(1));
616 	headers["Cache-Control"] = "public, max-age=31536000";
617 }
618 
619 /// Formats a timestamp in the format used by HTTP (RFC 2822).
620 string httpTime(SysTime time)
621 {
622 	time.timezone = UTC();
623 	return time.formatTime!(TimeFormats.HTTP)();
624 }
625 
626 import std.algorithm : sort;
627 
628 /// Parses a list in the format of "a, b, c;q=0.5, d" and returns
629 /// an array of items sorted by "q" (["a", "b", "d", "c"])
630 string[] parseItemList(string s)
631 {
632 	static struct Item
633 	{
634 		float q = 1.0;
635 		string str;
636 
637 		this(string s)
638 		{
639 			auto params = s.split(";");
640 			if (!params.length) return;
641 			str = params[0];
642 			foreach (param; params[1..$])
643 				if (param.startsWith("q="))
644 					q = to!float(param[2..$]);
645 		}
646 	}
647 
648 	return s
649 		.split(",")
650 		.amap!(a => Item(strip(a)))()
651 		.asort!`a.q > b.q`()
652 		.amap!`a.str`();
653 }
654 
655 unittest
656 {
657 	assert(parseItemList("a, b, c;q=0.5, d") == ["a", "b", "d", "c"]);
658 }
659 
660 // TODO: optimize / move to HtmlWriter
661 deprecated("Use ae.utils.xml.entities")
662 string httpEscape(string str)
663 {
664 	string result;
665 	foreach(c;str)
666 		switch(c)
667 		{
668 			case '<':
669 				result ~= "&lt;";
670 				break;
671 			case '>':
672 				result ~= "&gt;";
673 				break;
674 			case '&':
675 				result ~= "&amp;";
676 				break;
677 			case '\xDF':  // the beta-like symbol
678 				result ~= "&szlig;";
679 				break;
680 			default:
681 				result ~= [c];
682 		}
683 	return result;
684 }
685 
686 public import ae.net.ietf.url : UrlParameters, encodeUrlParameter, encodeUrlParameters, decodeUrlParameter, decodeUrlParameters;
687 
688 /// Represents a part from a multipart/* message.
689 struct MultipartPart
690 {
691 	/// The part's individual headers.
692 	Headers headers;
693 
694 	/// The part's contents.
695 	Data data;
696 }
697 
698 /// Encode a multipart body with the given parts and boundary.
699 Data encodeMultipart(MultipartPart[] parts, string boundary)
700 {
701 	Data data;
702 	foreach (ref part; parts)
703 	{
704 		data ~= "--" ~ boundary ~ "\r\n";
705 		foreach (name, value; part.headers)
706 			data ~= name ~ ": " ~ value ~ "\r\n";
707 		data ~= "\r\n";
708 		assert((cast(string)part.data.contents).indexOf(boundary) < 0);
709 		data ~= part.data;
710 		data ~= "\r\n";
711 	}
712 	data ~= "--" ~ boundary ~ "--\r\n";
713 	return data;
714 }
715 
716 /// Decode a multipart body using the given boundary.
717 MultipartPart[] decodeMultipart(Data data, string boundary)
718 {
719 	auto s = cast(char[])data.contents;
720 	auto term = "\r\n--" ~ boundary ~ "--\r\n";
721 	enforce(s.endsWith(term), "Bad multipart terminator");
722 	s = s[0..$-term.length];
723 	auto delim = "--" ~ boundary ~ "\r\n";
724 	enforce(s.skipOver(delim), "Bad multipart start");
725 	delim = "\r\n" ~ delim;
726 	auto parts = s.split(delim);
727 	MultipartPart[] result;
728 	foreach (part; parts)
729 	{
730 		auto segs = part.findSplit("\r\n\r\n");
731 		enforce(segs[1], "Can't find headers in multipart part");
732 		MultipartPart p;
733 		foreach (line; segs[0].split("\r\n"))
734 		{
735 			auto hparts = line.findSplit(":");
736 			p.headers[hparts[0].strip.idup] = hparts[2].strip.idup;
737 		}
738 		p.data = Data(segs[2]);
739 		result ~= p;
740 	}
741 	return result;
742 }
743 
744 unittest
745 {
746 	auto parts = [
747 		MultipartPart(Headers(["Foo" : "bar"]), Data.init),
748 		MultipartPart(Headers(["Baz" : "quux", "Frob" : "xyzzy"]), Data("Content goes here\xFF")),
749 	];
750 	auto boundary = "abcde";
751 	auto parts2 = parts.encodeMultipart(boundary).decodeMultipart(boundary);
752 	assert(parts2.length == parts.length);
753 	foreach (p; 0..parts.length)
754 	{
755 		assert(parts[p].headers == parts2[p].headers);
756 		assert(parts[p].data.contents == parts2[p].data.contents);
757 	}
758 }
759 
760 private bool asciiStartsWith(string s, string prefix)
761 {
762 	if (s.length < prefix.length)
763 		return false;
764 	import std.ascii;
765 	foreach (i, c; prefix)
766 		if (toLower(c) != toLower(s[i]))
767 			return false;
768 	return true;
769 }