import prologue import prologue/middlewares/staticfile import karax/[karaxdsl, vdom] import tiny_sqlite import strutils import logging import times import ./util from ./md import nil from ./domain import nil import ./sqlitesession let env = loadPrologueEnv(".env") let settings = newSettings( appName = "xenotime", debug = env.getOrDefault("debug", true), port = Port(env.getOrDefault("port", 5070)), secretKey = env.get("secretKey") ) const dbPath = "./xenotime.sqlite3" # TODO: work out gcsafety issues in making this runtime-configurable proc openDBConnection(): DbConn = logger().log(lvlInfo, "Opening database connection") let conn = openDatabase(dbPath) conn.exec("PRAGMA foreign_keys = ON") conn.exec("PRAGMA journal_mode = WAL") return conn autoInitializedThreadvar(db, DbConn, openDBConnection()) block: let db = openDatabase(dbPath) domain.migrate(db) close(db()) type AppContext* = ref object of Context db*: DbConn user*: Option[domain.User] proc dbMiddleware(): HandlerAsync = result = proc(ctx: AppContext) {.async.} = ctx.db = db() await switch(ctx) proc generalMiddleware(): HandlerAsync = result = proc(ctx: AppContext) {.async.} = let userID = ctx.session.getOrDefault("uid") if userID != "": ctx.user = domain.getUser(ctx.db, parseBiggestInt userID) await switch(ctx) ctx.response.setHeader("X-Content-Type-Options", "nosniff") ctx.response.setHeader("Content-Security-Policy", "default-src 'self'; img-src * data:; media-src * data:; form-action 'self'; frame-ancestors 'self'") ctx.response.setHeader("Referrer-Policy", "origin-when-cross-origin") func navButton(content: string, href: string, class: string): VNode = buildHtml(a(class="link-button " & class, href=href)): text content func base(ctx: AppContext, title: string, navItems: seq[VNode], bodyItems: VNode, sidebar: Option[VNode] = none(VNode)): string = let sidebarClass = if sidebar.isSome: "has-sidebar" else: "" let vnode = buildHtml(html): head: link(rel="stylesheet", href="/static/style.css") meta(charset="utf-8") meta(name="viewport", content="width=device-width,initial-scale=1.0") title: text title body: main(class=sidebarClass): for (category, msg) in ctx.getFlashedMsgsWithCategory(): tdiv(class=("flashmsg " & category)): text msg nav: navButton("Index", ctx.urlFor("index"), "index") navButton("New Thread", ctx.urlFor("new-thread"), "new-thread") if ctx.user.isNone: navButton("Login", ctx.urlFor("login"), "login") else: tdiv(class="logged-in-user"): text ctx.user.get.name for n in navItems: n tdiv(class="header"): h1: text title bodyItems if sidebar.isSome: tdiv(class="sidebar"): get sidebar "" & $vnode proc displayTime(t: Time): string = t.format("uuuu-MM-dd HH:mm:ss", utc()) proc index(ctx: AppContext) {.async.} = let threads = domain.allThreads(ctx.db) let html = buildHtml(tdiv): table(class="threads"): tr: th: text "Title" th: text "Creator" th: text "Posts" th: text "Created" th: text "Updated" for thread in threads: tr(class="thread"): td(class="title"): a(href=ctx.urlFor("thread", {"id": $thread.id})): thread.title.text td: thread.creatorName.text td: thread.replies.`$`.text td: thread.created.displayTime.text td: thread.updated.displayTime.text resp base(ctx, "Forum", @[ ], html) proc viewThread(ctx: AppContext) {.async.} = let threadID = parseBiggestInt ctx.getPathParams("id") let thread = domain.getThread(ctx.db, threadID).get let posts = domain.getPosts(ctx.db, threadID) let html = buildHtml(tdiv): tdiv(class="posts"): for post in posts: tdiv(class="post", id=("post" & $post.index)): tdiv(class="heading"): tdiv(class="username"): post.authorName.text tdiv: span(class="timestamp"): post.time.displayTime().text text " " a(href=("#post" & $post.index)): text ("#" & $post.index) tdiv(class="md"): md.renderToHtml(post.content).verbatim form(`method`="POST", action=ctx.urlFor("submit"), class="new-post"): input(`type`="hidden", value= $threadID, name="thread-id") textarea(name="content") input(`type`="submit", value="Post", class="submit-post") resp base(ctx, thread.title, @[ ], html) proc newThreadForm(ctx: AppContext) {.async.} = let html = buildHtml(tdiv): form(`method`="POST", action=ctx.urlFor("submit")): input(name="thread-title", `type`="text", placeholder="Title") textarea(name="content") input(`type`="submit", value="Post", class="submit-post") resp base(ctx, "New Thread", @[ ], html) proc submitPost(ctx: AppContext) {.async.} = let title = ctx.getFormParams("thread-title") if ctx.getFormParams("content") == "" or (title == "" and ctx.getFormParams("thread-id") == ""): ctx.flash("Please actually have content.", FlashLevel.Warning) resp redirect(ctx.request.headers["referer"], Http303) return if ctx.user.isSome: ctx.db.transaction: let threadID = if title != "": domain.postThread(ctx.db, title, ctx.user.get.id) else: parseBiggestInt ctx.getFormParams("thread-id") let content = ctx.getFormParams("content") let newIndex = domain.postPost(ctx.db, content, threadID, ctx.user.get.id) resp redirect(ctx.urlFor("thread", {"id": $threadID}) & "#post" & $newIndex, Http303) else: ctx.flash("Not logged in", FlashLevel.Warning) resp redirect("/", Http303) proc loginPage(ctx: AppContext) {.async.} = let html = buildHtml(tdiv): form(`method`="post", class="login"): input(`type`="text", placeholder="Username", name="username") input(`type`="password", placeholder="Password", name="password") input(`type`="submit", class="login", value="Login", name="action") details: summary: text "Create new account" input(`type`="password", placeholder="Confirm Password", name="confirm-password") input(`type`="submit", class="create-account", value="Create Account", name="action") resp base(ctx, "Login", @[ ], html) proc doLogin(ctx: AppContext) {.async.} = let username = ctx.getFormParams("username") let password = ctx.getFormParams("password") if username == "" or password == "": ctx.flash("Password/username missing", FlashLevel.Error) resp redirect(ctx.urlFor("login"), Http303) return let user = domain.getUser(ctx.db, username) if ctx.getFormParams("action") == "Create Account": if user.isSome: ctx.flash("User already exists", FlashLevel.Error) resp redirect(ctx.urlFor("login"), Http303) else: if password == ctx.getFormParams("confirm-password"): let id = domain.addUser(ctx.db, username, password) ctx.session["uid"] = $id ctx.flash("Account created") resp redirect(ctx.getQueryParams("redirect", "/"), Http303) else: ctx.flash("Passwords do not match", FlashLevel.Error) resp redirect(ctx.urlFor("login"), Http303) else: if user.isSome: if passwordVerify(password, user.get.pwhash): ctx.session["uid"] = $user.get.id ctx.flash("Logged in") resp redirect(ctx.getQueryParams("redirect", "/"), Http303) else: ctx.flash("Password does not match", FlashLevel.Error) resp redirect(ctx.urlFor("login"), Http303) else: ctx.flash("No such user", FlashLevel.Error) resp redirect(ctx.urlFor("login"), Http303) proc error500(ctx: AppContext) {.async.} = logger().log(lvlError, getCurrentExceptionMsg()) resp getCurrentExceptionMsg(), Http500 var app = newApp(settings = settings) app.use(@[staticFileMiddleware("static"), extendContextMiddleware(AppContext), dbMiddleware(), sessionMiddleware(settings, db), generalMiddleware()]) app.get("/", index, name="index") app.get("/login", loginPage, name="login") app.post("/login", doLogin, name="do-login") app.get("/thread", newThreadForm, name="new-thread") app.get("/thread/{id}", viewThread, name="thread") app.post("/", submitPost, name="submit") app.registerErrorHandler(Http500, error500) app.run()