xenotime/src/xenotime.nim

212 lines
9.0 KiB
Nim

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
"<!DOCTYPE html>" & $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()