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