forked from osmarks/xenotime
		
	stuff
This commit is contained in:
		
							
								
								
									
										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" | ||||
		Reference in New Issue
	
	Block a user