stuff
This commit is contained in:
commit
5ad0e0927c
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
.env
|
||||
xenotime
|
||||
*.sqlite*
|
121
src/domain.nim
Normal file
121
src/domain.nim
Normal 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
87
src/md.nim
Normal 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
87
src/sqlitesession.nim
Normal 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
158
src/style.sass
Executable 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
73
src/util.nim
Normal 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
208
src/xenotime.nim
Normal 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
1
static/style.css
Normal 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
1
static/style.css.map
Normal 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
2
watch-css.sh
Executable 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
19
xenotime.nimble
Normal 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"
|
Loading…
Reference in New Issue
Block a user