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 ~= "<"; 670 break; 671 case '>': 672 result ~= ">"; 673 break; 674 case '&': 675 result ~= "&"; 676 break; 677 case '\xDF': // the beta-like symbol 678 result ~= "ß"; 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 }