This commit is contained in:
osmarks 2021-04-25 13:35:51 +01:00
commit 5ad0e0927c
12 changed files with 761 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
.env
xenotime
*.sqlite*

1
nim.cfg Normal file
View File

@ -0,0 +1 @@
--threads:on

121
src/domain.nim Normal file
View File

@ -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

87
src/md.nim Normal file
View File

@ -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)

87
src/sqlitesession.nim Normal file
View File

@ -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)

158
src/style.sass Executable file
View File

@ -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

73
src/util.nim Normal file
View File

@ -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

208
src/xenotime.nim Normal file
View File

@ -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
"<!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.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()

1
static/style.css Normal file
View File

@ -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 */

1
static/style.css.map Normal file
View File

@ -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"}

2
watch-css.sh Executable file
View File

@ -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

19
xenotime.nimble Normal file
View File

@ -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"