1 /** 2 Common classes for HTTP clients and servers. 3 4 Copyright: © 2012-2015 Sönke Ludwig 5 License: Subject to the terms of the MIT license, as written in the included LICENSE.txt file. 6 Authors: Sönke Ludwig, Jan Krüger 7 */ 8 module vibe.http.common; 9 10 public import vibe.http.status; 11 12 import vibe.container.dictionarylist; 13 import vibe.container.internal.appender; 14 import vibe.container.internal.utilallocator; 15 import vibe.core.log; 16 import vibe.core.net; 17 import vibe.inet.message; 18 import vibe.stream.operations; 19 import vibe.textfilter.urlencode : urlEncode, urlDecode; 20 import vibe.internal.freelistref; 21 import vibe.internal.interfaceproxy : InterfaceProxy, interfaceProxy; 22 23 import std.algorithm; 24 import std.array; 25 import std.conv; 26 import std.datetime; 27 import std.exception; 28 import std.format; 29 import std.range : isOutputRange; 30 import std.string; 31 import std.typecons; 32 import std.uni: asLowerCase, sicmp; 33 34 35 enum HTTPVersion { 36 HTTP_1_0, 37 HTTP_1_1 38 } 39 40 41 enum HTTPMethod { 42 // HTTP standard, RFC 2616 43 GET, 44 HEAD, 45 PUT, 46 POST, 47 PATCH, 48 DELETE, 49 OPTIONS, 50 TRACE, 51 CONNECT, 52 53 // WEBDAV extensions, RFC 2518 54 PROPFIND, 55 PROPPATCH, 56 MKCOL, 57 COPY, 58 MOVE, 59 LOCK, 60 UNLOCK, 61 62 // Versioning Extensions to WebDAV, RFC 3253 63 VERSIONCONTROL, 64 REPORT, 65 CHECKOUT, 66 CHECKIN, 67 UNCHECKOUT, 68 MKWORKSPACE, 69 UPDATE, 70 LABEL, 71 MERGE, 72 BASELINECONTROL, 73 MKACTIVITY, 74 75 // Ordered Collections Protocol, RFC 3648 76 ORDERPATCH, 77 78 // Access Control Protocol, RFC 3744 79 ACL 80 } 81 82 83 /** 84 Returns the string representation of the given HttpMethod. 85 */ 86 string httpMethodString(HTTPMethod m) 87 @safe nothrow { 88 switch(m){ 89 case HTTPMethod.BASELINECONTROL: return "BASELINE-CONTROL"; 90 case HTTPMethod.VERSIONCONTROL: return "VERSION-CONTROL"; 91 default: 92 try return to!string(m); 93 catch (Exception e) assert(false, e.msg); 94 } 95 } 96 97 /** 98 Returns the HttpMethod value matching the given HTTP method string. 99 */ 100 HTTPMethod httpMethodFromString(string str) 101 @safe { 102 switch(str){ 103 default: throw new Exception("Invalid HTTP method: "~str); 104 // HTTP standard, RFC 2616 105 case "GET": return HTTPMethod.GET; 106 case "HEAD": return HTTPMethod.HEAD; 107 case "PUT": return HTTPMethod.PUT; 108 case "POST": return HTTPMethod.POST; 109 case "PATCH": return HTTPMethod.PATCH; 110 case "DELETE": return HTTPMethod.DELETE; 111 case "OPTIONS": return HTTPMethod.OPTIONS; 112 case "TRACE": return HTTPMethod.TRACE; 113 case "CONNECT": return HTTPMethod.CONNECT; 114 115 // WEBDAV extensions, RFC 2518 116 case "PROPFIND": return HTTPMethod.PROPFIND; 117 case "PROPPATCH": return HTTPMethod.PROPPATCH; 118 case "MKCOL": return HTTPMethod.MKCOL; 119 case "COPY": return HTTPMethod.COPY; 120 case "MOVE": return HTTPMethod.MOVE; 121 case "LOCK": return HTTPMethod.LOCK; 122 case "UNLOCK": return HTTPMethod.UNLOCK; 123 124 // Versioning Extensions to WebDAV, RFC 3253 125 case "VERSION-CONTROL": return HTTPMethod.VERSIONCONTROL; 126 case "REPORT": return HTTPMethod.REPORT; 127 case "CHECKOUT": return HTTPMethod.CHECKOUT; 128 case "CHECKIN": return HTTPMethod.CHECKIN; 129 case "UNCHECKOUT": return HTTPMethod.UNCHECKOUT; 130 case "MKWORKSPACE": return HTTPMethod.MKWORKSPACE; 131 case "UPDATE": return HTTPMethod.UPDATE; 132 case "LABEL": return HTTPMethod.LABEL; 133 case "MERGE": return HTTPMethod.MERGE; 134 case "BASELINE-CONTROL": return HTTPMethod.BASELINECONTROL; 135 case "MKACTIVITY": return HTTPMethod.MKACTIVITY; 136 137 // Ordered Collections Protocol, RFC 3648 138 case "ORDERPATCH": return HTTPMethod.ORDERPATCH; 139 140 // Access Control Protocol, RFC 3744 141 case "ACL": return HTTPMethod.ACL; 142 } 143 } 144 145 unittest 146 { 147 assert(httpMethodString(HTTPMethod.GET) == "GET"); 148 assert(httpMethodString(HTTPMethod.UNLOCK) == "UNLOCK"); 149 assert(httpMethodString(HTTPMethod.VERSIONCONTROL) == "VERSION-CONTROL"); 150 assert(httpMethodString(HTTPMethod.BASELINECONTROL) == "BASELINE-CONTROL"); 151 assert(httpMethodFromString("GET") == HTTPMethod.GET); 152 assert(httpMethodFromString("UNLOCK") == HTTPMethod.UNLOCK); 153 assert(httpMethodFromString("VERSION-CONTROL") == HTTPMethod.VERSIONCONTROL); 154 } 155 156 157 /** 158 Utility function that throws a HTTPStatusException if the _condition is not met. 159 */ 160 T enforceHTTP(T)(T condition, HTTPStatus statusCode, lazy string message = null, string file = __FILE__, typeof(__LINE__) line = __LINE__) 161 { 162 return enforce(condition, new HTTPStatusException(statusCode, message, file, line)); 163 } 164 165 /** 166 Utility function that throws a HTTPStatusException with status code "400 Bad Request" if the _condition is not met. 167 */ 168 T enforceBadRequest(T)(T condition, lazy string message = null, string file = __FILE__, typeof(__LINE__) line = __LINE__) 169 { 170 return enforceHTTP(condition, HTTPStatus.badRequest, message, file, line); 171 } 172 173 174 /** 175 Represents an HTTP request made to a server. 176 */ 177 class HTTPRequest { 178 @safe: 179 180 protected { 181 InterfaceProxy!Stream m_conn; 182 } 183 184 public { 185 /// The HTTP protocol version used for the request 186 HTTPVersion httpVersion = HTTPVersion.HTTP_1_1; 187 188 /// The HTTP _method of the request 189 HTTPMethod method = HTTPMethod.GET; 190 191 /** The request URI 192 193 Note that the request URI usually does not include the global 194 'http://server' part, but only the local path and a query string. 195 A possible exception is a proxy server, which will get full URLs. 196 */ 197 string requestURI = "/"; 198 199 /// Compatibility alias - scheduled for deprecation 200 alias requestURL = requestURI; 201 202 /// All request _headers 203 InetHeaderMap headers; 204 } 205 206 protected this(InterfaceProxy!Stream conn) 207 { 208 m_conn = conn; 209 } 210 211 protected this() 212 { 213 } 214 215 scope: 216 217 public override string toString() 218 { 219 return httpMethodString(method) ~ " " ~ requestURL ~ " " ~ getHTTPVersionString(httpVersion); 220 } 221 222 /** Shortcut to the 'Host' header (always present for HTTP 1.1) 223 */ 224 @property string host() const { auto ph = "Host" in headers; return ph ? *ph : null; } 225 /// ditto 226 @property void host(string v) { headers["Host"] = v; } 227 228 /** Returns the mime type part of the 'Content-Type' header. 229 230 This function gets the pure mime type (e.g. "text/plain") 231 without any supplimentary parameters such as "charset=...". 232 Use contentTypeParameters to get any parameter string or 233 headers["Content-Type"] to get the raw value. 234 */ 235 @property string contentType() 236 const { 237 auto pv = "Content-Type" in headers; 238 if( !pv ) return null; 239 auto idx = std..string.indexOf(*pv, ';'); 240 return idx >= 0 ? (*pv)[0 .. idx] : *pv; 241 } 242 /// ditto 243 @property void contentType(string ct) { headers["Content-Type"] = ct; } 244 245 /** Returns any supplementary parameters of the 'Content-Type' header. 246 247 This is a semicolon separated ist of key/value pairs. Usually, if set, 248 this contains the character set used for text based content types. 249 */ 250 @property string contentTypeParameters() 251 const { 252 auto pv = "Content-Type" in headers; 253 if( !pv ) return null; 254 auto idx = std..string.indexOf(*pv, ';'); 255 return idx >= 0 ? (*pv)[idx+1 .. $] : null; 256 } 257 258 /** Determines if the connection persists across requests. 259 */ 260 @property bool persistent() const 261 { 262 auto ph = "connection" in headers; 263 switch(httpVersion) { 264 case HTTPVersion.HTTP_1_0: 265 if (ph && asLowerCase(*ph).equal("keep-alive")) return true; 266 return false; 267 case HTTPVersion.HTTP_1_1: 268 if (ph && !(asLowerCase(*ph).equal("keep-alive"))) return false; 269 return true; 270 default: 271 return false; 272 } 273 } 274 } 275 276 277 /** 278 Represents the HTTP response from the server back to the client. 279 */ 280 class HTTPResponse { 281 @safe: 282 283 protected DictionaryList!Cookie m_cookies; 284 285 public { 286 /// The protocol version of the response - should not be changed 287 HTTPVersion httpVersion = HTTPVersion.HTTP_1_1; 288 289 /// The status code of the response, 200 by default 290 int statusCode = HTTPStatus.ok; 291 292 /** The status phrase of the response 293 294 If no phrase is set, a default one corresponding to the status code will be used. 295 */ 296 string statusPhrase; 297 298 /// The response header fields 299 InetHeaderMap headers; 300 301 /// All cookies that shall be set on the client for this request 302 @property ref DictionaryList!Cookie cookies() return scope { return m_cookies; } 303 } 304 305 scope: 306 307 public override string toString() 308 { 309 auto app = appender!string(); 310 formattedWrite(app, "%s %d %s", getHTTPVersionString(this.httpVersion), this.statusCode, this.statusPhrase); 311 return app.data; 312 } 313 314 /** Shortcut to the "Content-Type" header 315 */ 316 @property string contentType() const { auto pct = "Content-Type" in headers; return pct ? *pct : "application/octet-stream"; } 317 /// ditto 318 @property void contentType(string ct) { headers["Content-Type"] = ct; } 319 } 320 321 322 /** 323 Respresents a HTTP response status. 324 325 Throwing this exception from within a request handler will produce a matching error page. 326 */ 327 class HTTPStatusException : Exception { 328 pure nothrow @safe @nogc: 329 330 private { 331 int m_status; 332 } 333 334 this(int status, string message = null, string file = __FILE__, size_t line = __LINE__, Throwable next = null) 335 { 336 super(message.length ? message : httpStatusText(status), file, line, next); 337 m_status = status; 338 } 339 340 /// The HTTP status code 341 @property int status() const { return m_status; } 342 343 string debugMessage; 344 } 345 346 347 final class MultiPart { 348 string contentType; 349 350 InputStream stream; 351 //JsonValue json; 352 string[string] form; 353 } 354 355 /** 356 * Returns: 357 * The version string corresponding to the `ver`, 358 * suitable for usage in the start line of the request. 359 */ 360 string getHTTPVersionString(HTTPVersion ver) 361 nothrow pure @nogc @safe { 362 final switch(ver){ 363 case HTTPVersion.HTTP_1_0: return "HTTP/1.0"; 364 case HTTPVersion.HTTP_1_1: return "HTTP/1.1"; 365 } 366 } 367 368 369 HTTPVersion parseHTTPVersion(ref string str) 370 @safe { 371 enforceBadRequest(str.startsWith("HTTP/1.")); 372 str = str[7 .. $]; 373 int minorVersion = parse!int(str); 374 375 enforceBadRequest( minorVersion == 0 || minorVersion == 1 ); 376 return minorVersion == 0 ? HTTPVersion.HTTP_1_0 : HTTPVersion.HTTP_1_1; 377 } 378 379 380 /** 381 Takes an input stream that contains data in HTTP chunked format and outputs the raw data. 382 */ 383 final class ChunkedInputStream : InputStream 384 { 385 @safe: 386 387 private { 388 InterfaceProxy!InputStream m_in; 389 ulong m_bytesInCurrentChunk = 0; 390 } 391 392 /// private 393 this(InterfaceProxy!InputStream stream, bool dummy) 394 { 395 assert(!!stream); 396 m_in = stream; 397 readChunk(); 398 } 399 400 @property bool empty() const { return m_bytesInCurrentChunk == 0; } 401 402 @property ulong leastSize() const { return m_bytesInCurrentChunk; } 403 404 @property bool dataAvailableForRead() { return m_bytesInCurrentChunk > 0 && m_in.dataAvailableForRead; } 405 406 const(ubyte)[] peek() 407 { 408 auto dt = m_in.peek(); 409 return dt[0 .. min(dt.length, m_bytesInCurrentChunk)]; 410 } 411 412 size_t read(scope ubyte[] dst, IOMode mode) 413 { 414 enforceBadRequest(!empty, "Read past end of chunked stream."); 415 size_t nbytes = 0; 416 417 while (dst.length > 0) { 418 enforceBadRequest(m_bytesInCurrentChunk > 0, "Reading past end of chunked HTTP stream."); 419 420 auto sz = cast(size_t)min(m_bytesInCurrentChunk, dst.length); 421 m_in.read(dst[0 .. sz]); 422 dst = dst[sz .. $]; 423 m_bytesInCurrentChunk -= sz; 424 nbytes += sz; 425 426 // FIXME: this blocks, but shouldn't for IOMode.once/immediat 427 if( m_bytesInCurrentChunk == 0 ){ 428 // skip current chunk footer and read next chunk 429 ubyte[2] crlf; 430 m_in.read(crlf); 431 enforceBadRequest(crlf[0] == '\r' && crlf[1] == '\n'); 432 readChunk(); 433 } 434 435 if (mode != IOMode.all) break; 436 } 437 438 return nbytes; 439 } 440 441 alias read = InputStream.read; 442 443 private void readChunk() 444 { 445 assert(m_bytesInCurrentChunk == 0); 446 // read chunk header 447 logTrace("read next chunk header"); 448 auto ln = () @trusted { return cast(string)m_in.readLine(); } (); 449 logTrace("got chunk header: %s", ln); 450 m_bytesInCurrentChunk = parse!ulong(ln, 16u); 451 452 if( m_bytesInCurrentChunk == 0 ){ 453 // empty chunk denotes the end 454 // skip final chunk footer 455 ubyte[2] crlf; 456 m_in.read(crlf); 457 enforceBadRequest(crlf[0] == '\r' && crlf[1] == '\n'); 458 } 459 } 460 } 461 462 /// Creates a new `ChunkedInputStream` instance. 463 ChunkedInputStream chunkedInputStream(IS)(IS source_stream) if (isInputStream!IS) 464 { 465 return new ChunkedInputStream(interfaceProxy!InputStream(source_stream), true); 466 } 467 468 /// Creates a new `ChunkedInputStream` instance. 469 FreeListRef!ChunkedInputStream createChunkedInputStreamFL(IS)(IS source_stream) if (isInputStream!IS) 470 { 471 return () @trusted { return FreeListRef!ChunkedInputStream(interfaceProxy!InputStream(source_stream), true); } (); 472 } 473 474 475 /** 476 Outputs data to an output stream in HTTP chunked format. 477 */ 478 final class ChunkedOutputStream : OutputStream { 479 @safe: 480 481 alias ChunkExtensionCallback = string delegate(in ubyte[] data); 482 private { 483 InterfaceProxy!OutputStream m_out; 484 AllocAppender!(ubyte[]) m_buffer; 485 size_t m_maxBufferSize = 4*1024; 486 bool m_finalized = false; 487 ChunkExtensionCallback m_chunkExtensionCallback = null; 488 } 489 490 /// private 491 this(Allocator)(InterfaceProxy!OutputStream stream, Allocator alloc, bool dummy) 492 { 493 m_out = stream; 494 m_buffer = AllocAppender!(ubyte[])(alloc); 495 } 496 497 /** Maximum buffer size used to buffer individual chunks. 498 499 A size of zero means unlimited buffer size. Explicit flush is required 500 in this case to empty the buffer. 501 */ 502 @property size_t maxBufferSize() const { return m_maxBufferSize; } 503 /// ditto 504 @property void maxBufferSize(size_t bytes) { m_maxBufferSize = bytes; if (m_buffer.data.length >= m_maxBufferSize) flush(); } 505 506 /** A delegate used to specify the extensions for each chunk written to the underlying stream. 507 508 The delegate has to be of type `string delegate(in const(ubyte)[] data)` and gets handed the 509 data of each chunk before it is written to the underlying stream. If it's return value is non-empty, 510 it will be added to the chunk's header line. 511 512 The returned chunk extension string should be of the format `key1=value1;key2=value2;[...];keyN=valueN` 513 and **not contain any carriage return or newline characters**. 514 515 Also note that the delegate should accept the passed data through a scoped argument. Thus, **no references 516 to the provided data should be stored in the delegate**. If the data has to be stored for later use, 517 it needs to be copied first. 518 */ 519 @property ChunkExtensionCallback chunkExtensionCallback() const { return m_chunkExtensionCallback; } 520 /// ditto 521 @property void chunkExtensionCallback(ChunkExtensionCallback cb) { m_chunkExtensionCallback = cb; } 522 523 private void append(scope void delegate(scope ubyte[] dst) @safe del, size_t nbytes) 524 { 525 assert(del !is null); 526 auto sz = nbytes; 527 if (m_maxBufferSize > 0 && m_maxBufferSize < m_buffer.data.length + sz) 528 sz = m_maxBufferSize - min(m_buffer.data.length, m_maxBufferSize); 529 530 if (sz > 0) 531 { 532 m_buffer.reserve(sz); 533 () @trusted { 534 m_buffer.append((scope ubyte[] dst) { 535 debug assert(dst.length >= sz); 536 del(dst[0..sz]); 537 return sz; 538 }); 539 } (); 540 } 541 } 542 543 static if (is(typeof(.OutputStream.outputStreamVersion)) && .OutputStream.outputStreamVersion > 1) { 544 override size_t write(scope const(ubyte)[] bytes_, IOMode mode) { return doWrite(bytes_, mode); } 545 } else { 546 override size_t write(in ubyte[] bytes_, IOMode mode) { return doWrite(bytes_, mode); } 547 } 548 549 alias write = OutputStream.write; 550 551 private size_t doWrite(scope const(ubyte)[] bytes_, IOMode mode) 552 { 553 assert(!m_finalized); 554 const(ubyte)[] bytes = bytes_; 555 size_t nbytes = 0; 556 while (bytes.length > 0) { 557 append((scope ubyte[] dst) { 558 auto n = dst.length; 559 dst[] = bytes[0..n]; 560 bytes = bytes[n..$]; 561 nbytes += n; 562 }, bytes.length); 563 if (mode == IOMode.immediate) break; 564 if (mode == IOMode.once && nbytes > 0) break; 565 if (bytes.length > 0) 566 flush(); 567 } 568 return nbytes; 569 } 570 571 void flush() 572 { 573 assert(!m_finalized); 574 auto data = m_buffer.data(); 575 if( data.length ){ 576 writeChunk(data); 577 } 578 m_out.flush(); 579 () @trusted { m_buffer.reset(AppenderResetMode.reuseData); } (); 580 } 581 582 void finalize() 583 { 584 if (m_finalized) return; 585 flush(); 586 () @trusted { m_buffer.reset(AppenderResetMode.freeData); } (); 587 m_finalized = true; 588 writeChunk([]); 589 m_out.flush(); 590 } 591 592 private void writeChunk(in ubyte[] data) 593 { 594 import vibe.stream.wrapper; 595 auto rng = streamOutputRange(m_out); 596 formattedWrite(() @trusted { return &rng; } (), "%x", data.length); 597 if (m_chunkExtensionCallback !is null) 598 { 599 rng.put(';'); 600 auto extension = m_chunkExtensionCallback(data); 601 assert(!extension.startsWith(';')); 602 debug assert(extension.indexOf('\r') < 0); 603 debug assert(extension.indexOf('\n') < 0); 604 rng.put(extension); 605 } 606 rng.put("\r\n"); 607 rng.put(data); 608 rng.put("\r\n"); 609 } 610 } 611 612 /// Creates a new `ChunkedInputStream` instance. 613 ChunkedOutputStream createChunkedOutputStream(OS)(OS destination_stream) if (isOutputStream!OS) 614 { 615 return createChunkedOutputStream(destination_stream, theAllocator()); 616 } 617 /// ditto 618 ChunkedOutputStream createChunkedOutputStream(OS, Allocator)(OS destination_stream, Allocator allocator) if (isOutputStream!OS) 619 { 620 return new ChunkedOutputStream(interfaceProxy!OutputStream(destination_stream), allocator, true); 621 } 622 623 /// Creates a new `ChunkedOutputStream` instance. 624 FreeListRef!ChunkedOutputStream createChunkedOutputStreamFL(OS)(OS destination_stream) if (isOutputStream!OS) 625 { 626 return createChunkedOutputStreamFL(destination_stream, theAllocator()); 627 } 628 /// ditto 629 FreeListRef!ChunkedOutputStream createChunkedOutputStreamFL(OS, Allocator)(OS destination_stream, Allocator allocator) if (isOutputStream!OS) 630 { 631 return FreeListRef!ChunkedOutputStream(interfaceProxy!OutputStream(destination_stream), allocator, true); 632 } 633 634 /// Parses the cookie from a header field, returning the name of the cookie. 635 /// Implements an algorithm equivalent to https://tools.ietf.org/html/rfc6265#section-5.2 636 /// Returns: the cookie name as return value, populates the dst argument or allocates on the GC for the tuple overload. 637 string parseHTTPCookie(string header_string, scope Cookie dst) 638 @safe 639 in { 640 assert(dst !is null); 641 } do { 642 if (!header_string.length) 643 return typeof(return).init; 644 645 auto parts = header_string.splitter(';'); 646 auto idx = parts.front.indexOf('='); 647 if (idx == -1) 648 return typeof(return).init; 649 650 auto name = parts.front[0 .. idx].strip(); 651 dst.m_value = parts.front[name.length + 1 .. $].strip(); 652 parts.popFront(); 653 654 if (!name.length) 655 return typeof(return).init; 656 657 foreach(part; parts) { 658 if (!part.length) 659 continue; 660 661 idx = part.indexOf('='); 662 if (idx == -1) { 663 idx = part.length; 664 } 665 auto key = part[0 .. idx].strip(); 666 auto value = part[min(idx + 1, $) .. $].strip(); 667 668 try { 669 if (key.sicmp("httponly") == 0) { 670 dst.m_httpOnly = true; 671 } else if (key.sicmp("secure") == 0) { 672 dst.m_secure = true; 673 } else if (key.sicmp("expires") == 0) { 674 // RFC 822 got updated by RFC 1123 (which is to be used) but is valid for this 675 // this parsing is just for validation 676 parseRFC822DateTimeString(value); 677 dst.m_expires = value; 678 } else if (key.sicmp("max-age") == 0) { 679 if (value.length && value[0] != '-') 680 dst.m_maxAge = value.to!long; 681 } else if (key.sicmp("domain") == 0) { 682 if (value.length && value[0] == '.') 683 value = value[1 .. $]; // the leading . must be stripped (5.2.3) 684 685 enforce!ConvException(value.all!(a => a >= 32), "Cookie Domain must not contain any control characters"); 686 dst.m_domain = value.toLower; // must be lower (5.2.3) 687 } else if (key.sicmp("path") == 0) { 688 if (value.length && value[0] == '/') { 689 enforce!ConvException(value.all!(a => a >= 32), "Cookie Path must not contain any control characters"); 690 dst.m_path = value; 691 } else { 692 dst.m_path = null; 693 } 694 } // else extension value... 695 } catch (DateTimeException) { 696 } catch (ConvException) { 697 } 698 // RFC 6265 says to ignore invalid values on all of these fields 699 } 700 return name; 701 } 702 703 /// ditto 704 Tuple!(string, Cookie) parseHTTPCookie(string header_string) 705 @safe { 706 Cookie cookie = new Cookie(); 707 auto name = parseHTTPCookie(header_string, cookie); 708 return tuple(name, cookie); 709 } 710 711 final class Cookie { 712 @safe: 713 714 private { 715 string m_value; 716 string m_domain; 717 string m_path; 718 string m_expires; 719 long m_maxAge; 720 bool m_secure; 721 bool m_httpOnly; 722 SameSite m_sameSite; 723 } 724 725 enum Encoding { 726 url, 727 raw, 728 none = raw 729 } 730 731 enum SameSite { 732 default_, 733 lax, 734 strict, 735 } 736 737 /// Cookie payload 738 @property void value(string value) { m_value = urlEncode(value); } 739 /// ditto 740 @property string value() const { return urlDecode(m_value); } 741 742 /// Undecoded cookie payload 743 @property void rawValue(string value) { m_value = value; } 744 /// ditto 745 @property string rawValue() const { return m_value; } 746 747 /// The domain for which the cookie is valid 748 @property void domain(string value) { m_domain = value; } 749 /// ditto 750 @property string domain() const { return m_domain; } 751 752 /// The path/local URI for which the cookie is valid 753 @property void path(string value) { m_path = value; } 754 /// ditto 755 @property string path() const { return m_path; } 756 757 /// Expiration date of the cookie 758 @property void expires(string value) { m_expires = value; } 759 /// ditto 760 @property void expires(SysTime value) { m_expires = value.toRFC822DateTimeString(); } 761 /// ditto 762 @property string expires() const { return m_expires; } 763 764 /** Maximum life time of the cookie 765 766 This is the modern variant of `expires`. For backwards compatibility it 767 is recommended to set both properties, or to use the `expire` method. 768 */ 769 @property void maxAge(long value) { m_maxAge = value; } 770 /// ditto 771 @property void maxAge(Duration value) { m_maxAge = value.total!"seconds"; } 772 /// ditto 773 @property long maxAge() const { return m_maxAge; } 774 775 /** Require a secure connection for transmission of this cookie 776 */ 777 @property void secure(bool value) { m_secure = value; } 778 /// ditto 779 @property bool secure() const { return m_secure; } 780 781 /** Prevents access to the cookie from scripts. 782 */ 783 @property void httpOnly(bool value) { m_httpOnly = value; } 784 /// ditto 785 @property bool httpOnly() const { return m_httpOnly; } 786 787 /** Prevent cross-site request forgery. 788 */ 789 @property void sameSite(Cookie.SameSite value) { m_sameSite = value; } 790 /// ditto 791 @property Cookie.SameSite sameSite() const { return m_sameSite; } 792 793 /** Sets the "expires" and "max-age" attributes to limit the life time of 794 the cookie. 795 */ 796 void expire(Duration max_age) 797 { 798 this.expires = Clock.currTime(UTC()) + max_age; 799 this.maxAge = max_age; 800 } 801 /// ditto 802 void expire(SysTime expire_time) 803 { 804 this.expires = expire_time; 805 this.maxAge = expire_time - Clock.currTime(UTC()); 806 } 807 808 /// Sets the cookie value encoded with the specified encoding. 809 void setValue(string value, Encoding encoding) 810 { 811 final switch (encoding) { 812 case Encoding.url: m_value = urlEncode(value); break; 813 case Encoding.none: validateValue(value); m_value = value; break; 814 } 815 } 816 817 /// Writes out the full cookie in HTTP compatible format. 818 void writeString(R)(R dst, string name) 819 if (isOutputRange!(R, char)) 820 { 821 import vibe.textfilter.urlencode; 822 dst.put(name); 823 dst.put('='); 824 validateValue(this.value); 825 dst.put(this.value); 826 if (this.domain && this.domain != "") { 827 dst.put("; Domain="); 828 dst.put(this.domain); 829 } 830 if (this.path != "") { 831 dst.put("; Path="); 832 dst.put(this.path); 833 } 834 if (this.expires != "") { 835 dst.put("; Expires="); 836 dst.put(this.expires); 837 } 838 if (this.maxAge) dst.formattedWrite("; Max-Age=%s", this.maxAge); 839 if (this.secure) dst.put("; Secure"); 840 if (this.httpOnly) dst.put("; HttpOnly"); 841 with(Cookie.SameSite) 842 final switch(this.sameSite) { 843 case default_: break; 844 case lax: dst.put("; SameSite=Lax"); break; 845 case strict: dst.put("; SameSite=Strict"); break; 846 } 847 } 848 849 private static void validateValue(string value) 850 { 851 enforce(!value.canFind(';') && !value.canFind('"')); 852 } 853 } 854 855 unittest { 856 import std.exception : assertThrown; 857 858 auto c = new Cookie; 859 c.value = "foo"; 860 assert(c.value == "foo"); 861 assert(c.rawValue == "foo"); 862 863 c.value = "foo$"; 864 assert(c.value == "foo$"); 865 assert(c.rawValue == "foo%24", c.rawValue); 866 867 c.value = "foo&bar=baz?"; 868 assert(c.value == "foo&bar=baz?"); 869 assert(c.rawValue == "foo%26bar%3Dbaz%3F", c.rawValue); 870 871 c.setValue("foo%", Cookie.Encoding.raw); 872 assert(c.rawValue == "foo%"); 873 assertThrown(c.value); 874 875 assertThrown(c.setValue("foo;bar", Cookie.Encoding.raw)); 876 877 auto tup = parseHTTPCookie("foo=bar; HttpOnly; Secure; Expires=Wed, 09 Jun 2021 10:18:14 GMT; Max-Age=60000; Domain=foo.com; Path=/users"); 878 assert(tup[0] == "foo"); 879 assert(tup[1].value == "bar"); 880 assert(tup[1].httpOnly == true); 881 assert(tup[1].secure == true); 882 assert(tup[1].expires == "Wed, 09 Jun 2021 10:18:14 GMT"); 883 assert(tup[1].maxAge == 60000L); 884 assert(tup[1].domain == "foo.com"); 885 assert(tup[1].path == "/users"); 886 887 tup = parseHTTPCookie("SESSIONID=0123456789ABCDEF0123456789ABCDEF; Path=/site; HttpOnly"); 888 assert(tup[0] == "SESSIONID"); 889 assert(tup[1].value == "0123456789ABCDEF0123456789ABCDEF"); 890 assert(tup[1].httpOnly == true); 891 assert(tup[1].secure == false); 892 assert(tup[1].expires == ""); 893 assert(tup[1].maxAge == 0); 894 assert(tup[1].domain == ""); 895 assert(tup[1].path == "/site"); 896 897 tup = parseHTTPCookie("invalid"); 898 assert(!tup[0].length); 899 900 tup = parseHTTPCookie("valid="); 901 assert(tup[0] == "valid"); 902 assert(tup[1].value == ""); 903 904 tup = parseHTTPCookie("valid=;Path=/bar;Path=foo;Expires=14 ; Something ; Domain=..example.org"); 905 assert(tup[0] == "valid"); 906 assert(tup[1].value == ""); 907 assert(tup[1].httpOnly == false); 908 assert(tup[1].secure == false); 909 assert(tup[1].expires == ""); 910 assert(tup[1].maxAge == 0); 911 assert(tup[1].domain == ".example.org"); // spec says you must strip only the first leading dot 912 assert(tup[1].path == ""); 913 } 914 915 916 /** 917 */ 918 struct CookieValueMap { 919 @safe: 920 921 struct Cookie { 922 /// Name of the cookie 923 string name; 924 925 /// The raw cookie value as transferred over the wire 926 string rawValue; 927 928 this(string name, string value, .Cookie.Encoding encoding = .Cookie.Encoding.url) 929 { 930 this.name = name; 931 this.setValue(value, encoding); 932 } 933 934 /// Treats the value as URL encoded 935 string value() const { return urlDecode(rawValue); } 936 /// ditto 937 void value(string val) { rawValue = urlEncode(val); } 938 939 /// Sets the cookie value, applying the specified encoding. 940 void setValue(string value, .Cookie.Encoding encoding = .Cookie.Encoding.url) 941 { 942 final switch (encoding) { 943 case .Cookie.Encoding.none: this.rawValue = value; break; 944 case .Cookie.Encoding.url: this.rawValue = urlEncode(value); break; 945 } 946 } 947 } 948 949 private { 950 Cookie[] m_entries; 951 } 952 953 auto length(){ 954 return m_entries.length; 955 } 956 957 string get(string name, string def_value = null) 958 const { 959 foreach (ref c; m_entries) 960 if (c.name == name) 961 return c.value; 962 return def_value; 963 } 964 965 string[] getAll(string name) 966 const { 967 string[] ret; 968 foreach(c; m_entries) 969 if( c.name == name ) 970 ret ~= c.value; 971 return ret; 972 } 973 974 void add(string name, string value, .Cookie.Encoding encoding = .Cookie.Encoding.url){ 975 m_entries ~= Cookie(name, value, encoding); 976 } 977 978 void opIndexAssign(string value, string name) 979 { 980 m_entries ~= Cookie(name, value); 981 } 982 983 string opIndex(string name) 984 const { 985 import core.exception : RangeError; 986 foreach (ref c; m_entries) 987 if (c.name == name) 988 return c.value; 989 throw new RangeError("Non-existent cookie: "~name); 990 } 991 992 int opApply(scope int delegate(ref Cookie) @safe del) 993 { 994 foreach(ref c; m_entries) 995 if( auto ret = del(c) ) 996 return ret; 997 return 0; 998 } 999 1000 int opApply(scope int delegate(ref Cookie) @safe del) 1001 const { 1002 foreach(Cookie c; m_entries) 1003 if( auto ret = del(c) ) 1004 return ret; 1005 return 0; 1006 } 1007 1008 int opApply(scope int delegate(string name, string value) @safe del) 1009 { 1010 foreach(ref c; m_entries) 1011 if( auto ret = del(c.name, c.value) ) 1012 return ret; 1013 return 0; 1014 } 1015 1016 int opApply(scope int delegate(string name, string value) @safe del) 1017 const { 1018 foreach(Cookie c; m_entries) 1019 if( auto ret = del(c.name, c.value) ) 1020 return ret; 1021 return 0; 1022 } 1023 1024 auto opBinaryRight(string op)(string name) if(op == "in") 1025 { 1026 return Ptr(&this, name); 1027 } 1028 1029 auto opBinaryRight(string op)(string name) const if(op == "in") 1030 { 1031 return const(Ptr)(&this, name); 1032 } 1033 1034 private static struct Ref { 1035 private { 1036 CookieValueMap* map; 1037 string name; 1038 } 1039 1040 @property string get() const { return (*map)[name]; } 1041 void opAssign(string newval) { 1042 foreach (ref c; *map) 1043 if (c.name == name) { 1044 c.value = newval; 1045 return; 1046 } 1047 assert(false); 1048 } 1049 alias get this; 1050 } 1051 private static struct Ptr { 1052 private { 1053 CookieValueMap* map; 1054 string name; 1055 } 1056 bool opCast() const { 1057 foreach (ref c; map.m_entries) 1058 if (c.name == name) 1059 return true; 1060 return false; 1061 } 1062 inout(Ref) opUnary(string op : "*")() inout { return inout(Ref)(map, name); } 1063 } 1064 } 1065 1066 unittest { 1067 CookieValueMap m; 1068 m["foo"] = "bar;baz%1"; 1069 assert(m["foo"] == "bar;baz%1"); 1070 1071 m["foo"] = "bar"; 1072 assert(m.getAll("foo") == ["bar;baz%1", "bar"]); 1073 1074 assert("foo" in m); 1075 if (auto val = "foo" in m) { 1076 assert(*val == "bar;baz%1"); 1077 } else assert(false); 1078 *("foo" in m) = "baz"; 1079 assert(m["foo"] == "baz"); 1080 } 1081 1082 1083 package auto createRequestAllocator() 1084 { 1085 import vibe.container.internal.utilallocator: RegionListAllocator; 1086 1087 static if (is(RegionListAllocator!(shared(GCAllocator), true) == struct)) { 1088 version (VibeManualMemoryManagement) 1089 return allocatorObject(RegionListAllocator!(shared(Mallocator), false)(1024, Mallocator.instance)); 1090 else 1091 return allocatorObject(RegionListAllocator!(shared(GCAllocator), true)(1024, GCAllocator.instance)); 1092 } else { 1093 version (VibeManualMemoryManagement) 1094 return new RegionListAllocator!(shared(Mallocator), false)(1024, Mallocator.instance); 1095 else 1096 return new RegionListAllocator!(shared(GCAllocator), true)(1024, GCAllocator.instance); 1097 } 1098 } 1099 1100 package void freeRequestAllocator(Allocator)(ref Allocator alloc) 1101 { 1102 destroy(alloc); 1103 }