1 module lighttp.util;
2 
3 import std.array : Appender;
4 import std.conv : to, ConvException;
5 import std.base64 : Base64URLNoPadding;
6 import std.digest.crc : crc32Of;
7 import std.string : toUpper, toLower, split, join, strip, indexOf;
8 import std.traits : EnumMembers;
9 import std.uri : encode, decode;
10 import std.zlib : HeaderFormat, Compress;
11 
12 /**
13  * Indicates the status of an HTTP response.
14  */
15 struct Status {
16 	
17 	/**
18 	 * HTTP response status code.
19 	 */
20 	uint code;
21 	
22 	/**
23 	 * Additional short description of the status code.
24 	 */
25 	string message;
26 	
27 	bool opEquals(uint code) {
28 		return this.code == code;
29 	}
30 	
31 	bool opEquals(Status status) {
32 		return this.opEquals(status.code);
33 	}
34 	
35 	/**
36 	 * Concatenates the status code and the message into
37 	 * a string.
38 	 * Example:
39 	 * ---
40 	 * assert(Status(200, "OK").toString() == "200 OK");
41 	 * ---
42 	 */
43 	string toString() {
44 		return this.code.to!string ~ " " ~ this.message;
45 	}
46 	
47 	/**
48 	 * Creates a status from a known list of codes/messages.
49 	 * Example:
50 	 * ---
51 	 * assert(Status.get(200).message == "OK");
52 	 * ---
53 	 */
54 	public static Status get(uint code) {
55 		foreach(statusCode ; [EnumMembers!StatusCodes]) {
56 			if(code == statusCode.code) return statusCode;
57 		}
58 		return Status(code, "Unknown Status Code");
59 	}
60 	
61 }
62 
63 /**
64  * HTTP status codes and their human-readable names.
65  */
66 enum StatusCodes : Status {
67 	
68 	// informational
69 	continue_ = Status(100, "Continue"),
70 	switchingProtocols = Status(101, "Switching Protocols"),
71 	
72 	// success
73 	ok = Status(200, "OK"),
74 	created = Status(201, "Created"),
75 	accepted = Status(202, "Accepted"),
76 	nonAuthoritativeContent = Status(203, "Non-Authoritative Information"),
77 	noContent = Status(204, "No Content"),
78 	resetContent = Status(205, "Reset Content"),
79 	partialContent = Status(206, "Partial Content"),
80 	
81 	// redirection
82 	multipleChoices = Status(300, "Multiple Choices"),
83 	movedPermanently = Status(301, "Moved Permanently"),
84 	found = Status(302, "Found"),
85 	seeOther = Status(303, "See Other"),
86 	notModified = Status(304, "Not Modified"),
87 	useProxy = Status(305, "Use Proxy"),
88 	switchProxy = Status(306, "Switch Proxy"),
89 	temporaryRedirect = Status(307, "Temporary Redirect"),
90 	permanentRedirect = Status(308, "Permanent Redirect"),
91 	
92 	// client errors
93 	badRequest = Status(400, "Bad Request"),
94 	unauthorized = Status(401, "Unauthorized"),
95 	paymentRequired = Status(402, "Payment Required"),
96 	forbidden = Status(403, "Forbidden"),
97 	notFound = Status(404, "Not Found"),
98 	methodNotAllowed = Status(405, "Method Not Allowed"),
99 	notAcceptable = Status(406, "Not Acceptable"),
100 	proxyAuthenticationRequired = Status(407, "Proxy Authentication Required"),
101 	requestTimeout = Status(408, "Request Timeout"),
102 	conflict = Status(409, "Conflict"),
103 	gone = Status(410, "Gone"),
104 	lengthRequired = Status(411, "Length Required"),
105 	preconditionFailed = Status(412, "Precondition Failed"),
106 	payloadTooLarge = Status(413, "Payload Too Large"),
107 	uriTooLong = Status(414, "URI Too Long"),
108 	unsupportedMediaType = Status(415, "UnsupportedMediaType"),
109 	rangeNotSatisfiable = Status(416, "Range Not Satisfiable"),
110 	expectationFailed = Status(417, "Expectation Failed"),
111 	
112 	// server errors
113 	internalServerError = Status(500, "Internal Server Error"),
114 	notImplemented = Status(501, "Not Implemented"),
115 	badGateway = Status(502, "Bad Gateway"),
116 	serviceUnavailable = Status(503, "Service Unavailable"),
117 	gatewayTimeout = Status(504, "Gateway Timeout"),
118 	httpVersionNotSupported = Status(505, "HTTP Version Not Supported"),
119 	
120 }
121 
122 abstract class HTTP {
123 
124 	enum VERSION = "HTTP/1.1";
125 	
126 	enum GET = "GET";
127 	enum POST = "POST";
128 
129 	/**
130 	 * Method used.
131 	 */
132 	string method;
133 
134 	/**
135 	 * Headers of the request/response.
136 	 */
137 	string[string] headers;
138 
139 	protected string _body;
140 
141 	@property string body_() pure nothrow @safe @nogc {
142 		return _body;
143 	}
144 
145 	@property string body_(in void[] data) pure nothrow @nogc {
146 		return _body = cast(string)data;
147 	}
148 
149 	static if(__VERSION__ >= 2078) alias body = body_;
150 
151 }
152 
153 enum defaultHeaders = (string[string]).init;
154 
155 /**
156  * Container for a HTTP request.
157  * Example:
158  * ---
159  * new Request("GET", "/");
160  * new Request(Request.POST, "/subscribe.php");
161  * ---
162  */
163 class Request : HTTP {
164 	
165 	/**
166 	 * Path of the request. It should start with a slash.
167 	 */
168 	string path;
169 
170 	public this() {}
171 	
172 	public this(string method, string path, string[string] headers=defaultHeaders) {
173 		this.method = method;
174 		this.path = path;
175 		this.headers = headers;
176 	}
177 	
178 	/**
179 	 * Creates a get request.
180 	 * Example:
181 	 * ---
182 	 * auto get = Request.get("/index.html", ["Host": "127.0.0.1"]);
183 	 * assert(get.toString() == "GET /index.html HTTP/1.1\r\nHost: 127.0.0.1\r\n");
184 	 * ---
185 	 */
186 	public static Request get(string path, string[string] headers=defaultHeaders) {
187 		return new Request(GET, path, headers);
188 	}
189 	
190 	/**
191 	 * Creates a post request.
192 	 * Example:
193 	 * ---
194 	 * auto post = Request.post("/sub.php", ["Connection": "Keep-Alive"], "name=Mark&surname=White");
195 	 * assert(post.toString() == "POST /sub.php HTTP/1.1\r\nConnection: Keep-Alive\r\n\r\nname=Mark&surname=White");
196 	 * ---
197 	 */
198 	public static Request post(string path, string[string] headers=defaultHeaders, string body_="") {
199 		Request request = new Request(POST, path, headers);
200 		request.body_ = body_;
201 		return request;
202 	}
203 	
204 	/// ditto
205 	public static Request post(string path, string data, string[string] headers=defaultHeaders) {
206 		return post(path, headers, data);
207 	}
208 	
209 	/**
210 	 * Encodes the request into a string.
211 	 * Example:
212 	 * ---
213 	 * auto request = new Request(Request.GET, "index.html", ["Connection": "Keep-Alive"]);
214 	 * assert(request.toString() == "GET /index.html HTTP/1.1\r\nConnection: Keep-Alive\r\n");
215 	 * ---
216 	 */
217 	public override string toString() {
218 		if(this.body_.length) this.headers["Content-Length"] = to!string(this.body_.length);
219 		return encodeHTTP(this.method.toUpper() ~ " " ~ encode(this.path) ~ " HTTP/1.1", this.headers, this.body_);
220 	}
221 	
222 	/**
223 	 * Parses a string and returns a Request.
224 	 * If the request is successfully parsed Request.valid will be true.
225 	 * Please note that every key in the header is converted to lowercase for
226 	 * an easier search in the associative array.
227 	 * Example:
228 	 * ---
229 	 * auto request = Request.parse("GET /index.html HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: Keep-Alive\r\n");
230 	 * assert(request.valid);
231 	 * assert(request.method == Request.GET);
232 	 * assert(request.headers["Host"] == "127.0.0.1");
233 	 * assert(request.headers["Connection"] == "Keep-Alive");
234 	 * ---
235 	 */
236 	public bool parse(string data) {
237 		string status;
238 		if(decodeHTTP(data, status, this.headers, this._body)) {
239 			string[] spl = status.split(" ");
240 			if(spl.length == 3) {
241 				this.method = spl[0];
242 				this.path = decode(spl[1]);
243 				return true;
244 			}
245 		}
246 		return false;
247 	}
248 	
249 }
250 
251 /**
252  * Container for an HTTP response.
253  * Example:
254  * ---
255  * new Response(200, ["Connection": "Close"], "<b>Hi there</b>");
256  * new Response(404, [], "Cannot find the specified path");
257  * new Response(204);
258  * ---
259  */
260 class Response : HTTP {
261 	
262 	/**
263 	 * Status of the response.
264 	 */
265 	Status status;
266 	
267 	/**
268 	 * If the response was parsed, indicates whether it was in a
269 	 * valid HTTP format.
270 	 */
271 	bool valid;
272 
273 	public this() {}
274 	
275 	public this(Status status, string[string] headers=defaultHeaders, string body_="") {
276 		this.status = status;
277 		this.headers = headers;
278 		this.body_ = body_;
279 	}
280 	
281 	public this(uint statusCode, string[string] headers=defaultHeaders, string body_="") {
282 		this(Status.get(statusCode), headers, body_);
283 	}
284 	
285 	public this(Status status, string body_) {
286 		this(status, defaultHeaders, body_);
287 	}
288 	
289 	public this(uint statusCode, string body_) {
290 		this(statusCode, defaultHeaders, body_);
291 	}
292 	
293 	/**
294 	 * Creates a response for an HTTP error an automatically generates
295 	 * an HTML page to display it.
296 	 * Example:
297 	 * ---
298 	 * Response.error(404);
299 	 * Response.error(StatusCodes.methodNotAllowed, ["Allow": "GET"]);
300 	 * ---
301 	 */
302 	public static Response error(Status status, string[string] headers=defaultHeaders) {
303 		immutable message = status.toString();
304 		headers["Content-Type"] = "text/html";
305 		return new Response(status, headers, "<!DOCTYPE html><html><head><title>" ~ message ~ "</title></head><body><center><h1>" ~ message ~ "</h1></center><hr><center>" ~ headers.get("Server", "sel-net") ~ "</center></body></html>");
306 	}
307 	
308 	/// ditto
309 	public static Response error(uint statusCode, string[string] headers=defaultHeaders) {
310 		return error(Status.get(statusCode), headers);
311 	}
312 	
313 	/**
314 	 * Creates a 3xx redirect response and adds the `Location` field to
315 	 * the header.
316 	 * If not specified status code `301 Moved Permanently` will be used.
317 	 * Example:
318 	 * ---
319 	 * Response.redirect("/index.html");
320 	 * Response.redirect(302, "/view.php");
321 	 * Response.redirect(StatusCodes.seeOther, "/icon.png", ["Server": "sel-net"]);
322 	 * ---
323 	 */
324 	public void redirect(Status status, string location) {
325 		this.status = status;
326 		this.headers["Location"] = location;
327 	}
328 	
329 	/// ditto
330 	public void redirect(uint statusCode, string location) {
331 		this.redirect(Status.get(statusCode), location);
332 	}
333 	
334 	/// ditto
335 	public void redirect(string location) {
336 		this.redirect(StatusCodes.movedPermanently, location);
337 	}
338 	
339 	/**
340 	 * Encodes the response into a string.
341 	 * The `Content-Length` header field is created automatically
342 	 * based on the length of the content field.
343 	 * Example:
344 	 * ---
345 	 * auto response = new Response(200, [], "Hi");
346 	 * assert(response.toString() == "HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nHi");
347 	 * ---
348 	 */
349 	public override string toString() {
350 		this.headers["Content-Length"] = to!string(this.body_.length);
351 		return encodeHTTP("HTTP/1.1 " ~ this.status.toString(), this.headers, this.body_);
352 	}
353 	
354 	/**
355 	 * Parses a string and returns a Response.
356 	 * If the response is successfully parsed Response.valid will be true.
357 	 * Please note that every key in the header is converted to lowercase for
358 	 * an easier search in the associative array.
359 	 * Example:
360 	 * ---
361 	 * auto response = Response.parse("HTTP/1.1 200 OK\r\nContent-Type: plain/text\r\nContent-Length: 4\r\n\r\ntest");
362 	 * assert(response.valid);
363 	 * assert(response.status == 200);
364 	 * assert(response.headers["content-type"] == "text/plain");
365 	 * assert(response.headers["content-length"] == "4");
366 	 * assert(response.content == "test");
367 	 * ---
368 	 */
369 	public bool parse(string str) {
370 		string status;
371 		if(decodeHTTP(str, status, this.headers, this._body)) {
372 			string[] head = status.split(" ");
373 			if(head.length >= 3) {
374 				try {
375 					this.status = Status(to!uint(head[1]), join(head[2..$], " "));
376 					return true;
377 				} catch(ConvException) {}
378 			}
379 		}
380 		return false;
381 	}
382 	
383 }
384 
385 private enum CR_LF = "\r\n";
386 
387 private string encodeHTTP(string status, string[string] headers, string content) {
388 	Appender!string ret;
389 	ret.put(status);
390 	ret.put(CR_LF);
391 	foreach(key, value; headers) {
392 		ret.put(key);
393 		ret.put(": ");
394 		ret.put(value);
395 		ret.put(CR_LF);
396 	}
397 	ret.put(CR_LF); // empty line
398 	ret.put(content);
399 	return ret.data;
400 }
401 
402 private bool decodeHTTP(string str, ref string status, ref string[string] headers, ref string content) {
403 	string[] spl = str.split(CR_LF);
404 	if(spl.length > 1) {
405 		status = spl[0];
406 		size_t index;
407 		while(++index < spl.length && spl[index].length) { // read until empty line
408 			auto s = spl[index].split(":");
409 			if(s.length >= 2) {
410 				headers[s[0].strip.toLower()] = s[1..$].join(":").strip;
411 			} else {
412 				return false; // invalid header
413 			}
414 		}
415 		content = join(spl[index+1..$], "\r\n");
416 		return true;
417 	} else {
418 		return false;
419 	}
420 }
421 
422 class Resource {
423 
424 	private immutable string mime;
425 
426 	public immutable size_t thresold;
427 	
428 	public const(void)[] uncompressed;
429 	public const(void)[] compressed = null;
430 
431 	public this(string mime, size_t thresold=512) {
432 		this.mime = mime;
433 		this.thresold = thresold;
434 	}
435 
436 	public this(string mime, in void[] data) {
437 		this(mime);
438 		this.data = data;
439 	}
440 
441 	public @property const(void)[] data(in void[] data) {
442 		this.uncompressed = data;
443 		if(data.length >= this.thresold) this.compress();
444 		else this.compressed = null;
445 		return this.uncompressed;
446 	}
447 	
448 	private void compress() {
449 		Compress compress = new Compress(6, HeaderFormat.gzip);
450 		auto data = compress.compress(this.uncompressed);
451 		data ~= compress.flush();
452 		this.compressed = data;
453 	}
454 
455 	public void apply(Request req, Response res) {
456 		if(this.compressed !is null && req.headers.get("accept-encoding", "").indexOf("gzip") != -1) {
457 			res.headers["Content-Encoding"] = "gzip";
458 			res.body_ = cast(string)this.compressed;
459 		} else {
460 			res.body_ = cast(string)this.uncompressed;
461 		}
462 		res.headers["Content-Type"] = this.mime;
463 	}
464 	
465 }
466 
467 class CachedResource : Resource {
468 
469 	private string etag;
470 
471 	public this(string mime, size_t thresold=512) {
472 		super(mime, thresold);
473 	}
474 	
475 	public this(string mime, in void[] data) {
476 		super(mime, data);
477 	}
478 
479 	public override @property const(void)[] data(in void[] data) {
480 		this.etag = Base64URLNoPadding.encode(crc32Of(data));
481 		return super.data(data);
482 	}
483 
484 	public override void apply(Request req, Response res) {
485 		if(req.headers.get("if-none-match", "") == this.etag) {
486 			res.status = StatusCodes.notModified;
487 		} else {
488 			super.apply(req, res);
489 			res.headers["Cache-Control"] = "public, max-age=31536000";
490 			res.headers["ETag"] = this.etag;
491 		}
492 	}
493 
494 }