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