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 }