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