├── LICENSE
├── README.md
├── examples
├── example_1_hello_world
│ └── app.nim
├── example_2
│ ├── app.nim
│ ├── public
│ │ ├── images
│ │ │ ├── kiira.jpg
│ │ │ └── road.jpg
│ │ └── videos
│ │ │ └── burger.webm
│ └── templates
│ │ ├── index.html
│ │ └── layout.html
├── example_3_domains
│ ├── app.nim
│ └── domains
│ │ ├── api.nim
│ │ └── main.nim
├── example_4_host_subdomain_router
│ └── server.nim
├── example_5_withData
│ └── app.nim
├── example_6_fetch
│ └── app.nim
├── example_7_websocket_chat
│ ├── app.nim
│ ├── public
│ │ └── css
│ │ │ └── styles.css
│ └── templates
│ │ ├── index.html
│ │ └── layout.html
└── example_8_requestHook_app
│ └── app.nim
├── src
├── xander.nim
└── xander
│ ├── cli.nim
│ ├── constants.nim
│ ├── contenttype.nim
│ ├── install.sh
│ ├── tools.nim
│ ├── types.nim
│ ├── ws.nim
│ └── zip
│ └── zlib_modified.nim
└── xander.nimble
/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright (c) 2018 Santeri J.P. Sydänmetsä
2 |
3 | Permission is hereby granted, free of charge, to any person obtaining
4 | a copy of this software and associated documentation files (the
5 | "Software"), to deal in the Software without restriction, including
6 | without limitation the rights to use, copy, modify, merge, publish,
7 | distribute, sublicense, and/or sell copies of the Software, and to
8 | permit persons to whom the Software is furnished to do so, subject to
9 | the following conditions:
10 |
11 | The above copyright notice and this permission notice shall be included
12 | in all copies or substantial portions of the Software.
13 |
14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
17 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
18 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
19 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
20 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Xander
2 | Xander is an easy to use web application development library and framework for the [Nim programming language](https://nim-lang.org). Nim is a statically typed language with a Python-like syntax and a powerful macro system, something Xander uses greatly to its advantage.
3 |
4 | ## Installation
5 | The easiest way to install Xander is to use [Nimble](https://github.com/nim-lang/nimble), which is bundled with the Nim installation.
6 |
7 | ```nimble install https://github.com/sunjohanday/Xander.git```
8 |
9 | Otherwise you can download this git repository and ```import xander``` with the appropriate relative file path, e.g. ```import ../xander/xander```
10 |
11 | **OPTIONAL** If you wish to install Xander CLI, enter the following line on the command line (on Linux):
12 |
13 | ```~/.nimble/pkgs/Xander-0.6.0/Xander/install.sh```
14 |
15 | You can manually perform the tasks the CLI ```install.sh``` script performs. Simply compile the downloaded ```xander.nim``` file and run the executable.
16 |
17 | A basic Xander-app example:
18 | ```nim
19 | import xander
20 |
21 | get "/":
22 | respond "Hello World!"
23 |
24 | runForever(3000)
25 | ```
26 | More examples can be found in the ```examples``` folder.
27 |
28 | ## The Gist of It
29 | Xander injects variables for the developer to use in request handlers. These variables are:
30 |
31 | - request, the http request
32 | - data, contains data sent from the client such as get parameters and form data (shorthad for JsonNode)
33 | - headers, for setting response headers (see request.headers for request headers)
34 | - cookies, for accessing request cookies and setting response cookies
35 | - session, client specific session variables
36 | - files, uploaded files
37 |
38 | ```nim
39 | # Request Handler definition
40 | type
41 | RequestHandler* =
42 | proc(request: Request, data: var Data, headers: var HttpHeaders, cookies: var Cookies, session: var Session, files: var UploadFiles): Response {.gcsafe.}
43 | ```
44 |
45 | These variables do a lot of the legwork required in an effective web application.
46 |
47 | ## Serving files
48 | To serve files from a directory (and its sub-directories)
49 | ```nim
50 | # app.nim
51 | # the app dir contains a directory called 'public'
52 | serveFiles "/public"
53 | ```
54 | ```html
55 |
56 |
57 |
58 | ```
59 |
60 | ## Templates
61 | Xander provides support for templates, although it is very much a work in progress.
62 | To serve a template file:
63 | ```nim
64 | # Serve the index page
65 | respond tmplt("index")
66 | ```
67 | The default directory for templates is ```templates```, but it can also be changed by calling
68 | ```nim
69 | setTemplateDirectory("views")
70 | ```
71 | By having a ```layout.html``` template one can define a base layout for their pages.
72 | ```html
73 |
74 |
75 |
Name | 129 |Age | 130 |Hobbies | 131 |
---|---|---|
{[ person.name ]} | 136 |{[ person.age ]} | 137 |
138 |
|
144 |
{[name]} is {[age]} years old.
167 | ``` 168 | 169 | ## Dynamic routes 170 | To match a custom route and get the provided value(s), one must simply use a colon to specify a dynamic value. The values will be stored in the ```data``` parameter implicitly. 171 | ```nim 172 | # User requests /countries/ireland/people/paddy 173 | get "/countries/:country/people/:person": 174 | assert(data["country"] == "ireland") 175 | assert(data["person"] == "paddy") 176 | respond tmplt("userPage", data) 177 | ) 178 | ``` 179 | ```html 180 |File Upload
6 | 11 | 12 |Uploaded files on server
15 | {[UploadedFiles]} -------------------------------------------------------------------------------- /examples/example_2/templates/layout.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |{message}
" 222 | content = html(content) 223 | await serve(request, httpCode, content) 224 | 225 | proc parseFormMultiPart(body, boundary: string, data: var Data, files: var UploadFiles): void = 226 | let fileInfos = body.split(boundary) # Contains: Content-Disposition, Content-Type, Others... and actual content 227 | var 228 | parts: seq[string] 229 | fileName, fileExtension, content, varName: string 230 | size: int 231 | for fileInfo in fileInfos: 232 | if "Content-Disposition" in fileInfo: 233 | parts = fileInfo.split("\c\n\c\n", 1) 234 | assert parts.len == 2 235 | for keyVals in parts[0].split(";"): 236 | if " name=" in keyVals: 237 | varName = keyVals.split(" name=\"")[1].strip(chars = {'"'}) 238 | if " filename=" in keyVals: 239 | fileName = keyVals.split(" filename=\"")[1].split("\"")[0] 240 | fileExtension = if '.' in fileName: fileName.split(".")[1] else: "file" 241 | content = parts[1][0..parts[1].len - 3] # Strip the last two characters out = \r\n 242 | size = content.len 243 | # Add variables to Data and files to UploadFiles 244 | if fileName.len == 0: # Data 245 | data[varName] = content#.strip() 246 | else: # UploadFiles 247 | if not files.hasKey(varName): 248 | files[varName] = newSeq[UploadFile]() 249 | files[varName].add(newUploadFile(fileName, fileExtension, content, size)) 250 | varName = ""; fileName = ""; content = "" 251 | 252 | proc uploadFile*(directory: string, file: UploadFile, name = ""): void = 253 | let fileName = if name == "": file.name else: name 254 | var filePath = if directory.endsWith("/"): directory & fileName else: directory & "/" & fileName 255 | try: 256 | writeFile(filePath, file.content) 257 | except IOError: 258 | logger.log(lvlError, "IOError: Failed to write file") 259 | 260 | proc uploadFiles*(directory: string, files: seq[UploadFile]): void = 261 | var filePath: string 262 | let dir = if directory.endsWith("/"): directory else: directory & "/" 263 | for file in files: 264 | filePath = dir & file.name 265 | try: 266 | writeFile(filePath, file.content) 267 | except IOError: 268 | logger.log(lvlError, &"IOError: Failed to write file '{filePath}'") 269 | 270 | proc parseForm(body: string): JsonNode = 271 | result = newJObject() 272 | # Use decodeUrl(body, false) if plusses (+) should not 273 | # be considered spaces. 274 | let urlDecoded = decodeUrl(body) 275 | let kvPairs = urlDecoded.split("&") 276 | for kvPair in kvPairs: 277 | # Assuming the key doesn't contain equals signs, 278 | # but the value does, split once 279 | let kvArray = kvPair.split("=", 1) 280 | var key = kvArray[0] 281 | let value = kvArray[1] 282 | if "[]" in key: 283 | key = key[0 .. key.len - 3] 284 | if result.hasKey(key): 285 | var arr = result[key].getElems() 286 | arr.add(newJString(value)) 287 | result.set(key, arr) 288 | else: 289 | let arr = newJArray() 290 | arr.add(newJString(value)) 291 | result.set(key, arr) 292 | else: 293 | result.set(key, value) 294 | 295 | proc getJsonData(keys: OrderedTable[string, JsonNode], node: JsonNode): JsonNode = 296 | result = newJObject() 297 | for key in keys.keys: 298 | result{key} = node[key] 299 | 300 | proc parseRequestBody(body: string): JsonNode = 301 | try: # JSON 302 | var 303 | parsed = json.parseJson(body) 304 | keys = parsed.getFields() 305 | return getJsonData(keys, parsed) 306 | except: # Form 307 | return parseForm(body) 308 | 309 | func parseUrlQuery(query: string, data: var Data): void = 310 | let query = decodeUrl(query) 311 | if query.len > 0: 312 | for parameter in query.split("&"): 313 | if "=" in parameter: 314 | let pair = parameter.split("=") 315 | data[pair[0]] = pair[1] 316 | else: 317 | data[parameter] = true 318 | 319 | proc isValidGetPath(url, kind: string, params: var Data): bool = 320 | # Checks if the given 'url' matches 'kind'. As the 'url' can be dynamic, 321 | # the checker will ignore differences if a 'kind' subpath starts with a colon. 322 | var 323 | kind = kind.split("/") 324 | klen = kind.len 325 | url = url.split("/") 326 | result = true 327 | if url.len == klen: 328 | for i in 0..klen-1: 329 | if ":" in kind[i]: 330 | params[kind[i][1 .. kind[i].len - 1]] = url[i] 331 | elif kind[i] != url[i]: 332 | result = false 333 | break 334 | else: 335 | result = false 336 | 337 | proc hasSubdomain(host: string, subdomain: var string): bool = 338 | let domains = host.split('.') 339 | let count = domains.len 340 | if count > 1: # ["api", "mysite", "com"] ["mysite", "com"] ["api", "localhost"] ["192", "168", "1", "43"] 341 | if count == 3 and domains[0] == "www": 342 | return false 343 | elif count == 4: 344 | var isIpAddress = true 345 | for d in domains: 346 | try: 347 | discard parseInt(d) 348 | except: 349 | isIpAddress = false 350 | break 351 | if isIpAddress: 352 | subdomain = defaultDomain 353 | return true 354 | elif (count >= 3) or (count == 2 and domains[1].split(":")[0] == "localhost"): 355 | subdomain = domains[0] 356 | result = true 357 | 358 | proc checkPath(request: Request, kind: string, data: var Data, files: var UploadFiles): bool = 359 | # For get requests, checks that the path (which could be dynamic) is valid, 360 | # and gets the url parameters. For other requests, the request body is parsed. 361 | # The 'kind' parameter is an existing route. 362 | if request.reqMethod == HttpGet: # URL parameters 363 | result = isValidGetPath(request.url.path, kind, data) 364 | else: # Form body 365 | let contentType = request.headers["Content-Type"].split(";") # TODO: Use me wisely to detemine how to parse request body 366 | if request.url.path == kind: 367 | result = true 368 | if request.body.len > 0: 369 | if "multipart/form-data" in contentType: # File upload 370 | let boundary = "--" & contentType[1].split("=")[1] 371 | parseFormMultiPart(request.body, boundary, data, files) 372 | else: # Other 373 | data = parseRequestBody(request.body) 374 | 375 | proc setResponseCookies*(response: var types.Response, cookies: Cookies): void = 376 | for key, cookie in cookies.server: 377 | response.headers.add("Set-Cookie", 378 | cookies_module.setCookie( 379 | cookie.name, cookie.value, cookie.domain, cookie.path, cookie.expires, true, cookie.secure, cookie.httpOnly)) 380 | 381 | # TODO: This is not very random 382 | # SHA-1 Hash 383 | proc generateSessionId(): string = 384 | $secureHash($(cpuTime() + rand(10000).float)) 385 | 386 | proc setResponseHeaders(response: var types.Response, headers: HttpHeaders): void = 387 | for key, val in headers.pairs: 388 | response.headers.add(key, val) 389 | 390 | proc getSession(cookies: var Cookies, session: var Session): string = 391 | # Gets a session if one exists. Initializes a new one if it doesn't. 392 | var ssid: string 393 | if not cookies.contains("XANDER-SSID"): 394 | ssid = $generateSessionId() 395 | sessions[ssid] = session 396 | cookies.set("XANDER-SSID", ssid, httpOnly = true) 397 | else: 398 | ssid = cookies.get("XANDER-SSID") 399 | if sessions.hasKey(ssid): 400 | session = sessions[ssid] 401 | return ssid 402 | 403 | # The default content-type header is text/plain. 404 | proc setContentTypeToHTMLIfNeeded(response: types.Response, headers: var HttpHeaders): void = 405 | if "" in response.body: 406 | headers["Content-Type"] = "text/html; charset=UTF-8" 407 | 408 | proc setDefaultHeaders(headers: var HttpHeaders): void = 409 | headers["Cache-Control"] = "public; max-age=" & $(60*60*24*7) # One week 410 | headers["Connection"] = "keep-alive" 411 | headers["Content-Type"] = "text/plain; charset=UTF-8" 412 | headers["Content-Security-Policy"] = "font-src 'self'" 413 | headers["Feature-Policy"] = "autoplay 'none'" 414 | headers["Referrer-Policy"] = "no-referrer" 415 | headers["Server"] = "xander" 416 | headers["Vary"] = "User-Agent, Accept-Encoding" 417 | headers["X-Content-Type-Options"] = "nosniff" 418 | headers["X-Frame-Options"] = "DENY" 419 | headers["X-XSS-Protection"] = "1; mode=block" 420 | 421 | # TODO: Some say .pngs should not be compressed 422 | proc gzip(response: var types.Response, request: Request, headers: var HttpHeaders): void = 423 | if request.headers.hasKey("accept-encoding"): 424 | if "gzip" in request.headers["accept-encoding"]: 425 | try: 426 | # Uses a modified version of zip/zlib.nim 427 | response.body = compress(response.body, response.body.len, Z_DEFLATED) 428 | headers["content-encoding"] = "gzip" 429 | except: 430 | # Deprecated, since the modified version is used 431 | logger.log(lvlError, "Failed to gzip compress. Did you set 'Type Ulong* = uint'?") 432 | 433 | proc parseHostAndDomain(request: Request): tuple[host, domain: string] = 434 | result = (defaultHost, defaultDomain) 435 | if request.headers.hasKey("host"): 436 | let url = $request.headers["host"].split(':')[0] # leave the port out of this! 437 | let parts = url.split('.') 438 | result = case parts.len: 439 | of 1: 440 | (parts[0], defaultDomain) # localhost 441 | of 2: 442 | if parts[1] == "localhost": 443 | (parts[1], parts[0]) # api.localhost 444 | else: 445 | (parts[0], defaultDomain) # site.com 446 | of 3: 447 | (parts[1], parts[0]) # api.site.com 448 | else: 449 | # TODO: NOT GOOD AT ALL 450 | (defaultHost, defaultDomain) 451 | 452 | proc getHostAndDomain(request: Request): tuple[host, domain: string] = 453 | if xanderRoutes.isDefaultHost(): 454 | var subdomain: string 455 | if request.headers.hasKey("host") and hasSubdomain(request.headers["host"], subdomain): 456 | (defaultHost, subdomain) 457 | else: 458 | (defaultHost, defaultDomain) 459 | else: 460 | parseHostAndDomain(request) 461 | 462 | # EXPERIMENTAL 463 | type RequestHookProc* = proc(r: Request): Future[void] {.gcsafe.} 464 | var requestHook* {.threadvar.} : RequestHookProc # == nil 465 | 466 | # EXPERIMENTAL 467 | type WebSocketHandler* = proc(ws: WebSocket): Future[void] {.gcsafe.} 468 | var websockets {.threadvar.} : Table[string, WebSocketHandler] 469 | websockets = initTable[string, WebSocketHandler]() 470 | 471 | proc handleWebSockets(request: Request): Future[void] {.async,gcsafe.} = 472 | if websockets.hasKey(request.url.path): 473 | try: 474 | var ws = await newWebSocket(request) 475 | await websockets[request.url.path](ws) 476 | except: 477 | discard 478 | 479 | # TODO: Check that request size <= server max allowed size 480 | # Called on each request to server 481 | proc onRequest(request: Request): Future[void] {.async,gcsafe.} = 482 | # Experimental: Request Hook 483 | if requestHook != nil: 484 | await requestHook(request) 485 | # Experimental: Built-in Web Sockets 486 | await handleWebSockets(request) 487 | # Initialize variables 488 | var (data, headers, cookies, session, files) = newRequestHandlerVariables() 489 | var (host, domain) = getHostAndDomain(request) 490 | if xanderRoutes.existsMethod(request.reqMethod, host, domain): 491 | # At this point, we're inside the domain! 492 | for serverRoute in xanderRoutes[host][domain][request.reqMethod]: 493 | if checkPath(request, serverRoute.route, data, files): 494 | # Get URL query parameters 495 | parseUrlQuery(request.url.query, data) 496 | # Parse cookies from header 497 | let parsedCookies = parseCookies(request.headers.getOrDefault("Cookie")) 498 | # Cookies sent from client 499 | for key, val in parsedCookies.pairs: 500 | cookies.setClient(key, val) 501 | # Create or get session and session ID 502 | let ssid = getSession(cookies, session) 503 | # Set default headers 504 | setDefaultHeaders(headers) 505 | # Request handler response 506 | var response: types.Response 507 | try: 508 | response = serverRoute.handler(request, data, headers, cookies, session, files) 509 | except: 510 | logger.log(lvlError, "Request Handler broke with request path '$1'.".format(request.url.path)) 511 | response.httpCode = Http500 512 | response.body = "Internal server error" 513 | response.headers = newHttpHeaders() 514 | # TODO: Fix the way content type is determined 515 | setContentTypeToHTMLIfNeeded(response, headers) 516 | # gzip encode if needed 517 | gzip(response, request, headers) 518 | # Update session 519 | sessions[ssid] = session 520 | # Cookies set on server => add them to headers 521 | setResponseCookies(response, cookies) 522 | # Put headers into response 523 | setResponseHeaders(response, headers) 524 | await request.respond(response.httpCode, response.body, response.headers) 525 | await serveError(request, Http404) 526 | 527 | # TODO: Check port range 528 | proc runForever*(port: uint = 3000, message: string = "Xander server is up and running!"): void = 529 | logger.log(lvlInfo, message) 530 | defer: close(xanderServer) 531 | waitFor xanderServer.serve(Port(port), onRequest) 532 | 533 | proc addRoute*(host = defaultHost, domain = defaultDomain, httpMethod: HttpMethod, route: string, handler: RequestHandler): void = 534 | xanderRoutes.addRoute(httpMethod, route, handler, host, domain) 535 | 536 | proc addGet*(host, domain, route: string, handler: RequestHandler): void = 537 | addRoute(host, domain, HttpGet, route, handler) 538 | 539 | proc addPost*(host, domain, route: string, handler: RequestHandler): void = 540 | addRoute(host, domain, HttpPost, route, handler) 541 | 542 | proc addPut*(host, domain, route: string, handler: RequestHandler): void = 543 | addRoute(host, domain, HttpPut, route, handler) 544 | 545 | proc addDelete*(host, domain, route: string, handler: RequestHandler): void = 546 | addRoute(host, domain, HttpDelete, route, handler) 547 | 548 | # TODO: Build source using Nim Nodes instead of strings 549 | proc buildRequestHandlerSource(host, domain, reqMethod, route, body: string): string = 550 | var source = &"add{reqMethod}({tools.quote(host)}, {tools.quote(domain)}, {tools.quote(route)}, {requestHandlerString}{newLine}" 551 | for row in body.split(newLine): 552 | if row.len > 0: 553 | source &= &"{tab}{row}{newLine}" 554 | return source & ")" 555 | 556 | macro get*(route: string, body: untyped): void = 557 | let requestHandlerSource = buildRequestHandlerSource(defaultHost, defaultDomain, "Get", unquote(route), repr(body)) 558 | parseStmt(requestHandlerSource) 559 | 560 | macro x_get*(host, domain, route: string, body: untyped): void = 561 | let requestHandlerSource = buildRequestHandlerSource(unquote(host), unquote(domain), "Get", unquote(route), repr(body)) 562 | parseStmt(requestHandlerSource) 563 | 564 | macro post*(route: string, body: untyped): void = 565 | let requestHandlerSource = buildRequestHandlerSource(defaultHost, defaultDomain, "Post", unquote(route), repr(body)) 566 | parseStmt(requestHandlerSource) 567 | 568 | macro x_post*(host, domain, route: string, body: untyped): void = 569 | let requestHandlerSource = buildRequestHandlerSource(unquote(host), unquote(domain), "Post", unquote(route), repr(body)) 570 | parseStmt(requestHandlerSource) 571 | 572 | macro put*(route: string, body: untyped): void = 573 | let requestHandlerSource = buildRequestHandlerSource(defaultHost, defaultDomain, "Put", unquote(route), repr(body)) 574 | parseStmt(requestHandlerSource) 575 | 576 | macro x_put*(host, domain, route: string, body: untyped): void = 577 | let requestHandlerSource = buildRequestHandlerSource(unquote(host), unquote(domain), "Put", unquote(route), repr(body)) 578 | parseStmt(requestHandlerSource) 579 | 580 | macro delete*(route: string, body: untyped): void = 581 | let requestHandlerSource = buildRequestHandlerSource(defaultHost, defaultDomain, "Delete", unquote(route), repr(body)) 582 | parseStmt(requestHandlerSource) 583 | 584 | macro delete*(host, domain, route: string, body: untyped): void = 585 | let requestHandlerSource = buildRequestHandlerSource(unquote(host), unquote(domain), "Delete", unquote(route), repr(body)) 586 | parseStmt(requestHandlerSource) 587 | 588 | proc startsWithRequestMethod(s: string): bool = 589 | let methods = @["get", "post", "put", "delete"] 590 | for m in methods: 591 | if s.startsWith(m): 592 | return true 593 | 594 | proc reformatRouterCode(host, domain, path, body: string): string = 595 | for line in body.split(newLine): 596 | var lineToAdd = line & newLine 597 | if line.len > 0 and ':' in line: 598 | # request handler 599 | if startsWithRequestMethod(line): 600 | let requestHandlerDefinition = line.split(" ") 601 | let requestMethod = requestHandlerDefinition[0] 602 | var route = requestHandlerDefinition[1] 603 | if route == "\"/\":": 604 | route = "\"\"" 605 | route = route[0] & path & route[1..route.len - 1] 606 | lineToAdd = "x_" & requestMethod & " " & tools.quote(host) & ", " & tools.quote(domain) & ", " & route & ":" & newLine 607 | elif route.startsWith('"') and route.endsWith("\":") and route[1] == '/': 608 | route = route[0] & path & route[1..route.len - 1] 609 | lineToAdd = "x_" & requestMethod & " " & tools.quote(host) & ", " & tools.quote(domain) & ", " & route & newLine 610 | result &= lineToAdd 611 | 612 | macro router*(route, body: untyped): void = 613 | let path = repr(route).unquote 614 | let body = reformatRouterCode(defaultHost, defaultDomain, path, repr(body)) 615 | parseStmt(body) 616 | 617 | macro x_router*(host, domain, route, body: untyped): void = 618 | let body = reformatRouterCode(host.unquote, domain.unquote, route.unquote, repr(body)) 619 | parseStmt(body) 620 | 621 | proc reformatSubdomainCode(domain, host, body: string): string = 622 | # transforms request macros and routers to x_gets and x_routers 623 | for line in body.split(newLine): 624 | var lineToAdd = line & newLine 625 | if line.len > 0 and ':' in line: 626 | # request handler not within a host-block 627 | if startsWithRequestMethod(line): 628 | let requestHandlerDefinition = line.split(" ") 629 | let requestMethod = requestHandlerDefinition[0] 630 | let route = requestHandlerDefinition[1] 631 | lineToAdd = "x_" & requestMethod & " " & tools.quote(host) & ", " & tools.quote(domain) & ", " & route & newLine 632 | # router not within a host-block 633 | elif line.startsWith("router"): 634 | let routerDefinition = line.split(" ") 635 | var route = routerDefinition[1] 636 | lineToAdd = "x_router " & tools.quote(host) & ", " & tools.quote(domain) & ", " & route & newLine 637 | result &= lineToAdd 638 | 639 | macro subdomain*(name, body: untyped): void = 640 | let name = repr(name).unquote.toLower 641 | let body = reformatSubdomainCode(name, defaultHost, repr(body)) 642 | parseStmt(body) 643 | 644 | proc reformatHostCode(host, body: string): string = 645 | var domain = defaultDomain 646 | var router = "" 647 | for line in body.split(newLine): 648 | var lineToAdd = line & newLine 649 | if line.len > 0 and ':' in line: 650 | # Subdomain 651 | if strip(line).startsWith("subdomain"): 652 | router = "" 653 | domain = unquote(line.split(" ")[1].replace(":", "")) 654 | # Router 655 | elif strip(line).startsWith("router"): 656 | if indentation(line) == 0: domain = defaultDomain 657 | router = strip(line).split(" ")[1].replace(":", "").unquote 658 | # Request Handler 659 | elif strip(line).startsWithRequestMethod: 660 | let indent = indentation(line) # level of indentation 661 | case indent: 662 | of 0: # just inside host 663 | domain = defaultDomain 664 | router = "" 665 | else: # other options 666 | discard 667 | var line = line[indent .. line.len - 1] 668 | let handlerDef = line.split(" ") 669 | var route = handlerDef[1].unquote.strip(chars = {':'}) 670 | route = if route.len == 1 and router != "": router else: router & route[0 .. route.len - 1] 671 | lineToAdd = repeat(" ", indent) & "x_" & handlerDef[0] & " " & tools.quote(host) & ", " & tools.quote(domain) & ", " & tools.quote(route) & ":" & newLine 672 | # Append line to result 673 | result &= lineToAdd 674 | 675 | macro host*(host, body: untyped): void = 676 | let host = repr(host).unquote.toLower 677 | let body = reformatHostCode(host, repr(body)) 678 | parseStmt(body) 679 | 680 | proc addWebSocketProc*(path: string, p: WebSocketHandler): void = 681 | websockets[path] = p 682 | 683 | macro websocket*(path, body: untyped): void = 684 | var src = "addWebSocketProc($1, proc(ws: WebSocket) {.async.} =\n".format(repr(path)) 685 | for line in repr(body).split( newLine ): 686 | src &= tab & line & newLine 687 | src &= ")" 688 | parseStmt(src) 689 | 690 | proc printServerStructure*(): void = 691 | logger.log(lvlInfo, xanderRoutes) 692 | 693 | # TODO: Dynamically created directories are not supported 694 | proc serveFiles*(route: string): void = 695 | # Given a route, e.g. '/public', this proc 696 | # adds a get method for provided directory and 697 | # its child directories. This proc is RECURSIVE. 698 | var route = route.replace(applicationDirectory, "") 699 | logger.log(lvlInfo, "Serving files from ", applicationDirectory & route) 700 | let path = if route.endsWith("/"): route[0..route.len-2] else: route # /public/ => /public 701 | let newRoute = path & "/:fileName" # /public/:fileName 702 | for host in xanderRoutes.keys: # add file serving for every host 703 | addGet(host, defaultDomain, newRoute, proc(request: Request, data: var Data, headers: var HttpHeaders, cookies: var Cookies, session: var Session, files: var UploadFiles): types.Response {.gcsafe.} = 704 | let filePath = applicationDirectory & path / decodeUrl(data.get("fileName")) # ./public/.../fileName 705 | let ext = splitFile(filePath).ext 706 | if existsFile(filePath): 707 | headers["Content-Type"] = getContentType(ext) 708 | respond readFile(filePath) 709 | else: respond Http404) 710 | for directory in walkDirs(applicationDirectory & path & "/*"): 711 | serveFiles(directory) 712 | 713 | proc fetch*(url: string): string = 714 | var client = newAsyncHttpClient() 715 | waitFor client.getContent(url) 716 | 717 | setControlCHook(proc() {.noconv.} = 718 | close(xanderServer) 719 | quit(0) 720 | ) 721 | 722 | when isMainModule: 723 | 724 | include xander/cli 725 | -------------------------------------------------------------------------------- /src/xander/cli.nim: -------------------------------------------------------------------------------- 1 | import 2 | os, 3 | osproc, 4 | sequtils, 5 | strutils 6 | 7 | proc newProject(appDir = "my-xander-app"): void = 8 | stdout.write "Creating project '$1'... ".format(appDir) 9 | createDir(appDir) 10 | createDir(appDir / "templates") 11 | createDir(appDir / "public") 12 | createDir(appDir / "public" / "css") 13 | createDir(appDir / "public" / "js") 14 | writeFile(appDir / "app.nim", """import xander 15 | 16 | get "/": 17 | respond tmplt("index") 18 | 19 | get "/about": 20 | respond tmplt("about") 21 | 22 | get "/contact": 23 | respond tmplt("contact") 24 | 25 | serveFiles("/public") 26 | printServerStructure() 27 | runForever(3000)""") 28 | writeFile(appDir / "templates" / "layout.html", """ 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 |Powered by Xander
49 |75 | Congratulations! This app is running Xander! For Xander reference, check out 76 | xander-nim.tk and 77 | Xander's GitHub page. 78 |
""") 79 | writeFile(appDir / "templates" / "about.html", """82 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed tempor odio quis sem tincidunt, id tincidunt velit rutrum. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Sed lectus nisl, lacinia sed volutpat a, convallis a velit. Etiam rhoncus tincidunt enim, sed interdum justo tincidunt eu. Nulla bibendum dui at mi vulputate, sed eleifend turpis sodales. Vivamus dignissim dapibus blandit. In laoreet mattis dapibus. Pellentesque magna elit, porta sed tristique et, tempus sit amet justo. Curabitur aliquet feugiat tincidunt. Fusce tempor efficitur mi blandit consequat. Praesent sed maximus risus. Nullam nulla purus, pellentesque quis leo sed, tincidunt ultrices enim. Curabitur at ante et justo porta bibendum. Morbi quis bibendum ipsum, a vulputate mi. Nulla blandit sodales diam a pharetra. Phasellus tristique, magna nec dapibus aliquam, felis nisl viverra ipsum, vitae hendrerit felis nisi ut tortor. 83 | Vestibulum ut lobortis dui, sit amet dictum dolor. Integer vitae varius lectus, quis tincidunt mauris. Phasellus sodales ligula non vestibulum faucibus. Duis ac risus eleifend nisl accumsan feugiat. Maecenas vestibulum dolor id nibh molestie, a mollis dolor viverra. Proin at feugiat est. Cras porta suscipit dignissim. Pellentesque maximus libero at eros fringilla tincidunt. Vestibulum ultricies pulvinar finibus. Phasellus id rutrum dui, vel condimentum nunc. Vivamus condimentum aliquet magna ut convallis. Maecenas congue dignissim urna, ac feugiat lorem cursus at. Integer placerat, quam id vulputate sollicitudin, dui neque pellentesque est, sed malesuada mauris ligula non ex. Sed ac dignissim massa, sit amet congue diam. 84 | Ut pellentesque rhoncus ultricies. Aenean non pretium diam. Nunc fringilla ante eleifend, maximus lacus non, euismod orci. Sed non orci ornare, condimentum sapien commodo, efficitur eros. Mauris quis magna sed sapien aliquet feugiat. Duis scelerisque purus libero, et condimentum lacus faucibus eu. Proin porttitor molestie arcu a consectetur. Nunc sed tortor non ipsum finibus condimentum. Duis quis scelerisque sem, vel sagittis lorem. Proin egestas lorem nec augue finibus dapibus. 85 | Donec malesuada ante eu diam tincidunt, id commodo nisi accumsan. Phasellus non dictum justo. Suspendisse pharetra quis dolor eu pretium. Proin consectetur imperdiet lacus quis sollicitudin. Vivamus hendrerit metus bibendum erat semper, id placerat nibh tristique. Suspendisse quis tristique leo, ac consectetur velit. Vivamus dui velit, porta a urna sit amet, tristique vestibulum orci. 86 | Curabitur velit lacus, mattis at quam vitae, ultricies ornare dui. Nam sed elementum orci. Pellentesque eu quam nunc. Morbi pharetra consectetur lorem quis molestie. Praesent viverra arcu elit, venenatis elementum diam commodo a. Integer venenatis turpis vel felis sodales, vitae iaculis odio dignissim. Aliquam erat volutpat. 87 |
""") 88 | writeFile(appDir / "templates" / "contact.html", """91 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed tempor odio quis sem tincidunt, id tincidunt velit rutrum. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Sed lectus nisl, lacinia sed volutpat a, convallis a velit. Etiam rhoncus tincidunt enim, sed interdum justo tincidunt eu. Nulla bibendum dui at mi vulputate, sed eleifend turpis sodales. Vivamus dignissim dapibus blandit. In laoreet mattis dapibus. Pellentesque magna elit, porta sed tristique et, tempus sit amet justo. Curabitur aliquet feugiat tincidunt. Fusce tempor efficitur mi blandit consequat. Praesent sed maximus risus. Nullam nulla purus, pellentesque quis leo sed, tincidunt ultrices enim. Curabitur at ante et justo porta bibendum. Morbi quis bibendum ipsum, a vulputate mi. Nulla blandit sodales diam a pharetra. Phasellus tristique, magna nec dapibus aliquam, felis nisl viverra ipsum, vitae hendrerit felis nisi ut tortor. 92 | Vestibulum ut lobortis dui, sit amet dictum dolor. Integer vitae varius lectus, quis tincidunt mauris. Phasellus sodales ligula non vestibulum faucibus. Duis ac risus eleifend nisl accumsan feugiat. Maecenas vestibulum dolor id nibh molestie, a mollis dolor viverra. Proin at feugiat est. Cras porta suscipit dignissim. Pellentesque maximus libero at eros fringilla tincidunt. Vestibulum ultricies pulvinar finibus. Phasellus id rutrum dui, vel condimentum nunc. Vivamus condimentum aliquet magna ut convallis. Maecenas congue dignissim urna, ac feugiat lorem cursus at. Integer placerat, quam id vulputate sollicitudin, dui neque pellentesque est, sed malesuada mauris ligula non ex. Sed ac dignissim massa, sit amet congue diam. 93 | Ut pellentesque rhoncus ultricies. Aenean non pretium diam. Nunc fringilla ante eleifend, maximus lacus non, euismod orci. Sed non orci ornare, condimentum sapien commodo, efficitur eros. Mauris quis magna sed sapien aliquet feugiat. Duis scelerisque purus libero, et condimentum lacus faucibus eu. Proin porttitor molestie arcu a consectetur. Nunc sed tortor non ipsum finibus condimentum. Duis quis scelerisque sem, vel sagittis lorem. Proin egestas lorem nec augue finibus dapibus. 94 | Donec malesuada ante eu diam tincidunt, id commodo nisi accumsan. Phasellus non dictum justo. Suspendisse pharetra quis dolor eu pretium. Proin consectetur imperdiet lacus quis sollicitudin. Vivamus hendrerit metus bibendum erat semper, id placerat nibh tristique. Suspendisse quis tristique leo, ac consectetur velit. Vivamus dui velit, porta a urna sit amet, tristique vestibulum orci. 95 | Curabitur velit lacus, mattis at quam vitae, ultricies ornare dui. Nam sed elementum orci. Pellentesque eu quam nunc. Morbi pharetra consectetur lorem quis molestie. Praesent viverra arcu elit, venenatis elementum diam commodo a. Integer venenatis turpis vel felis sodales, vitae iaculis odio dignissim. Aliquam erat volutpat. 96 |
""") 97 | writeFile(appDir / "public" / "css" / "styles.css", """html { 98 | min-height: 100%; 99 | } 100 | 101 | body { 102 | margin: 0 auto; 103 | min-height: 100%; 104 | font-family: Arial; 105 | }""") 106 | echo "Done!" 107 | 108 | type 109 | CmdResponse = tuple 110 | output: TaintedString 111 | exitCode: int 112 | 113 | proc cmd(s: string): CmdResponse = 114 | execCmdEx(s) 115 | 116 | proc nimCompile(fileName: string): CmdResponse = 117 | cmd("nim c --threads:on --verbosity:1 --hints:off $1".format(fileName)) 118 | 119 | proc runProject(fileName = "app.nim"): void = 120 | if existsFile(fileName): 121 | discard execCmd("rm ." / fileName.splitFile.name) 122 | discard nimCompile(fileName) 123 | discard execCmd("." / fileName.splitFile.name) 124 | else: 125 | echo "File not found: ", fileName 126 | 127 | proc getProcessId(port: string, pid: var string): bool = 128 | var output = execProcess("lsof -i | grep *:$1".format(port)).strip() 129 | var parts = output.split(" ").filter(proc(x: string): bool = x.len > 0) 130 | if parts.len > 1: 131 | pid = parts[1] 132 | return true 133 | 134 | proc killProcess(pid: string): void = 135 | discard execProcess("kill $1".format(pid)) 136 | 137 | proc updateXander(): void = 138 | echo execCmd("nimble install https://github.com/sunjohanday/xander") 139 | 140 | setControlCHook(proc() {.noconv.} = 141 | quit(0) 142 | ) 143 | 144 | let params = commandLineParams() 145 | 146 | if params.len == 0: 147 | echo """No command provided. Commands are 'new', 'run' and 'listen'.""" 148 | 149 | else: 150 | case params[0]: 151 | 152 | of "new": 153 | if params.len > 1: 154 | newProject(params[1]) 155 | else: 156 | newProject() 157 | 158 | of "run": 159 | if params.len > 1: 160 | runProject(params[1]) 161 | else: 162 | runProject() 163 | 164 | of "listen": 165 | 166 | var fileName {.threadvar.} : string 167 | var execName {.threadvar.} : string 168 | 169 | if params.len > 1: 170 | fileName = params[1] 171 | execName = fileName.splitFile.name 172 | 173 | else: 174 | fileName = "app.nim" 175 | execName = "app" 176 | 177 | var app: Thread[string] 178 | var appProc = proc(execName: string) {.thread, nimcall.} = 179 | let output = execProcess("./$1 &".format(execName)) 180 | if output.len > 0: 181 | echo "\n~~~OUTPUT~~~\n$1\n~~~/OUTPUT~~~\n".format(output) 182 | 183 | var (output, exitCode) = nimCompile(fileName) 184 | 185 | if exitCode == 1: 186 | echo output 187 | else: 188 | createThread(app, appProc, execName) 189 | 190 | proc getPort(): string = 191 | for line in fileName.lines: 192 | if "runForever" in line: 193 | result = line.replace("(", "").replace(")", "").replace(" ", "").replace("runForever", "") 194 | # Linux: lsof command recognizes port 8080 as http-alt 195 | result = if result == "8080": "http-alt" else: result 196 | 197 | var pid: string # Process ID 198 | var previous = readFile(fileName) # File content previously 199 | var next: TaintedString # File content now 200 | var port = getPort() # Application port 201 | 202 | echo "Listening $1 on port $2".format(fileName, port) 203 | 204 | # Infinite loop? 205 | while true: 206 | 207 | # Get file content 208 | next = readFile(fileName) 209 | 210 | # File content has changed 211 | if next != previous: 212 | echo "Code changed!" 213 | previous = next 214 | 215 | # If process is found, kill it and re-compile 216 | if getProcessId(port, pid): 217 | killProcess(pid) 218 | (output, exitCode) = nimCompile(fileName) 219 | if exitCode == 1: 220 | echo output 221 | else: 222 | port = getPort() 223 | createThread(app, appProc, execName) 224 | 225 | else: 226 | port = getPort() 227 | createThread(app, appProc, execName) 228 | 229 | sleep(200) 230 | 231 | of "update": 232 | updateXander() 233 | 234 | else: 235 | echo "Bad command! Commands are 'new', 'run' nd 'listen'." -------------------------------------------------------------------------------- /src/xander/constants.nim: -------------------------------------------------------------------------------- 1 | import 2 | regex 3 | 4 | const # Characters 5 | newLine*: string = "\n" 6 | tab*: string = " " 7 | 8 | const # Template tags 9 | contentTag*: string = "{[%content%]}" 10 | 11 | const # Request handler string 12 | requestHandlerString*: string = "proc(request: Request, data: var Data, headers: var HttpHeaders, cookies: var Cookies, session: var Session, files: var UploadFiles): Response {.gcsafe.} =" 13 | 14 | const # Time 15 | unixEpoch*: string = "Thu, 1 Jan 1970 12:00:00 UTC" -------------------------------------------------------------------------------- /src/xander/contenttype.nim: -------------------------------------------------------------------------------- 1 | import 2 | strutils 3 | 4 | func getContentType*(ext: string, charset = "UTF-8"): string = 5 | var ext = ext 6 | if not(startsWith(ext, '.')): 7 | ext = '.' & ext 8 | result = case ext: 9 | of ".aac": 10 | "audio/aac" 11 | of ".abw": 12 | "application/x-abiword" 13 | of ".arc": 14 | "application/x-freearc" 15 | of ".avi": 16 | "video/x-msvideo" 17 | of ".azw": 18 | "application/vnd.amazon.ebook" 19 | of ".bin": 20 | "application/octet-stream" 21 | of ".bmp": 22 | "image/bmp" 23 | of ".bz": 24 | "application/x-bzip" 25 | of ".bz2": 26 | "application/x-bzip2" 27 | of ".csh": 28 | "application/x-csh" 29 | of ".css": 30 | "text/css" 31 | of ".csv": 32 | "text/csv" 33 | of ".doc": 34 | "application/msword" 35 | of ".docx": 36 | "application/vnd.openxmlformats-officedocument.wordprocessingml.document" 37 | of ".eot": 38 | "application/vnd.ms-fontobject" 39 | of ".epub": 40 | "application/epub+zip" 41 | of ".gif": 42 | "image/gif" 43 | of ".html": 44 | "text/html" 45 | of ".htm": 46 | "text/html" 47 | of ".ico": 48 | "image/vnd.microsoft.icon" 49 | of ".ics": 50 | "text/calendar" 51 | of ".jar": 52 | "application/java-archive" 53 | of ".jpg": 54 | "image/jpeg" 55 | of ".jpeg": 56 | "image/jpeg" 57 | of ".js": 58 | "text/javascript" 59 | of ".json": 60 | "application/json" 61 | of ".jsonld": 62 | "application/ld+json" 63 | of ".midi": 64 | "audio/midi audio/x-midi" 65 | of ".mid": 66 | "audio/midi audio/x-midi" 67 | of ".mjs": 68 | "text/javascript" 69 | of ".mp3": 70 | "audio/mpeg" 71 | of ".mpeg": 72 | "video/mpeg" 73 | of ".mpkg": 74 | "application/vnd.apple.installer+xml" 75 | of ".odp": 76 | "application/vnd.oasis.opendocument.presentation" 77 | of ".ods": 78 | "application/vnd.oasis.opendocument.spreadsheet" 79 | of ".odt": 80 | "application/vnd.oasis.opendocument.text" 81 | of ".oga": 82 | "audio/ogg" 83 | of ".ogv": 84 | "video/ogg" 85 | of ".ogx": 86 | "application/ogg" 87 | of ".otf": 88 | "font/otf" 89 | of ".png": 90 | "image/png" 91 | of ".pdf": 92 | "application/pdf" 93 | of ".ppt": 94 | "application/vnd.ms-powerpoint" 95 | of ".pptx": 96 | "application/vnd.openxmlformats-officedocument.presentationml.presentation" 97 | of ".rar": 98 | "application/x-rar-compressed" 99 | of ".rtf": 100 | "application/rtf" 101 | of ".sh": 102 | "application/x-sh" 103 | of ".svg": 104 | "image/svg+xml" 105 | of ".swf": 106 | "application/x-shockwave-flash" 107 | of ".tar": 108 | "application/x-tar" 109 | of ".tiff": 110 | "image/tiff" 111 | of ".tif": 112 | "image/tiff" 113 | of ".ts": 114 | "video/mp2t" 115 | of ".ttf": 116 | "font/ttf" 117 | of ".txt": 118 | "text/plain" 119 | of ".vsd": 120 | "application/vnd.visio" 121 | of ".wav": 122 | "audio/wav" 123 | of ".weba": 124 | "audio/webm" 125 | of ".webm": 126 | "video/webm" 127 | of ".webp": 128 | "image/webp" 129 | of ".woff": 130 | "font/woff" 131 | of ".woff2": 132 | "font/woff2" 133 | of ".xhtml": 134 | "application/xhtml+xml" 135 | of ".xls": 136 | "application/vnd.ms-excel" 137 | of ".xlsx": 138 | "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" 139 | of ".xml": 140 | "application/xml" 141 | of ".xul": 142 | "application/vnd.mozilla.xul+xml" 143 | of ".zip": 144 | "application/zip" 145 | of ".3gp": 146 | "video/3gpp" 147 | of ".3g2": 148 | "video/3gpp2" 149 | of ".7z": 150 | "application/x-7z-compressed" 151 | else: 152 | "text/plain" 153 | return result & "; charset=" & charset -------------------------------------------------------------------------------- /src/xander/install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Colors 3 | GREEN='\033[0;32m' 4 | WHITE='\033[1;37m' 5 | NC='\033[0m' # No Color 6 | # Make .xander directory 7 | [ -d "~/.xander" ] && mkdir "~/.xander" 8 | # Get latest version of Xander 9 | x=($(ls ~/.nimble/pkgs | grep xander-)) 10 | y=~/.nimble/pkgs/${x[-1]}/xander.nim 11 | # Compiling with cpp for better compatiblity 12 | echo "Found Xander in $y" 13 | echo -e "${WHITE}Compiling ${x[-1]}${NC}" 14 | ~/.nimble/bin/nim cpp --verbosity:1 --hints:off --threads:on -o:~/.xander/xander $y 15 | echo -e "${GREEN}Finished!${NC} The Xander executable can be found in ~/.xander" 16 | echo "Add the following line to ~/.bashrc or ~/.profile:" 17 | echo "" 18 | echo " export PATH=/home/$USER/.xander:\$PATH" 19 | echo "" -------------------------------------------------------------------------------- /src/xander/tools.nim: -------------------------------------------------------------------------------- 1 | import 2 | strutils 3 | 4 | func quote*(s: string): string = 5 | "\"" & s & "\"" 6 | 7 | func unquote*(s: string): string = 8 | s.replace("\"", "") 9 | 10 | func unquote*(n: NimNode): string = 11 | repr(n).replace("\"", "") 12 | 13 | func toIntSeq*(s: string, sep = ','): seq[int] = 14 | result = newSeq[int]() 15 | for value in s.multiReplace(("[", ""), ("]", "")).split(sep): 16 | result.add(parseInt(value)) 17 | 18 | func indentation*(s: string): int = 19 | for c in s: 20 | if c == ' ': 21 | result += 1 22 | else: 23 | break 24 | -------------------------------------------------------------------------------- /src/xander/types.nim: -------------------------------------------------------------------------------- 1 | import 2 | asyncdispatch, 3 | asynchttpserver, 4 | json, 5 | regex, 6 | sequtils, 7 | strutils, 8 | tables, 9 | macros 10 | 11 | import 12 | constants, 13 | tools 14 | 15 | type 16 | Cookie* = tuple 17 | name: string 18 | value: string 19 | domain: string 20 | path: string 21 | expires: string 22 | secure: bool 23 | httpOnly: bool 24 | 25 | func newCookie*(name, value, domain, path, expires = "", secure, httpOnly = false): Cookie = 26 | return (name, value, domain, path, expires, secure, httpOnly) 27 | 28 | type 29 | Cookies* = tuple 30 | client: Table[string, Cookie] 31 | server: Table[string, Cookie] 32 | 33 | func newCookies*(): Cookies = 34 | var c: Cookies 35 | c.client = initTable[string, Cookie]() 36 | c.server = initTable[string, Cookie]() 37 | return c 38 | 39 | func get*(cookies: Cookies, cookieName: string): string = 40 | if cookies.client.hasKey(cookieName): 41 | return cookies.client[cookieName].value 42 | 43 | func set*(cookies: var Cookies, c: Cookie): void = 44 | let cookieName = c.name 45 | cookies.server[cookieName] = c 46 | 47 | func set*(cookies: var Cookies, name, value, domain, path, expires = "", secure, httpOnly = false): void = 48 | cookies.server[name] = newCookie(name, value, domain, path, expires, secure, httpOnly) 49 | 50 | func contains*(cookies: var Cookies, cookieName: string): bool = 51 | cookies.client.hasKey(cookieName) 52 | 53 | func contains*(cookies: Cookies, keys: varargs[string]): bool = 54 | result = true 55 | for key in keys: 56 | if not cookies.client.hasKey(key): 57 | return false 58 | 59 | func containsAndEquals*(cookies: var Cookies, cookieName, value: string): bool = 60 | cookies.contains(cookieName) and cookies.get(cookieName) == value 61 | 62 | func remove*(cookies: var Cookies, cookieName: string): void = 63 | cookies.server[cookieName] = newCookie(cookieName, expires = unixEpoch) 64 | 65 | # Cookies in the request header only contain the 'name' and 'value' -fields 66 | func setClient*(cookies: var Cookies, name, value: string = ""): void = 67 | cookies.client[name] = newCookie(name, value) 68 | 69 | type 70 | Data* = JsonNode 71 | 72 | func newData*(): Data = 73 | newJObject() 74 | 75 | func newData*[T](key: string, value: T): Data = 76 | result = newData() 77 | set(result, key, value) 78 | 79 | func get*(data: Data, key: string): string = 80 | let d = data.getOrDefault(key) 81 | if d != nil: 82 | return d.getStr() 83 | 84 | macro withData*(keys: varargs[string], body: untyped): void = 85 | var vars = repr(keys).strip(chars = {'[', ']'}).replace("\"", "").split(", ") 86 | var source: string = "if" 87 | # create if statement 88 | for i in 0 .. vars.len - 1: 89 | source &= " data.hasKey($1)".format(tools.quote(vars[i])) 90 | if i == vars.len - 1: source &= ":\n" 91 | else: source &= " and" 92 | # add var declaration(s) 93 | for v in vars: 94 | #TODO: source &= "if data[$1].kind == JArray:\n\t" 95 | source &= tab & "var $1 = data.get($2)\n".format(v.replace('-', '_'), tools.quote(v)) 96 | # add body 97 | for line in repr(body).splitLines: 98 | if line.len > 0: 99 | source &= tab & line & newLine 100 | # parse statement 101 | parseStmt(source) 102 | 103 | macro withHeaders*(keys: varargs[string], body: untyped): void = 104 | var vars = repr(keys).strip(chars = {'[', ']'}).replace("\"", "").split(", ") 105 | var source: string = "if" 106 | # create if statement 107 | for i in 0 .. vars.len - 1: 108 | source &= " request.headers.hasKey($1)".format(tools.quote(vars[i])) 109 | if i == vars.len - 1: source &= ":\n" 110 | else: source &= " and" 111 | # add var declaration(s) 112 | for v in vars: 113 | source &= tab & "var $1 = request.headers[$2]\n".format(v.replace('-', '_'), tools.quote(v)) 114 | # add body 115 | for line in repr(body).splitLines: 116 | if line.len > 0: 117 | source &= tab & line & newLine 118 | # parse statement 119 | parseStmt(source) 120 | 121 | func getBool*(data: Data, key: string): bool = 122 | let d = data.getOrDefault(key) 123 | if d != nil: 124 | return d.getBool() 125 | 126 | func getInt*(data: Data, key: string): int = 127 | let d = data.getOrDefault(key) 128 | if d != nil: 129 | return d.getInt() 130 | 131 | func set*[T](node: var Data, key: string, data: T) = 132 | add(node, key, %data) 133 | 134 | func add*[T](node: Data, key: string, value: T): Data = 135 | result = node 136 | add(node, key, %value) 137 | 138 | func hasKeys*(data: Data, keys: varargs[string]): bool = 139 | result = true 140 | for key in keys: 141 | if not data.hasKey(key): 142 | return false 143 | 144 | func `[]=`*[T](node: var Data, key: string, value: T) = 145 | add(node, key, %value) 146 | 147 | type 148 | Dictionary* = 149 | Table[string, string] 150 | 151 | func newDictionary*(): Dictionary = 152 | return initTable[string, string]() 153 | 154 | func set*(dict: var Dictionary, key, value: string) = 155 | dict[key] = value 156 | 157 | type 158 | Response* = tuple 159 | body: string 160 | httpCode: HttpCode 161 | headers: HttpHeaders 162 | 163 | type 164 | Session* = Data 165 | Sessions* = Table[string, Session] 166 | 167 | func newSession*(): Session = 168 | newData() 169 | 170 | func newSessions*(): Sessions = 171 | initTable[string, Session]() 172 | 173 | func remove*(session: var Session, key: string): void = 174 | if session.hasKey(key): 175 | delete(session, key) 176 | 177 | type 178 | UploadFile* = tuple 179 | name, ext, content: string 180 | size: int 181 | 182 | func newUploadFile*(name, ext, content: string, size: int = 0): UploadFile = 183 | (name, ext, content, size) 184 | 185 | type 186 | UploadFiles* = 187 | Table[string, seq[UploadFile]] 188 | 189 | func newUploadFiles*(): UploadFiles = 190 | return initTable[string, seq[UploadFile]]() 191 | 192 | type 193 | RequestHandlerVariables* = tuple 194 | data: Data 195 | headers: HttpHeaders 196 | cookies: Cookies 197 | session: Session 198 | files: UploadFiles 199 | 200 | func newRequestHandlerVariables*(): RequestHandlerVariables = 201 | (newData(), newHttpHeaders(), newCookies(), newSession(), newUploadFiles()) 202 | 203 | type 204 | RequestHandler* = 205 | proc(request: Request, data: var Data, headers: var HttpHeaders, cookies: var Cookies, session: var Session, files: var UploadFiles): Response {.gcsafe.} 206 | # TODO: Would below work? 207 | #proc(request: Request, X: var RequestHandlerVariables): Response {.gcsafe.} 208 | 209 | template newRequestHandler*(body: untyped): RequestHandler = 210 | body 211 | 212 | type 213 | ServerRoute* = tuple 214 | route: string 215 | handler: RequestHandler 216 | 217 | func newServerRoute*(route: string, handler: RequestHandler): ServerRoute = 218 | (route, handler) 219 | 220 | type 221 | Domain* = 222 | # HttpMethod => List of Server Routes 223 | Table[HttpMethod, seq[ServerRoute]] 224 | Domains* = 225 | Table[string, Domain] 226 | 227 | func newDomain*(): Domain = 228 | initTable[HttpMethod, seq[ServerRoute]]() 229 | 230 | func newDomains*(): Domains = 231 | initTable[string, Domain]() 232 | 233 | type 234 | Hosts* = 235 | # Host => Domains 236 | Table[string, Domains] 237 | 238 | const defaultHost* = "DEFAULT_HOST" 239 | const defaultDomain* = "DEFAULT_DOMAIN" 240 | const defaultMethod* = HttpGet 241 | 242 | func `$`*(server: Hosts): string = 243 | result = newLine & newLine & "~~~ SERVER STRUCTURE ~~~" & newLine 244 | for host in server.keys: 245 | result &= " HOST " & host & newLine 246 | for domain in server[host].keys: 247 | result &= " DOMAIN " & domain & newLine 248 | for httpMethod in server[host][domain].keys: 249 | result &= " METHOD " & $httpMethod & newLine 250 | for route in server[host][domain][httpMethod]: 251 | result &= " ROUTE " & route.route & newLine 252 | result &= "~~~ SERVER STRUCTURE ~~~" & newLine & newLine 253 | 254 | func newHosts*(): Hosts = 255 | initTable[string, Domains]() 256 | 257 | func existsHost*(server: Hosts, host: string): bool = 258 | server.hasKey(host) 259 | 260 | func existsDomain*(server: Hosts, domain: string, host: string): bool = 261 | server.existsHost(host) and server[host].hasKey(domain) 262 | 263 | func existsMethod*(server: Hosts, httpMethod: HttpMethod, host = defaultHost, domain = defaultDomain): bool = 264 | server.existsHost(host) and server.existsDomain(domain, host) and server[host][domain].hasKey(httpMethod) 265 | 266 | func addHost*(server: var Hosts, host = defaultHost): void = 267 | if not server.hasKey(host): 268 | server[host] = newDomains() 269 | 270 | func addDomain*(server: var Hosts, domain = defaultDomain, host = defaultHost): void = 271 | server[host][domain] = newDomain() 272 | 273 | func addMethod*(server: var Hosts, httpMethod = defaultMethod, host = defaultHost, domain = defaultDomain): void = 274 | if server.hasKey(host) and server[host].hasKey(domain): 275 | server[host][domain][httpMethod] = newSeq[ServerRoute]() 276 | 277 | func getRoutes*(server: Hosts, host = defaultHost, domain = defaultDomain, httpMethod = defaultMethod): seq[ServerRoute] = 278 | server[host][domain][httpMethod] 279 | 280 | func addRoute*(server: var Hosts, httpMethod: HttpMethod, route: string, handler: RequestHandler, host = defaultHost, domain = defaultDomain): void = 281 | if not server.existsHost(host): 282 | server.addHost(host) 283 | if not server.existsDomain(domain, host): 284 | server.addDomain(domain, host) 285 | if not server.existsMethod(httpMethod, host, domain): 286 | server.addMethod(httpMethod, host, domain) 287 | var routes = server.getRoutes(host, domain, httpMethod) 288 | routes.add newServerRoute(route, handler) 289 | server[host][domain][httpMethod] = routes 290 | 291 | func addRoute*(server: var Hosts, route: string, handler: RequestHandler, host = defaultHost, domain = defaultDomain, httpMethod: HttpMethod = defaultMethod): void = 292 | var routes = server.getRoutes(host, domain, httpMethod) 293 | routes.add newServerRoute(route, handler) 294 | server[host][domain][httpMethod] = routes 295 | 296 | proc isDefaultHost*(server: Hosts): bool = 297 | server.existsHost(defaultHost) #and server.len == 1 298 | 299 | type 300 | ErrorHandler* = 301 | proc(request: Request, httpCode: HttpCode): Response {.gcsafe.} -------------------------------------------------------------------------------- /src/xander/ws.nim: -------------------------------------------------------------------------------- 1 | # !! 2 | # All credit to treeforms's websocket library 3 | # https://github.com/treeform/ws 4 | # !! 5 | 6 | import httpcore, httpclient, asynchttpserver, asyncdispatch, nativesockets, 7 | asyncnet, strutils, streams, random, std/sha1, base64, uri, strformat, httpcore 8 | 9 | type 10 | ReadyState* = enum 11 | Connecting = 0 # The connection is not yet open. 12 | Open = 1 # The connection is open and ready to communicate. 13 | Closing = 2 # The connection is in the process of closing. 14 | Closed = 3 # The connection is closed or couldn't be opened. 15 | 16 | WebSocket* = ref object 17 | req*: Request 18 | version*: int 19 | key*: string 20 | protocol*: string 21 | readyState*: ReadyState 22 | 23 | WebSocketError* = object of Exception 24 | 25 | 26 | template `[]`(value: uint8, index: int): bool = 27 | ## Get bits from uint8, uint8[2] gets 2nd bit. 28 | (value and (1 shl (7 - index))) != 0 29 | 30 | 31 | proc nibbleFromChar(c: char): int = 32 | ## Converts hex chars like `0` to 0 and `F` to 15. 33 | case c: 34 | of '0'..'9': (ord(c) - ord('0')) 35 | of 'a'..'f': (ord(c) - ord('a') + 10) 36 | of 'A'..'F': (ord(c) - ord('A') + 10) 37 | else: 255 38 | 39 | 40 | proc nibbleToChar(value: int): char = 41 | ## Converts number like 0 to `0` and 15 to `fg`. 42 | case value: 43 | of 0..9: char(value + ord('0')) 44 | else: char(value + ord('a') - 10) 45 | 46 | 47 | proc decodeBase16*(str: string): string = 48 | ## Base16 decode a string. 49 | result = newString(str.len div 2) 50 | for i in 0 ..< result.len: 51 | result[i] = chr( 52 | (nibbleFromChar(str[2 * i]) shl 4) or 53 | nibbleFromChar(str[2 * i + 1])) 54 | 55 | 56 | proc encodeBase16*(str: string): string = 57 | ## Base61 encode a string. 58 | result = newString(str.len * 2) 59 | for i, c in str: 60 | result[i * 2] = nibbleToChar(ord(c) shr 4) 61 | result[i * 2 + 1] = nibbleToChar(ord(c) and 0x0f) 62 | 63 | 64 | proc genMaskKey*(): array[4, char] = 65 | ## Generates a random key of 4 random chars. 66 | proc r(): char = char(rand(256)) 67 | [r(), r(), r(), r()] 68 | 69 | 70 | proc newWebSocket*(req: Request): Future[WebSocket] {.async.} = 71 | ## Creates a new socket from a request. 72 | if not req.headers.hasKey("sec-websocket-version"): 73 | await req.respond(Http404, "Not Found") 74 | raise newException(WebSocketError, "Not a valid websocket handshake.") 75 | 76 | var ws = WebSocket() 77 | ws.req = req 78 | ws.version = parseInt(req.headers["sec-webSocket-version"]) 79 | ws.key = req.headers["sec-webSocket-key"].strip() 80 | if req.headers.hasKey("sec-webSocket-protocol"): 81 | ws.protocol = req.headers["sec-websocket-protocol"].strip() 82 | 83 | let sh = secureHash(ws.key & "258EAFA5-E914-47DA-95CA-C5AB0DC85B11") 84 | let acceptKey = base64.encode(decodeBase16($sh)) 85 | 86 | var responce = "HTTP/1.1 101 Web Socket Protocol Handshake\c\L" 87 | responce.add("Sec-WebSocket-Accept: " & acceptKey & "\c\L") 88 | responce.add("Connection: Upgrade\c\L") 89 | responce.add("Upgrade: webSocket\c\L") 90 | if not ws.protocol.len == 0: 91 | responce.add("Sec-WebSocket-Protocol: " & ws.protocol & "\c\L") 92 | responce.add "\c\L" 93 | 94 | await ws.req.client.send(responce) 95 | ws.readyState = Open 96 | return ws 97 | 98 | 99 | proc newWebSocket*(url: string, protocol: string = ""): Future[WebSocket] {.async.} = 100 | ## Creates a new WebSocket connection, protocol is optinal, "" means no protocol. 101 | var ws = WebSocket() 102 | ws.req = Request() 103 | ws.req.client = newAsyncSocket() 104 | ws.protocol = protocol 105 | 106 | var uri = parseUri(url) 107 | var port = Port(9001) 108 | case uri.scheme 109 | of "wss": 110 | uri.scheme = "https" 111 | port = Port(443) 112 | of "ws": 113 | uri.scheme = "http" 114 | port = Port(80) 115 | else: 116 | raise newException(WebSocketError, 117 | &"Scheme {uri.scheme} not supported yet.") 118 | if uri.port.len > 0: 119 | port = Port(parseInt(uri.port)) 120 | 121 | var client = newAsyncHttpClient() 122 | client.headers = newHttpHeaders({ 123 | "Connection": "Upgrade", 124 | "Upgrade": "websocket", 125 | "Sec-WebSocket-Version": "13", 126 | "Sec-WebSocket-Key": "JCSoP2Cyk0cHZkKAit5DjA==", 127 | "Sec-WebSocket-Extensions": "permessage-deflate; client_max_window_bits" 128 | }) 129 | if ws.protocol != "": 130 | client.headers["Sec-WebSocket-Protocol"] = ws.protocol 131 | var _ = await client.get(url) 132 | ws.req.client = client.getSocket() 133 | 134 | ws.readyState = Open 135 | return ws 136 | 137 | 138 | type 139 | Opcode* = enum 140 | ## 4 bits. Defines the interpretation of the "Payload data". 141 | Cont = 0x0 ## denotes a continuation frame 142 | Text = 0x1 ## denotes a text frame 143 | Binary = 0x2 ## denotes a binary frame 144 | # 3-7 are reserved for further non-control frames 145 | Close = 0x8 ## denotes a connection close 146 | Ping = 0x9 ## denotes a ping 147 | Pong = 0xa ## denotes a pong 148 | # B-F are reserved for further control frames 149 | 150 | #[ 151 | 0 1 2 3 152 | 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 153 | +-+-+-+-+-------+-+-------------+-------------------------------+ 154 | |F|R|R|R| opcode|M| Payload len | Extended payload length | 155 | |I|S|S|S| (4) |A| (7) | (16/64) | 156 | |N|V|V|V| |S| | (if payload len==126/127) | 157 | | |1|2|3| |K| | | 158 | +-+-+-+-+-------+-+-------------+ - - - - - - - - - - - - - - - + 159 | | Extended payload length continued, if payload len == 127 | 160 | + - - - - - - - - - - - - - - - +-------------------------------+ 161 | | |Masking-key, if MASK set to 1 | 162 | +-------------------------------+-------------------------------+ 163 | | Masking-key (continued) | Payload Data | 164 | +-------------------------------- - - - - - - - - - - - - - - - + 165 | : Payload Data continued ... : 166 | + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + 167 | | Payload Data continued ... | 168 | +---------------------------------------------------------------+ 169 | ]# 170 | Frame* = tuple 171 | fin: bool ## Indicates that this is the final fragment in a message. 172 | rsv1: bool ## MUST be 0 unless negotiated that defines meanings 173 | rsv2: bool 174 | rsv3: bool 175 | opcode: Opcode ## Defines the interpretation of the "Payload data". 176 | mask: bool ## Defines whether the "Payload data" is masked. 177 | data: string ## Payload data 178 | 179 | 180 | proc encodeFrame*(f: Frame): string = 181 | ## Encodes a frame into a string buffer. 182 | ## See https://tools.ietf.org/html/rfc6455#section-5.2 183 | 184 | var ret = newStringStream() 185 | 186 | var b0 = (f.opcode.uint8 and 0x0f) # 0th byte: opcodes and flags 187 | if f.fin: 188 | b0 = b0 or 128u8 189 | 190 | ret.write(b0) 191 | 192 | # Payload length can be 7 bits, 7+16 bits, or 7+64 bits. 193 | # 1st byte: playload len start and mask bit. 194 | var b1 = 0u8 195 | 196 | if f.data.len <= 125: 197 | b1 = f.data.len.uint8 198 | elif f.data.len > 125 and f.data.len <= 0xffff: 199 | b1 = 126u8 200 | else: 201 | b1 = 127u8 202 | 203 | if f.mask: 204 | b1 = b1 or (1 shl 7) 205 | 206 | ret.write(uint8 b1) 207 | 208 | # Only need more bytes if data len is 7+16 bits, or 7+64 bits. 209 | if f.data.len > 125 and f.data.len <= 0xffff: 210 | # Data len is 7+16 bits. 211 | ret.write(htons(f.data.len.uint16)) 212 | elif f.data.len > 0xffff: 213 | # Data len is 7+64 bits. 214 | var len = f.data.len 215 | ret.write char((len shr 56) and 255) 216 | ret.write char((len shr 48) and 255) 217 | ret.write char((len shr 40) and 255) 218 | ret.write char((len shr 32) and 255) 219 | ret.write char((len shr 24) and 255) 220 | ret.write char((len shr 16) and 255) 221 | ret.write char((len shr 8) and 255) 222 | ret.write char(len and 255) 223 | 224 | var data = f.data 225 | 226 | if f.mask: 227 | # If we need to maks it generate random mask key and mask the data. 228 | let maskKey = genMaskKey() 229 | for i in 0..