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