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 /// private 390 this(InterfaceProxy!InputStream stream, bool dummy) 391 { 392 assert(!!stream); 393 m_in = stream; 394 readChunk(); 395 } 396 397 @property bool empty() const { return m_bytesInCurrentChunk == 0; } 398 399 @property ulong leastSize() const { return m_bytesInCurrentChunk; } 400 401 @property bool dataAvailableForRead() { return m_bytesInCurrentChunk > 0 && m_in.dataAvailableForRead; } 402 403 const(ubyte)[] peek() 404 { 405 auto dt = m_in.peek(); 406 return dt[0 .. min(dt.length, m_bytesInCurrentChunk)]; 407 } 408 409 size_t read(scope ubyte[] dst, IOMode mode) 410 { 411 enforceBadRequest(!empty, "Read past end of chunked stream."); 412 size_t nbytes = 0; 413 414 while (dst.length > 0) { 415 enforceBadRequest(m_bytesInCurrentChunk > 0, "Reading past end of chunked HTTP stream."); 416 417 auto sz = cast(size_t)min(m_bytesInCurrentChunk, dst.length); 418 m_in.read(dst[0 .. sz]); 419 dst = dst[sz .. $]; 420 m_bytesInCurrentChunk -= sz; 421 nbytes += sz; 422 423 // FIXME: this blocks, but shouldn't for IOMode.once/immediat 424 if( m_bytesInCurrentChunk == 0 ){ 425 // skip current chunk footer and read next chunk 426 ubyte[2] crlf; 427 m_in.read(crlf); 428 enforceBadRequest(crlf[0] == '\r' && crlf[1] == '\n'); 429 readChunk(); 430 } 431 432 if (mode != IOMode.all) break; 433 } 434 435 return nbytes; 436 } 437 438 alias read = InputStream.read; 439 440 private void readChunk() 441 { 442 assert(m_bytesInCurrentChunk == 0); 443 // read chunk header 444 logTrace("read next chunk header"); 445 auto ln = () @trusted { return cast(string)m_in.readLine(); } (); 446 logTrace("got chunk header: %s", ln); 447 m_bytesInCurrentChunk = parse!ulong(ln, 16u); 448 449 if( m_bytesInCurrentChunk == 0 ){ 450 // empty chunk denotes the end 451 // skip final chunk footer 452 ubyte[2] crlf; 453 m_in.read(crlf); 454 enforceBadRequest(crlf[0] == '\r' && crlf[1] == '\n'); 455 } 456 } 457 } 458 459 /// Creates a new `ChunkedInputStream` instance. 460 ChunkedInputStream chunkedInputStream(IS)(IS source_stream) if (isInputStream!IS) 461 { 462 return new ChunkedInputStream(interfaceProxy!InputStream(source_stream), true); 463 } 464 465 /// Creates a new `ChunkedInputStream` instance. 466 FreeListRef!ChunkedInputStream createChunkedInputStreamFL(IS)(IS source_stream) if (isInputStream!IS) 467 { 468 return () @trusted { return FreeListRef!ChunkedInputStream(interfaceProxy!InputStream(source_stream), true); } (); 469 } 470 471 472 /** 473 Outputs data to an output stream in HTTP chunked format. 474 */ 475 final class ChunkedOutputStream : OutputStream { 476 @safe: 477 478 alias ChunkExtensionCallback = string delegate(in ubyte[] data); 479 private { 480 InterfaceProxy!OutputStream m_out; 481 AllocAppender!(ubyte[]) m_buffer; 482 size_t m_maxBufferSize = 4*1024; 483 bool m_finalized = false; 484 ChunkExtensionCallback m_chunkExtensionCallback = null; 485 } 486 487 /// private 488 this(InterfaceProxy!OutputStream stream, IAllocator alloc, bool dummy) 489 { 490 m_out = stream; 491 m_buffer = AllocAppender!(ubyte[])(alloc); 492 } 493 494 /** Maximum buffer size used to buffer individual chunks. 495 496 A size of zero means unlimited buffer size. Explicit flush is required 497 in this case to empty the buffer. 498 */ 499 @property size_t maxBufferSize() const { return m_maxBufferSize; } 500 /// ditto 501 @property void maxBufferSize(size_t bytes) { m_maxBufferSize = bytes; if (m_buffer.data.length >= m_maxBufferSize) flush(); } 502 503 /** A delegate used to specify the extensions for each chunk written to the underlying stream. 504 505 The delegate has to be of type `string delegate(in const(ubyte)[] data)` and gets handed the 506 data of each chunk before it is written to the underlying stream. If it's return value is non-empty, 507 it will be added to the chunk's header line. 508 509 The returned chunk extension string should be of the format `key1=value1;key2=value2;[...];keyN=valueN` 510 and **not contain any carriage return or newline characters**. 511 512 Also note that the delegate should accept the passed data through a scoped argument. Thus, **no references 513 to the provided data should be stored in the delegate**. If the data has to be stored for later use, 514 it needs to be copied first. 515 */ 516 @property ChunkExtensionCallback chunkExtensionCallback() const { return m_chunkExtensionCallback; } 517 /// ditto 518 @property void chunkExtensionCallback(ChunkExtensionCallback cb) { m_chunkExtensionCallback = cb; } 519 520 private void append(scope void delegate(scope ubyte[] dst) @safe del, size_t nbytes) 521 { 522 assert(del !is null); 523 auto sz = nbytes; 524 if (m_maxBufferSize > 0 && m_maxBufferSize < m_buffer.data.length + sz) 525 sz = m_maxBufferSize - min(m_buffer.data.length, m_maxBufferSize); 526 527 if (sz > 0) 528 { 529 m_buffer.reserve(sz); 530 () @trusted { 531 m_buffer.append((scope ubyte[] dst) { 532 debug assert(dst.length >= sz); 533 del(dst[0..sz]); 534 return sz; 535 }); 536 } (); 537 } 538 } 539 540 size_t write(in ubyte[] bytes_, IOMode mode) 541 { 542 assert(!m_finalized); 543 const(ubyte)[] bytes = bytes_; 544 size_t nbytes = 0; 545 while (bytes.length > 0) { 546 append((scope ubyte[] dst) { 547 auto n = dst.length; 548 dst[] = bytes[0..n]; 549 bytes = bytes[n..$]; 550 nbytes += n; 551 }, bytes.length); 552 if (mode == IOMode.immediate) break; 553 if (mode == IOMode.once && nbytes > 0) break; 554 if (bytes.length > 0) 555 flush(); 556 } 557 return nbytes; 558 } 559 560 alias write = OutputStream.write; 561 562 void flush() 563 { 564 assert(!m_finalized); 565 auto data = m_buffer.data(); 566 if( data.length ){ 567 writeChunk(data); 568 } 569 m_out.flush(); 570 () @trusted { m_buffer.reset(AppenderResetMode.reuseData); } (); 571 } 572 573 void finalize() 574 { 575 if (m_finalized) return; 576 flush(); 577 () @trusted { m_buffer.reset(AppenderResetMode.freeData); } (); 578 m_finalized = true; 579 writeChunk([]); 580 m_out.flush(); 581 } 582 583 private void writeChunk(in ubyte[] data) 584 { 585 import vibe.stream.wrapper; 586 auto rng = streamOutputRange(m_out); 587 formattedWrite(() @trusted { return &rng; } (), "%x", data.length); 588 if (m_chunkExtensionCallback !is null) 589 { 590 rng.put(';'); 591 auto extension = m_chunkExtensionCallback(data); 592 assert(!extension.startsWith(';')); 593 debug assert(extension.indexOf('\r') < 0); 594 debug assert(extension.indexOf('\n') < 0); 595 rng.put(extension); 596 } 597 rng.put("\r\n"); 598 rng.put(data); 599 rng.put("\r\n"); 600 } 601 } 602 603 /// Creates a new `ChunkedInputStream` instance. 604 ChunkedOutputStream createChunkedOutputStream(OS)(OS destination_stream, IAllocator allocator = theAllocator()) if (isOutputStream!OS) 605 { 606 return new ChunkedOutputStream(interfaceProxy!OutputStream(destination_stream), allocator, true); 607 } 608 609 /// Creates a new `ChunkedOutputStream` instance. 610 FreeListRef!ChunkedOutputStream createChunkedOutputStreamFL(OS)(OS destination_stream, IAllocator allocator = theAllocator()) if (isOutputStream!OS) 611 { 612 return FreeListRef!ChunkedOutputStream(interfaceProxy!OutputStream(destination_stream), allocator, true); 613 } 614 615 /// Parses the cookie from a header field, returning the name of the cookie. 616 /// Implements an algorithm equivalent to https://tools.ietf.org/html/rfc6265#section-5.2 617 /// Returns: the cookie name as return value, populates the dst argument or allocates on the GC for the tuple overload. 618 string parseHTTPCookie(string header_string, scope Cookie dst) 619 @safe 620 in { 621 assert(dst !is null); 622 } do { 623 if (!header_string.length) 624 return typeof(return).init; 625 626 auto parts = header_string.splitter(';'); 627 auto idx = parts.front.indexOf('='); 628 if (idx == -1) 629 return typeof(return).init; 630 631 auto name = parts.front[0 .. idx].strip(); 632 dst.m_value = parts.front[name.length + 1 .. $].strip(); 633 parts.popFront(); 634 635 if (!name.length) 636 return typeof(return).init; 637 638 foreach(part; parts) { 639 if (!part.length) 640 continue; 641 642 idx = part.indexOf('='); 643 if (idx == -1) { 644 idx = part.length; 645 } 646 auto key = part[0 .. idx].strip(); 647 auto value = part[min(idx + 1, $) .. $].strip(); 648 649 try { 650 if (key.sicmp("httponly") == 0) { 651 dst.m_httpOnly = true; 652 } else if (key.sicmp("secure") == 0) { 653 dst.m_secure = true; 654 } else if (key.sicmp("expires") == 0) { 655 // RFC 822 got updated by RFC 1123 (which is to be used) but is valid for this 656 // this parsing is just for validation 657 parseRFC822DateTimeString(value); 658 dst.m_expires = value; 659 } else if (key.sicmp("max-age") == 0) { 660 if (value.length && value[0] != '-') 661 dst.m_maxAge = value.to!long; 662 } else if (key.sicmp("domain") == 0) { 663 if (value.length && value[0] == '.') 664 value = value[1 .. $]; // the leading . must be stripped (5.2.3) 665 666 enforce!ConvException(value.all!(a => a >= 32), "Cookie Domain must not contain any control characters"); 667 dst.m_domain = value.toLower; // must be lower (5.2.3) 668 } else if (key.sicmp("path") == 0) { 669 if (value.length && value[0] == '/') { 670 enforce!ConvException(value.all!(a => a >= 32), "Cookie Path must not contain any control characters"); 671 dst.m_path = value; 672 } else { 673 dst.m_path = null; 674 } 675 } // else extension value... 676 } catch (DateTimeException) { 677 } catch (ConvException) { 678 } 679 // RFC 6265 says to ignore invalid values on all of these fields 680 } 681 return name; 682 } 683 684 /// ditto 685 Tuple!(string, Cookie) parseHTTPCookie(string header_string) 686 @safe { 687 Cookie cookie = new Cookie(); 688 auto name = parseHTTPCookie(header_string, cookie); 689 return tuple(name, cookie); 690 } 691 692 final class Cookie { 693 @safe: 694 695 private { 696 string m_value; 697 string m_domain; 698 string m_path; 699 string m_expires; 700 long m_maxAge; 701 bool m_secure; 702 bool m_httpOnly; 703 SameSite m_sameSite; 704 } 705 706 enum Encoding { 707 url, 708 raw, 709 none = raw 710 } 711 712 enum SameSite { 713 default_, 714 lax, 715 strict, 716 } 717 718 /// Cookie payload 719 @property void value(string value) { m_value = urlEncode(value); } 720 /// ditto 721 @property string value() const { return urlDecode(m_value); } 722 723 /// Undecoded cookie payload 724 @property void rawValue(string value) { m_value = value; } 725 /// ditto 726 @property string rawValue() const { return m_value; } 727 728 /// The domain for which the cookie is valid 729 @property void domain(string value) { m_domain = value; } 730 /// ditto 731 @property string domain() const { return m_domain; } 732 733 /// The path/local URI for which the cookie is valid 734 @property void path(string value) { m_path = value; } 735 /// ditto 736 @property string path() const { return m_path; } 737 738 /// Expiration date of the cookie 739 @property void expires(string value) { m_expires = value; } 740 /// ditto 741 @property void expires(SysTime value) { m_expires = value.toRFC822DateTimeString(); } 742 /// ditto 743 @property string expires() const { return m_expires; } 744 745 /** Maximum life time of the cookie 746 747 This is the modern variant of `expires`. For backwards compatibility it 748 is recommended to set both properties, or to use the `expire` method. 749 */ 750 @property void maxAge(long value) { m_maxAge = value; } 751 /// ditto 752 @property void maxAge(Duration value) { m_maxAge = value.total!"seconds"; } 753 /// ditto 754 @property long maxAge() const { return m_maxAge; } 755 756 /** Require a secure connection for transmission of this cookie 757 */ 758 @property void secure(bool value) { m_secure = value; } 759 /// ditto 760 @property bool secure() const { return m_secure; } 761 762 /** Prevents access to the cookie from scripts. 763 */ 764 @property void httpOnly(bool value) { m_httpOnly = value; } 765 /// ditto 766 @property bool httpOnly() const { return m_httpOnly; } 767 768 /** Prevent cross-site request forgery. 769 */ 770 @property void sameSite(Cookie.SameSite value) { m_sameSite = value; } 771 /// ditto 772 @property Cookie.SameSite sameSite() const { return m_sameSite; } 773 774 /** Sets the "expires" and "max-age" attributes to limit the life time of 775 the cookie. 776 */ 777 void expire(Duration max_age) 778 { 779 this.expires = Clock.currTime(UTC()) + max_age; 780 this.maxAge = max_age; 781 } 782 /// ditto 783 void expire(SysTime expire_time) 784 { 785 this.expires = expire_time; 786 this.maxAge = expire_time - Clock.currTime(UTC()); 787 } 788 789 /// Sets the cookie value encoded with the specified encoding. 790 void setValue(string value, Encoding encoding) 791 { 792 final switch (encoding) { 793 case Encoding.url: m_value = urlEncode(value); break; 794 case Encoding.none: validateValue(value); m_value = value; break; 795 } 796 } 797 798 /// Writes out the full cookie in HTTP compatible format. 799 void writeString(R)(R dst, string name) 800 if (isOutputRange!(R, char)) 801 { 802 import vibe.textfilter.urlencode; 803 dst.put(name); 804 dst.put('='); 805 validateValue(this.value); 806 dst.put(this.value); 807 if (this.domain && this.domain != "") { 808 dst.put("; Domain="); 809 dst.put(this.domain); 810 } 811 if (this.path != "") { 812 dst.put("; Path="); 813 dst.put(this.path); 814 } 815 if (this.expires != "") { 816 dst.put("; Expires="); 817 dst.put(this.expires); 818 } 819 if (this.maxAge) dst.formattedWrite("; Max-Age=%s", this.maxAge); 820 if (this.secure) dst.put("; Secure"); 821 if (this.httpOnly) dst.put("; HttpOnly"); 822 with(Cookie.SameSite) 823 final switch(this.sameSite) { 824 case default_: break; 825 case lax: dst.put("; SameSite=Lax"); break; 826 case strict: dst.put("; SameSite=Strict"); break; 827 } 828 } 829 830 private static void validateValue(string value) 831 { 832 enforce(!value.canFind(';') && !value.canFind('"')); 833 } 834 } 835 836 unittest { 837 import std.exception : assertThrown; 838 839 auto c = new Cookie; 840 c.value = "foo"; 841 assert(c.value == "foo"); 842 assert(c.rawValue == "foo"); 843 844 c.value = "foo$"; 845 assert(c.value == "foo$"); 846 assert(c.rawValue == "foo%24", c.rawValue); 847 848 c.value = "foo&bar=baz?"; 849 assert(c.value == "foo&bar=baz?"); 850 assert(c.rawValue == "foo%26bar%3Dbaz%3F", c.rawValue); 851 852 c.setValue("foo%", Cookie.Encoding.raw); 853 assert(c.rawValue == "foo%"); 854 assertThrown(c.value); 855 856 assertThrown(c.setValue("foo;bar", Cookie.Encoding.raw)); 857 858 auto tup = parseHTTPCookie("foo=bar; HttpOnly; Secure; Expires=Wed, 09 Jun 2021 10:18:14 GMT; Max-Age=60000; Domain=foo.com; Path=/users"); 859 assert(tup[0] == "foo"); 860 assert(tup[1].value == "bar"); 861 assert(tup[1].httpOnly == true); 862 assert(tup[1].secure == true); 863 assert(tup[1].expires == "Wed, 09 Jun 2021 10:18:14 GMT"); 864 assert(tup[1].maxAge == 60000L); 865 assert(tup[1].domain == "foo.com"); 866 assert(tup[1].path == "/users"); 867 868 tup = parseHTTPCookie("SESSIONID=0123456789ABCDEF0123456789ABCDEF; Path=/site; HttpOnly"); 869 assert(tup[0] == "SESSIONID"); 870 assert(tup[1].value == "0123456789ABCDEF0123456789ABCDEF"); 871 assert(tup[1].httpOnly == true); 872 assert(tup[1].secure == false); 873 assert(tup[1].expires == ""); 874 assert(tup[1].maxAge == 0); 875 assert(tup[1].domain == ""); 876 assert(tup[1].path == "/site"); 877 878 tup = parseHTTPCookie("invalid"); 879 assert(!tup[0].length); 880 881 tup = parseHTTPCookie("valid="); 882 assert(tup[0] == "valid"); 883 assert(tup[1].value == ""); 884 885 tup = parseHTTPCookie("valid=;Path=/bar;Path=foo;Expires=14 ; Something ; Domain=..example.org"); 886 assert(tup[0] == "valid"); 887 assert(tup[1].value == ""); 888 assert(tup[1].httpOnly == false); 889 assert(tup[1].secure == false); 890 assert(tup[1].expires == ""); 891 assert(tup[1].maxAge == 0); 892 assert(tup[1].domain == ".example.org"); // spec says you must strip only the first leading dot 893 assert(tup[1].path == ""); 894 } 895 896 897 /** 898 */ 899 struct CookieValueMap { 900 @safe: 901 902 struct Cookie { 903 /// Name of the cookie 904 string name; 905 906 /// The raw cookie value as transferred over the wire 907 string rawValue; 908 909 this(string name, string value, .Cookie.Encoding encoding = .Cookie.Encoding.url) 910 { 911 this.name = name; 912 this.setValue(value, encoding); 913 } 914 915 /// Treats the value as URL encoded 916 string value() const { return urlDecode(rawValue); } 917 /// ditto 918 void value(string val) { rawValue = urlEncode(val); } 919 920 /// Sets the cookie value, applying the specified encoding. 921 void setValue(string value, .Cookie.Encoding encoding = .Cookie.Encoding.url) 922 { 923 final switch (encoding) { 924 case .Cookie.Encoding.none: this.rawValue = value; break; 925 case .Cookie.Encoding.url: this.rawValue = urlEncode(value); break; 926 } 927 } 928 } 929 930 private { 931 Cookie[] m_entries; 932 } 933 934 auto length(){ 935 return m_entries.length; 936 } 937 938 string get(string name, string def_value = null) 939 const { 940 foreach (ref c; m_entries) 941 if (c.name == name) 942 return c.value; 943 return def_value; 944 } 945 946 string[] getAll(string name) 947 const { 948 string[] ret; 949 foreach(c; m_entries) 950 if( c.name == name ) 951 ret ~= c.value; 952 return ret; 953 } 954 955 void add(string name, string value, .Cookie.Encoding encoding = .Cookie.Encoding.url){ 956 m_entries ~= Cookie(name, value, encoding); 957 } 958 959 void opIndexAssign(string value, string name) 960 { 961 m_entries ~= Cookie(name, value); 962 } 963 964 string opIndex(string name) 965 const { 966 import core.exception : RangeError; 967 foreach (ref c; m_entries) 968 if (c.name == name) 969 return c.value; 970 throw new RangeError("Non-existent cookie: "~name); 971 } 972 973 int opApply(scope int delegate(ref Cookie) @safe del) 974 { 975 foreach(ref c; m_entries) 976 if( auto ret = del(c) ) 977 return ret; 978 return 0; 979 } 980 981 int opApply(scope int delegate(ref Cookie) @safe del) 982 const { 983 foreach(Cookie c; m_entries) 984 if( auto ret = del(c) ) 985 return ret; 986 return 0; 987 } 988 989 int opApply(scope int delegate(string name, string value) @safe del) 990 { 991 foreach(ref c; m_entries) 992 if( auto ret = del(c.name, c.value) ) 993 return ret; 994 return 0; 995 } 996 997 int opApply(scope int delegate(string name, string value) @safe del) 998 const { 999 foreach(Cookie c; m_entries) 1000 if( auto ret = del(c.name, c.value) ) 1001 return ret; 1002 return 0; 1003 } 1004 1005 auto opBinaryRight(string op)(string name) if(op == "in") 1006 { 1007 return Ptr(&this, name); 1008 } 1009 1010 auto opBinaryRight(string op)(string name) const if(op == "in") 1011 { 1012 return const(Ptr)(&this, name); 1013 } 1014 1015 private static struct Ref { 1016 private { 1017 CookieValueMap* map; 1018 string name; 1019 } 1020 1021 @property string get() const { return (*map)[name]; } 1022 void opAssign(string newval) { 1023 foreach (ref c; *map) 1024 if (c.name == name) { 1025 c.value = newval; 1026 return; 1027 } 1028 assert(false); 1029 } 1030 alias get this; 1031 } 1032 private static struct Ptr { 1033 private { 1034 CookieValueMap* map; 1035 string name; 1036 } 1037 bool opCast() const { 1038 foreach (ref c; map.m_entries) 1039 if (c.name == name) 1040 return true; 1041 return false; 1042 } 1043 inout(Ref) opUnary(string op : "*")() inout { return inout(Ref)(map, name); } 1044 } 1045 } 1046 1047 unittest { 1048 CookieValueMap m; 1049 m["foo"] = "bar;baz%1"; 1050 assert(m["foo"] == "bar;baz%1"); 1051 1052 m["foo"] = "bar"; 1053 assert(m.getAll("foo") == ["bar;baz%1", "bar"]); 1054 1055 assert("foo" in m); 1056 if (auto val = "foo" in m) { 1057 assert(*val == "bar;baz%1"); 1058 } else assert(false); 1059 *("foo" in m) = "baz"; 1060 assert(m["foo"] == "baz"); 1061 }