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 }