commit 5ad0e0927c418d56f04e1f060af45e66d7222bc8 Author: osmarks Date: Sun Apr 25 13:35:51 2021 +0100 stuff diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f02b305 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.env +xenotime +*.sqlite* \ No newline at end of file diff --git a/nim.cfg b/nim.cfg new file mode 100644 index 0000000..9d57ecf --- /dev/null +++ b/nim.cfg @@ -0,0 +1 @@ +--threads:on \ No newline at end of file diff --git a/src/domain.nim b/src/domain.nim new file mode 100644 index 0000000..8b848a7 --- /dev/null +++ b/src/domain.nim @@ -0,0 +1,121 @@ +import tiny_sqlite +import logging +import options +import times +import sequtils + +import ./util + +let migrations = @[ + """ +CREATE TABLE users ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + pwhash BLOB NOT NULL, + created INTEGER NOT NULL +); +CREATE TABLE threads ( + id INTEGER PRIMARY KEY, + title TEXT NOT NULL, + created INTEGER NOT NULL, + updated INTEGER NOT NULL, + creator INTEGER NOT NULL REFERENCES users(id) +); +CREATE TABLE posts ( + id INTEGER PRIMARY KEY, + time INTEGER NOT NULL, + content TEXT NOT NULL, + thread INTEGER NOT NULL REFERENCES threads(id), + author INTEGER NOT NULL REFERENCES users(id), + idx INTEGER NOT NULL +); +CREATE TABLE sessions ( + id INTEGER PRIMARY KEY, + timestamp INTEGER NOT NULL, + data TEXT NOT NULL +); + +CREATE INDEX posts_thread_idx ON posts (thread); + """ +] + +type + User* = object + id*: int64 + name*: string + pwhash*: string + created*: Time + Thread* = object + id*: int64 + title*: string + created*: Time + updated*: Time + creator*: int64 + creatorName*: string + replies*: int + Post* = object + id*: int64 + thread*: int64 + time*: Time + content*: string + author*: int64 + authorName*: string + index*: int + +proc migrate*(db: DbConn) = + let currentVersion = fromDbValue(get db.value("PRAGMA user_version"), int) + for mid in (currentVersion + 1) .. migrations.len: + db.transaction: + logger().log(lvlInfo, "Migrating to schema " & $mid) + db.execScript migrations[mid - 1] + # for some reason this pragma does not work using normal parameter binding + db.exec("PRAGMA user_version = " & $mid) + logger().log(lvlDebug, "DB ready") + +proc processPost(r: ResultRow): Post = + let (id, ts, content, thread, author, authorName, index) = r.unpack((int64, Time, string, int64, int64, string, int)) + Post(id: id, time: ts, content: content, thread: thread, author: author, authorName: authorName, index: index) + +proc processThread(r: ResultRow): Thread = + let (id, title, created, updated, creator, creatorName, replies) = r.unpack((int64, string, Time, Time, int64, string, int)) + Thread(id: id, created: created, title: title, updated: updated, creator: creator, creatorName: creatorName, replies: replies) + +proc processUser(r: ResultRow): User = + let (id, name, pwhash, created) = r.unpack((int64, string, string, Time)) + User(id: id, name: name, pwhash: pwhash, created: created) + +proc getPosts*(db: DbConn, threadID: int64): seq[Post] = + db.all("SELECT posts.id, posts.time, posts.content, posts.thread, posts.author, users.name, posts.idx FROM posts JOIN users ON users.id = posts.author WHERE thread = ? ORDER BY time ASC", threadID).map(processPost) + +proc allThreads*(db: DbConn): seq[Thread] = + db.all("""SELECT threads.id, threads.title, threads.created, threads.updated, threads.creator, users.name, (SELECT COUNT(*) FROM posts WHERE thread = threads.id) + FROM threads JOIN users ON users.id = threads.creator ORDER BY updated DESC""").map(processThread) + +proc getThread*(db: DbConn, id: int64): Option[Thread] = + db.one("""SELECT threads.id, threads.title, threads.created, threads.updated, threads.creator, users.name, (SELECT COUNT(*) FROM posts WHERE thread = threads.id) + FROM threads JOIN users ON users.id = threads.creator WHERE threads.id = ?""", id).map(processThread) + +proc postPost*(db: DbConn, post: string, threadID: int64, userID: int64): int = + var index: int + db.transaction: + db.exec("UPDATE threads SET updated = ? WHERE id = ?", getTime(), threadID) + index = db.value("SELECT COUNT(*) FROM posts WHERE posts.thread = ?", threadID).get.fromDbValue(int) + db.exec("INSERT INTO posts VALUES (?, ?, ?, ?, ?, ?)", randomID(), getTime(), post, threadID, userID, index) + index + +proc postThread*(db: DbConn, title: string, userID: int64): int64 = + let id = randomID() + db.exec("INSERT INTO threads VALUES (?, ?, ?, ?, ?)", id, title, getTime(), getTime(), userID) + id + +proc getUser*(db: DbConn, id: int64): Option[User] = + db.one("SELECT * FROM users WHERE id = ?", id).map(processUser) + +proc getUser*(db: DbConn, name: string): Option[User] = + db.one("SELECT * FROM users WHERE name = ?", name).map(processUser) + +proc addUser*(db: DbConn, name: string, password: string): int64 = + let id = randomID() + let pwhash = passwordHash(password) + db.exec("INSERT INTO users VALUES (?, ?, ?, ?)", id, name, pwhash, getTime()) + id \ No newline at end of file diff --git a/src/md.nim b/src/md.nim new file mode 100644 index 0000000..945c21b --- /dev/null +++ b/src/md.nim @@ -0,0 +1,87 @@ +import cmark/native as cmark except Node, Parser +# the builtin re library would probably be better for this - it can directly take cstrings (so better perf when dealing with the cstrings from cmark) and may be faster +# unfortunately it does not expose a findAll thing which returns the *positions* of everything for some weird reason + +cmark_gfm_core_extensions_ensure_registered() + +type + Node = object + raw: NodePtr + BorrowedNode = object + raw: NodePtr + Parser = object + raw: ParserPtr + +proc `=copy`(dest: var Node, source: Node) {.error.} +proc `=destroy`(x: var Node) = cmark_node_free(x.raw) +proc `=destroy`(x: var BorrowedNode) = discard + +proc `=destroy`(x: var Parser) = cmark_parser_free(x.raw) + +proc borrow(n: Node): BorrowedNode = BorrowedNode(raw: n.raw) + +proc newParser(options: int64, extensions: seq[string]): Parser = + let parser: ParserPtr = cmark_parser_new(options.cint) + if parser == nil: raise newException(CatchableError, "failed to initialize parser") + # load and enable desired syntax extensions + # these are freed with the parser (probably) + for ext in extensions: + let e: cstring = ext + let eptr = cmark_find_syntax_extension(e) + if eptr == nil: + cmark_parser_free(parser) + raise newException(LibraryError, "failed to find extension " & ext) + if cmark_parser_attach_syntax_extension(parser, eptr) == 0: + cmark_parser_free(parser) + raise newException(CatchableError, "failed to attach extension " & ext) + Parser(raw: parser) + +proc parse(p: Parser, document: string): Node = + let + str: cstring = document + length = len(document).csize_t + cmark_parser_feed(p.raw, str, length) + let ast = cmark_parser_finish(p.raw) + if ast == nil: raise newException(CatchableError, "parsing failed - should not occur") + Node(raw: ast) + +proc nodeType(n: BorrowedNode): NodeType = cmark_node_get_type(n.raw) +proc nodeContent(n: BorrowedNode): string = $cmark_node_get_literal(n.raw) + +proc newNode(ty: NodeType, content: string): Node = + let raw = cmark_node_new(ty) + if raw == nil: raise newException(CatchableError, "node creation failed") + if cmark_node_set_literal(raw, content) != 1: + cmark_node_free(raw) + raise newException(CatchableError, "node content setting failed") + Node(raw: raw) + +proc parentNode(parentOf: BorrowedNode): BorrowedNode = BorrowedNode(raw: cmark_node_parent(parentOf.raw)) +proc pushNodeAfter(after: BorrowedNode, node: sink Node) {.nodestroy.} = assert cmark_node_insert_before(after.raw, node.raw) == 1 +proc unlinkNode(node: sink BorrowedNode): Node {.nodestroy.} = + cmark_node_unlink(node.raw) + Node(raw: node.raw) + +proc render(ast: Node, options: int64, parser: Parser): string = + let html: cstring = cmark_render_html(ast.raw, options.cint, cmark_parser_get_syntax_extensions(parser.raw)) + defer: free(html) + result = $html + +iterator cmarkTree(root: BorrowedNode): (EventType, BorrowedNode) {.inline.} = + var iter = cmark_iter_new(root.raw) + if iter == nil: raise newException(CatchableError, "iterator initialization failed") + defer: cmark_iter_free(iter) + while true: + let ev = cmark_iter_next(iter) + if ev == etDone: break + let node: NodePtr = cmark_iter_get_node(iter) + yield (ev, BorrowedNode(raw: node)) + +proc renderToHtml*(input: string): string = + let opt = CMARK_OPT_STRIKETHROUGH_DOUBLE_TILDE or CMARK_OPT_TABLE_PREFER_STYLE_ATTRIBUTES + + # initialize parser with the extensions in use, parse things + let parser = newParser(opt, @["table", "strikethrough"]) + let doc = parse(parser, input) + + render(doc, opt, parser) \ No newline at end of file diff --git a/src/sqlitesession.nim b/src/sqlitesession.nim new file mode 100644 index 0000000..0eea257 --- /dev/null +++ b/src/sqlitesession.nim @@ -0,0 +1,87 @@ +# based on https://github.com/planety/prologue/blob/devel/src/prologue/middlewares/sessions/redissession.nim +# SQLite session storage adapter + +import std/[options, strtabs, asyncdispatch] + +from prologue/core/types import len, Session, newSession +from prologue/core/context import Context, HandlerAsync, getCookie, setCookie, deleteCookie +from prologue/core/response import addHeader +from prologue/core/middlewaresbase import switch +from prologue/core/nativesettings import Settings +from cookiejar import SameSite +from strutils import parseBiggestInt +import tiny_sqlite +import times +import json +import std/jsonutils +from sequtils import toSeq, map +import strtabs +from sugar import `=>` + +import prologue/core/request +import prologue/core/types + +import ./util + +proc sessionMiddleware*( + settings: Settings, + getDB: (proc(): DbConn), + sessionName = "session", + maxAge: int = 14 * 24 * 60 * 60, # 14 days, in seconds + domain = "", + sameSite = Lax, + httpOnly = false +): HandlerAsync = + return proc(ctx: Context) {.async.} = + # TODO: this may be invoking dark gods slightly (fix?) + # the proc only accesses the DB threadvar, but it itself is GC memory + {.gcsafe.}: + let db = getDB() + var sessionIDString = ctx.getCookie(sessionName) + + # no session ID results in sessionIDString being empty, which will also result in a parse failure + # this will cause a new session ID to be generated + var sessionID: int64 = -1 + try: + sessionID = int64(parseBiggestInt sessionIDString) + except ValueError: discard + + var sessionTS = getTime() + var newlyCreated = false + + if sessionID != -1: + # fetch session from database + let info = db.one("SELECT * FROM sessions WHERE id = ?", sessionID) + if info.isSome: + let (sid, ts, data) = info.get().unpack((int64, Time, string)) + sessionTS = ts + ctx.session = newSession(data = newStringTable(modeCaseSensitive)) + for (key, value) in parseJson(data).jsonTo(seq[(string, string)]): + ctx.session[key] = value + else: + ctx.session = newSession(data = newStringTable(modeCaseSensitive), newCreated = true) + else: + ctx.session = newSession(data = newStringTable(modeCaseSensitive), newCreated = true) + sessionID = randomID() + + await switch(ctx) + + if ctx.session.len == 0: # empty or modified (del or clear) + if ctx.session.modified: # modified + db.exec("DELETE FROM sessions WHERE id = ?", sessionID) + ctx.deleteCookie(sessionName, domain = domain) # delete session data in cookie + return + elif ctx.session.modified: + ctx.setCookie(sessionName, $sessionID, maxAge = some(maxAge), domain = domain, sameSite = sameSite, httpOnly = httpOnly) + + if ctx.session.accessed: + ctx.response.addHeader("Vary", "Cookie") + + if ctx.session.modified: + let serializedSessionData = $(toSeq(ctx.session.data.pairs).map(x => (x.key, x.value)).toJson()) + db.exec("INSERT OR REPLACE INTO sessions VALUES (?, ?, ?)", sessionID, sessionTS, serializedSessionData) + # garbage collect old sessions + # TODO: consider checking elsewhere, as not doing so leads to a bit of an exploit where + # old session IDs can be used for a while + let oldSessionThreshold = getTime() + initDuration(seconds = -maxAge) + db.exec("DELETE FROM sessions WHERE timestamp < ?", oldSessionThreshold) \ No newline at end of file diff --git a/src/style.sass b/src/style.sass new file mode 100755 index 0000000..9c32fc1 --- /dev/null +++ b/src/style.sass @@ -0,0 +1,158 @@ +html + scrollbar-color: black lightgray + +* + box-sizing: border-box + +body + font-family: "Fira Sans","Noto Sans",Verdana,sans-serif + font-weight: 300 + margin: 0 0 0 0 + display: flex + flex-wrap: wrap + justify-content: space-around + +main + max-width: 60em + width: 100% + padding: 0 1em 0 1em + margin-left: auto + margin-right: auto + &.has-sidebar + margin-right: 0 + +.sidebar + padding: 1em 1em 1em 1em + margin-right: auto + min-width: 16em + +strong + font-weight: 600 + +h1, h2, h3, h4, h5, h6 + &:first-of-type + border-bottom: 1px solid gray + margin: 0 0 0.2em 0 + font-weight: 500 + a + color: inherit + +:not(pre) > code + background: black + color: white + padding: 0.3em + +ul, ol + padding-left: 1em +ul + list-style-type: square +blockquote + border-left: 0.3em solid black + padding-left: 0.3em +table + border-collapse: collapse + + th + background: black + color: white + font-weight: normal + td, th + padding: 0.2em 0.5em + td + border: 1px solid gray + tr:nth-child(2n+1) + background: lightgray + +.md + margin-top: 0.5em + > *, p + margin: 0 0 0.5em 0 + +nav + margin-bottom: 0.5em + display: flex + .logged-in-user + margin-left: auto + align-self: center + +img + max-width: 100% + +.timestamp + color: gray + +.selected + font-style: italic + +.link-button, button, input[type=submit] + border: none + border-radius: 0 + padding: 0.75em + background: gray + text-align: center + text-decoration: none + color: black + display: inline-block + font-size: 1rem + &:hover + // very hacky way to make the button darker when hovered + background-image: linear-gradient(rgba(0,0,0,.2), rgba(0,0,0,.2)) + + &.new-thread + background-color: #75bbfd + &.submit-post + background-color: #703be7 + &.index + background-color: #fd3c06 + &.login + background-color: #76cd26 + &.create-account + background-color: #dbb40c + +input[type=search], input[type=text], input[type=password], .post + border: 1px solid gray + padding: 0.75em + width: 100% + border-radius: 0 + margin-bottom: -1px + +textarea + resize: vertical + width: 100% + height: 30em + border: 1px solid gray + margin-top: -1px +.new-post textarea + height: 15em + +.flashmsg + padding: 0.5em + margin: 0.5em 0 0.5em 0 + border: 1px solid black + &.info + background: skyblue + &.error + background: salmon + &.warning + background: orange + +.post + .heading + display: flex + justify-content: space-between + +.threads + width: 100% + .title + white-space: nowrap + /*display: grid + //thead, tbody, tr +// display: contents + //grid-template-columns: minmax(15em, 8fr) 2fr 1fr 3fr 3fr + //grid-template-columns: repeat(auto-fit, 20em 5em 1em 3em 5em) + +@media (orientation: portrait) + .threads td, .threads th + //display: block + .threads .title + white-space: inherit \ No newline at end of file diff --git a/src/util.nim b/src/util.nim new file mode 100644 index 0000000..93bd7f0 --- /dev/null +++ b/src/util.nim @@ -0,0 +1,73 @@ +import times +import strutils +import tiny_sqlite +import times +import options +import sequtils +import json +from os import `/`, existsOrCreateDir +import md5 +import prologue +import logging +import nimcrypto/sysrand +import argon2_bind + +func timeToTimestamp*(t: Time): int64 = toUnix(t) * 1000 + (nanosecond(t) div 1000000) +func timestampToTime*(ts: int64): Time = initTime(ts div 1000, (ts mod 1000) * 1000000) +func timestampToStr*(t: Time): string = intToStr(int(timeToTimestamp(t))) +proc toJsonHook(t: Time): JsonNode = newJInt(timeToTimestamp(t)) + +# store time as milliseconds +proc toDbValue*(t: Time): DbValue = DbValue(kind: sqliteInteger, intVal: timeToTimestamp(t)) +proc fromDbValue*(value: DbValue, T: typedesc[Time]): Time = timestampToTime(value.intVal) + +template autoInitializedThreadvar*(name: untyped, typ: typedesc, initialize: typed): untyped = + var data* {.threadvar.}: Option[typ] + proc `name`*(): typ = + if isSome(data): result = get data + else: + result = initialize + data = some result + +proc randomID*(): int64 = + var arr: array[8, byte] + doAssert randomBytes(arr) == 8, "RNG failed" + cast[int64](arr).abs # TODO: do not invoke horrible unsafety? + +# remove any unsafe characters from a filename, by only allowing bytes [a-zA-Z._ -] +proc normalizeFilename(s: string): string = + for byte in s: + if byte in {' ', 'a'..'z', 'A'..'Z', '-', '_', '.'}: + result.add(byte) + else: + result.add('_') + +proc makeFilePath*(basepath, page, filename: string): string = + # putting tons of things into one directory may cause issues, so "shard" it into 256 subdirs deterministically + let pageHash = getMD5(page) + let hashdir = pageHash[0..1] + # it is possible that for some horrible reason someone could make two files/pages which normalize to the same thing + # but are nevertheless different files + # thus, put the hash of the ORIGINAL file/pagename before the normalized version + let pagedir = pageHash[2..31] & "-" & normalizeFilename(page) + let filenameHash = getMD5(filename) + discard existsOrCreateDir(basepath / hashdir) + discard existsOrCreateDir(basepath / hashdir / pagedir) + # saved file path should not include the basedir for file storage, as this may be moved around/reconfigured + hashdir / pagedir / (filenameHash & "-" & normalizeFilename(filename)) + +autoInitializedThreadvar(logger, ConsoleLogger, newConsoleLogger()) + +const argon2Params = setupArgon2Params(algoType = argon2_bind.Argon2id) + +proc passwordHash*(pass: string): string = + var salt = repeat[byte](0, 16) + doAssert randomBytes(salt) == 16, "RNG failed" + argon2_bind.getOutput(pass, salt, argon2Params).encoded + +proc passwordVerify*(pass: string, hash: string): bool = + try: + return argon2_bind.isVerified(hash, pass) + except Argon2Error: + logger().log(lvlWarn, "argon2 error", getCurrentExceptionMsg()) + return false \ No newline at end of file diff --git a/src/xenotime.nim b/src/xenotime.nim new file mode 100644 index 0000000..1e156db --- /dev/null +++ b/src/xenotime.nim @@ -0,0 +1,208 @@ +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.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() \ No newline at end of file diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..1e5bb8e --- /dev/null +++ b/static/style.css @@ -0,0 +1 @@ +html{scrollbar-color:#000 #d3d3d3}*{box-sizing:border-box}body{font-family:"Fira Sans","Noto Sans",Verdana,sans-serif;font-weight:300;margin:0 0 0 0;display:flex;flex-wrap:wrap;justify-content:space-around}main{max-width:60em;width:100%;padding:0 1em 0 1em;margin-left:auto;margin-right:auto}main.has-sidebar{margin-right:0}.sidebar{padding:1em 1em 1em 1em;margin-right:auto;min-width:16em}strong{font-weight:600}h1,h2,h3,h4,h5,h6{margin:0 0 .2em 0;font-weight:500}h1:first-of-type,h2:first-of-type,h3:first-of-type,h4:first-of-type,h5:first-of-type,h6:first-of-type{border-bottom:1px solid gray}h1 a,h2 a,h3 a,h4 a,h5 a,h6 a{color:inherit}:not(pre)>code{background:#000;color:#fff;padding:.3em}ul,ol{padding-left:1em}ul{list-style-type:square}blockquote{border-left:.3em solid #000;padding-left:.3em}table{border-collapse:collapse}table th{background:#000;color:#fff;font-weight:normal}table td,table th{padding:.2em .5em}table td{border:1px solid gray}table tr:nth-child(2n+1){background:#d3d3d3}.md{margin-top:.5em}.md>*,.md p{margin:0 0 .5em 0}nav{margin-bottom:.5em;display:flex}nav .logged-in-user{margin-left:auto;align-self:center}img{max-width:100%}.timestamp{color:gray}.selected{font-style:italic}.link-button,button,input[type=submit]{border:none;border-radius:0;padding:.75em;background:gray;text-align:center;text-decoration:none;color:#000;display:inline-block;font-size:1rem}.link-button:hover,button:hover,input[type=submit]:hover{background-image:linear-gradient(rgba(0, 0, 0, 0.2), rgba(0, 0, 0, 0.2))}.link-button.new-thread,button.new-thread,input[type=submit].new-thread{background-color:#75bbfd}.link-button.submit-post,button.submit-post,input[type=submit].submit-post{background-color:#703be7}.link-button.index,button.index,input[type=submit].index{background-color:#fd3c06}.link-button.login,button.login,input[type=submit].login{background-color:#76cd26}.link-button.create-account,button.create-account,input[type=submit].create-account{background-color:#dbb40c}input[type=search],input[type=text],input[type=password],.post{border:1px solid gray;padding:.75em;width:100%;border-radius:0;margin-bottom:-1px}textarea{resize:vertical;width:100%;height:30em;border:1px solid gray;margin-top:-1px}.new-post textarea{height:15em}.flashmsg{padding:.5em;margin:.5em 0 .5em 0;border:1px solid #000}.flashmsg.info{background:skyblue}.flashmsg.error{background:salmon}.flashmsg.warning{background:orange}.post .heading{display:flex;justify-content:space-between}.threads{width:100%}.threads .title{white-space:nowrap}@media(orientation: portrait){.threads .title{white-space:inherit}}/*# sourceMappingURL=style.css.map */ diff --git a/static/style.css.map b/static/style.css.map new file mode 100644 index 0000000..6a213bf --- /dev/null +++ b/static/style.css.map @@ -0,0 +1 @@ +{"version":3,"sourceRoot":"","sources":["../src/style.sass"],"names":[],"mappings":"AAAA,KACI,6BAEJ,EACI,sBAEJ,KACI,uDACA,gBACA,eACA,aACA,eACA,6BAEJ,KACI,eACA,WACA,oBACA,iBACA,kBACA,iBACI,eAER,SACI,wBACA,kBACA,eAEJ,OACI,gBAEJ,kBAGI,kBACA,gBAHA,sGACI,6BAGJ,8BACI,cAER,eACI,gBACA,WACA,aAEJ,MACI,iBACJ,GACI,uBACJ,WACI,4BACA,kBACJ,MACI,yBAEA,SACI,gBACA,WACA,mBACJ,kBACI,kBACJ,SACI,sBACJ,yBACI,mBAER,IACI,gBACA,YACI,kBAER,IACI,mBACA,aACA,oBACI,iBACA,kBAER,IACI,eAEJ,WACI,WAEJ,UACI,kBAEJ,uCACI,YACA,gBACA,cACA,gBACA,kBACA,qBACA,WACA,qBACA,eACA,yDAEI,yEAEJ,wEACI,yBACJ,2EACI,yBACJ,yDACI,yBACJ,yDACI,yBACJ,oFACI,yBAER,+DACI,sBACA,cACA,WACA,gBACA,mBAEJ,SACI,gBACA,WACA,YACA,sBACA,gBACJ,mBACI,YAEJ,UACI,aACA,qBACA,sBACA,eACI,mBACJ,gBACI,kBACJ,kBACI,kBAGJ,eACI,aACA,8BAER,SACI,WACA,gBACI,mBAOR,8BAGI,gBACI","file":"style.css"} \ No newline at end of file diff --git a/watch-css.sh b/watch-css.sh new file mode 100755 index 0000000..172ab9e --- /dev/null +++ b/watch-css.sh @@ -0,0 +1,2 @@ +#!/bin/sh +npx sass --watch -s compressed src/style.sass:static/style.css # & npx esbuild --bundle src/client.js --outfile=static/client.js --sourcemap --minify --watch \ No newline at end of file diff --git a/xenotime.nimble b/xenotime.nimble new file mode 100644 index 0000000..41cde42 --- /dev/null +++ b/xenotime.nimble @@ -0,0 +1,19 @@ +# Package + +version = "0.1.0" +author = "osmarks" +description = "forum software thing" +license = "MIT" +srcDir = "src" +bin = @["xenotime"] + + +# Dependencies + +requires "nim >= 1.4.2" +requires "prologue >= 0.4" +requires "karax >= 1.2.1" +requires "https://github.com/GULPF/tiny_sqlite#2944bc7" +requires "https://github.com/osmarks/nim-cmark-gfm" +requires "nimcrypto" +requires "argon2_bind >= 0.1" \ No newline at end of file