# # # The Nim Forum # (c) Copyright 2012 Andreas Rumpf, Dominik Picheta # Look at license.txt for more info. # All rights reserved. # import system except Thread import os, strutils, times, strtabs, math, jester, asyncdispatch, asyncnet, sequtils, parseutils, random, rst, recaptcha, json, re, sugar, strformat, logging, xmltree import cgi except setCookie import std/options import checksums/md5 import auth, email, utils, buildcss import baradb_sqlite when not defined(skipStopForumSpamCheck): import httpclient import frontend/threadlist except User import frontend/[ category, postlist, error, header, post, profile, user, karaxutils, search ] from htmlgen import tr, th, td, span, input, `div`, pre from xmltree import escape when not declared(roSandboxDisabled): {.error: "Your Nim version is vulnerable to a CVE. Upgrade it.".} type TCrud = enum crCreate, crRead, crUpdate, crDelete Session = object of RootObj userName, userPass, email: string rank: Rank previousVisitAt: int64 TForumData = ref object of Session req: Request userid: string config: Config const supportUrl {.strdefine.} = "" var db: DbConn isFTSAvailable: bool config: Config captcha: ReCaptcha mailer: Mailer karaxHtml: string proc init(c: TForumData) = c.userPass = "" c.userName = "" c.userid = "" proc loggedIn(c: TForumData): bool = result = c.userName.len > 0 # --------------- HTML widgets ------------------------------------------------ proc genThreadUrl(c: TForumData, postId = "", action = "", threadid = ""): string = result = "/t/" & threadid if action != "": result.add("?action=" & action) if postId != "": result.add("&postid=" & postid) elif postId != "": result.add("#" & postId) result = c.req.makeUri(result, absolute = false) proc getGravatarUrl(email: string, size = 80): string = let emailMD5 = email.toLowerAscii.toMD5 return ("https://www.gravatar.com/avatar/" & $emailMD5 & "?s=" & $size & "&d=identicon") # ----------------------------------------------------------------------------- proc validateCaptcha(recaptchaResp, ip: string) {.async, gcsafe.} = # captcha validation: {.cast(gcsafe).}: if config.recaptchaSecretKey.len > 0: var verifyFut = captcha.verify(recaptchaResp, ip) yield verifyFut if verifyFut.failed: raise newForumError( "Invalid recaptcha answer", @[] ) proc sendResetPassword( c: TForumData, email: string, recaptchaResp: string, userIp: string ) {.async, gcsafe.} = # Gather some extra information to determine ident hash. let row = db.getRow( sql""" select name, password, email, salt from person where email = ? or name = ? """, email, email ) if row[0] == "": raise newForumError("Email or username not found", @["email"]) {.cast(gcsafe).}: if not c.loggedIn: await validateCaptcha(recaptchaResp, userIp) await sendSecureEmail( mailer, ResetPassword, c.req, row[0], row[1], row[2], row[3] ) proc logout(c: TForumData) = const query = sql"delete from session where sessionKey = ?" c.username = "" c.userpass = "" exec(db, query, c.req.cookies["sid"]) proc checkLoggedIn(c: TForumData) = if not c.req.cookies.hasKey("sid"): return let sid = c.req.cookies["sid"] if execAffectedRows(db, sql("update session set lastModified = NOW() " & "where sessionKey = ?"), sid) > 0: c.userid = getValue(db, sql"select userid from session where sessionKey = ?", sid) let row = getRow(db, sql"select name, email, usrStatus from person where id = ?", c.userid) c.username = row[0] c.email = row[1] c.rank = parseEnum[Rank](row[2]) # In order to handle the "last visit" line appropriately, i.e. # it shouldn't disappear after a refresh, we need to manage a # special field called `previousVisitAt` appropriately. # That is if a user hasn't been seen for more than an hour (or so), we can # update `previousVisitAt` to the last time they were online. let personRow = getRow( db, sql""" select strftime('%s', lastOnline), strftime('%s', previousVisitAt) from person where id = ? """, c.userid ) c.previousVisitAt = personRow[1].parseInt let diff = getTime() - fromUnix(personRow[0].parseInt) if diff.inMinutes > 30: c.previousVisitAt = personRow[0].parseInt db.exec( sql""" update person set previousVisitAt = lastOnline, lastOnline = NOW() where id = ?; """, c.userid ) else: db.exec(sql"update person set lastOnline = NOW() where id = ?", c.userid) else: warn("SID not found in sessions. Assuming logged out.") proc incrementViews(threadId: int) = const query = sql"update thread set views = views + 1 where id = ?" exec(db, query, threadId) proc validateRst(c: TForumData, content: string): bool {.gcsafe.}= result = true try: discard rstToHtml(content) except EParseError: result = false proc crud(c: TCrud, table: string, data: varargs[string]): SqlQuery = case c of crCreate: var fields = "insert into " & table & "(" var vals = "" for i, d in data: if i > 0: fields.add(", ") vals.add(", ") fields.add(d) vals.add('?') result = sql(fields & ") values (" & vals & ")") of crRead: var res = "select " for i, d in data: if i > 0: res.add(", ") res.add(d) result = sql(res & " from " & table) of crUpdate: var res = "update " & table & " set " for i, d in data: if i > 0: res.add(", ") res.add(d) res.add(" = ?") result = sql(res & " where id = ?") of crDelete: result = sql("delete from " & table & " where id = ?") proc rateLimitCheck(c: TForumData): bool = const query40 = sql("SELECT count(*) FROM post where author = ? and " & "(strftime('%s', 'now') - strftime('%s', creation)) < 40") const query90 = sql("SELECT count(*) FROM post where author = ? and " & "(strftime('%s', 'now') - strftime('%s', creation)) < 90") const query300 = sql("SELECT count(*) FROM post where author = ? and " & "(strftime('%s', 'now') - strftime('%s', creation)) < 300") # TODO Why can't I pass the secs as a param? let last40s = getValue(db, query40, c.userId).parseInt let last90s = getValue(db, query90, c.userId).parseInt let last300s = getValue(db, query300, c.userId).parseInt if last40s > 1: return true if last90s > 2: return true if last300s > 6: return true return false proc verifyIdentHash( c: TForumData, name: string, epoch: int64, ident: string ) = const query = sql"select password, salt from person where name = ?" var row = getRow(db, query, name) if row[0] == "": raise newForumError("User doesn't exist.", @["nick"]) let newIdent = makeIdentHash(name, row[0], epoch, row[1]) # Check that it hasn't expired. let diff = getTime() - epoch.fromUnix() if diff.inHours > 2: raise newForumError("Link expired") if newIdent != ident: raise newForumError("Invalid ident hash") proc initialise() = randomize() config = loadConfig() if len(config.recaptchaSecretKey) > 0 and len(config.recaptchaSiteKey) > 0: captcha = initReCaptcha(config.recaptchaSecretKey, config.recaptchaSiteKey) else: doAssert config.isDev, "Recaptcha required for production!" warn("No recaptcha secret key specified.") mailer = newMailer(config) db = open(connection=config.dbPath, user="", password="", database="nimforum") isFTSAvailable = false buildCSS(config) # Read karax.html and set its properties. when defined(embedVersion): const NimblePkgVersion {.strdefine.} = "" GitHash = staticExec("git rev-parse --short HEAD || echo \"\"") Version = NimblePkgVersion & (if GitHash.len != 0: "-" & GitHash else: "") karaxHtml = readFile("public/karax.html") % { "title": config.title, "timestamp": encodeUrl(CompileDate & CompileTime), "ga": config.ga, "generator": when defined(embedVersion): "Nimforum " & Version else: "Nimforum" }.newStringTable() template createTFD() {.dirty.} = var c: TForumData new(c) init(c) c.req = request if cookies(request).len > 0: checkLoggedIn(c) #[ DB functions. TODO: Move to another module? ]# proc selectUser(userRow: seq[string], avatarSize: int=80): User = result = User( id: userRow[0], name: userRow[1], avatarUrl: userRow[2].getGravatarUrl(avatarSize), lastOnline: userRow[3].parseInt, previousVisitAt: userRow[4].parseInt, rank: parseEnum[Rank](userRow[5]), isDeleted: userRow[6] == "1" ) # Don't give data about a deleted user. if result.isDeleted: result.name = "DeletedUser" result.avatarUrl = getGravatarUrl(result.name & userRow[2], avatarSize) proc selectPost(postRow: seq[string], skippedPosts: seq[int], replyingTo: Option[PostLink], history: seq[PostInfo], likes: seq[User]): Post = let content = try: postRow[1].rstToHtml() except EParseError: `div`( span(class="text-error", "Couldn't render post #$1, raw RST below." % postRow[0]), pre(xmltree.escape(postRow[1])) ) return Post( id: postRow[0].parseInt, replyingTo: replyingTo, author: selectUser(postRow[5..11]), likes: likes, seen: false, # TODO: history: history, info: PostInfo( creation: postRow[2].parseInt, content: content ), moreBefore: skippedPosts ) proc selectReplyingTo(replyingTo: string): Option[PostLink] = if replyingTo.len == 0: return const replyingToQuery = sql""" select p.id, strftime('%s', p.creation), p.thread, u.id, u.name, u.email, strftime('%s', u.lastOnline), strftime('%s', u.previousVisitAt), u.usrStatus, u.isDeleted, t.name from post p, person u, thread t where p.thread = t.id and p.author = u.id and p.id = ? and p.isDeleted = 0; """ let row = getRow(db, replyingToQuery, replyingTo) if row[0].len == 0: return return some(PostLink( creation: row[1].parseInt(), topic: row[^1], threadId: row[2].parseInt(), postId: row[0].parseInt(), author: some(selectUser(row[3..9])) )) proc selectHistory(postId: int): seq[PostInfo] = const historyQuery = sql""" select strftime('%s', creation), content from postRevision where original = ? order by creation asc; """ result = @[] for row in getAllRows(db, historyQuery, $postId): result.add(PostInfo( creation: row[0].parseInt(), content: try: row[1].rstToHtml() except EParseError: `div`( span(class="text-error", "Couldn't render historic post #$1, raw RST below." % $postId), pre(xmltree.escape(row[1])) ) )) proc selectLikes(postId: int): seq[User] = const likeQuery = sql""" select u.id, u.name, u.email, strftime('%s', u.lastOnline), strftime('%s', u.previousVisitAt), u.usrStatus, u.isDeleted from postLike h, person u where h.post = ? and h.author = u.id order by h.creation asc; """ result = @[] for row in getAllRows(db, likeQuery, $postId): result.add(selectUser(row)) proc selectThreadAuthor(threadId: int): User = const authorQuery = sql""" select id, name, email, strftime('%s', lastOnline), strftime('%s', previousVisitAt), usrStatus, isDeleted from person where id in ( select author from post where thread = ? order by id limit 1 ) """ return selectUser(getRow(db, authorQuery, threadId)) proc selectThread(threadRow: seq[string], author: User): Thread = const postsQuery = sql"""select count(*), min(strftime('%s', creation)) from post where thread = ?;""" const usersListQuery = sql""" select u.id, name, email, strftime('%s', lastOnline), strftime('%s', previousVisitAt), usrStatus, u.isDeleted, count(*) from person u, post p where p.author = u.id and p.thread = ? group by name order by count(*) desc limit 5; """ let posts = getRow(db, postsQuery, threadRow[0]) var thread = Thread( id: threadRow[0].parseInt, topic: threadRow[1], category: Category( id: threadRow[6].parseInt, name: threadRow[7], description: threadRow[8], color: threadRow[9] ), users: @[], replies: posts[0].parseInt-1, views: threadRow[2].parseInt, activity: threadRow[3].parseInt, creation: posts[1].parseInt, isLocked: threadRow[4] == "1", isSolved: false, # TODO: Add a field to `post` to identify the solution. isPinned: threadRow[5] == "1" ) # Gather the users list. for user in getAllRows(db, usersListQuery, thread.id): thread.users.add(selectUser(user)) # Grab the author. thread.author = author return thread proc executeReply(c: TForumData, threadId: int, content: string, replyingTo: Option[int]): int64 {.gcsafe.} = # TODO: Refactor TForumData. assert c.loggedIn() if not canPost(c.rank): case c.rank of EmailUnconfirmed: raise newForumError("You need to confirm your email before you can post") else: raise newForumError("You are not allowed to post") when not defined(skipRateLimitCheck): if rateLimitCheck(c): raise newForumError("You're posting too fast!") if content.strip().len == 0: raise newForumError("Message cannot be empty") if not validateRst(c, content): raise newForumError("Message needs to be valid RST", @["msg"]) # Ensure that the thread isn't locked. let isLocked = getValue( db, sql""" select isLocked from thread where id = ?; """, threadId ) if isLocked.len == 0: raise newForumError("Thread not found.") if isLocked == "1": raise newForumError("Cannot reply to a locked thread.") var retID: int64 if replyingTo.isSome(): retID = db.nextId("post") exec( db, crud(crCreate, "post", "id", "author", "ip", "content", "thread", "replyingTo", "creation"), $retID, c.userId, c.req.ip, content, $threadId, $replyingTo.get(), $now().format("yyyy-MM-dd HH:mm:ss") ) else: retID = db.nextId("post") exec( db, crud(crCreate, "post", "id", "author", "ip", "content", "thread", "creation"), $retID, c.userId, c.req.ip, content, $threadId, $now().format("yyyy-MM-dd HH:mm:ss") ) if isFTSAvailable: discard tryExec( db, crud(crCreate, "post_fts", "id", "content"), retID.int, content ) exec(db, sql"update thread set modified = NOW() where id = ?", $threadId) return retID proc updatePost(c: TForumData, postId: int, content: string, subject: Option[string]) = ## Updates an existing post. assert c.loggedIn() let postQuery = sql""" select author, strftime('%s', creation), thread from post where id = ? """ let postRow = getRow(db, postQuery, postId) # Verify that the current user has permissions to edit the specified post. let creation = fromUnix(postRow[1].parseInt) let isArchived = (getTime() - creation).inHours >= 2 let canEdit = c.rank == Admin or c.userid == postRow[0] if isArchived and c.rank < Moderator: raise newForumError("This post is too old and can no longer be edited") if not canEdit: raise newForumError("You cannot edit this post") if not validateRst(c, content): raise newForumError("Message needs to be valid RST", @["msg"]) # Update post. # - We create a new postRevision entry for our edit. let revId = db.nextId("postRevision") exec( db, crud(crCreate, "postRevision", "id", "content", "original"), $revId, content, $postId ) # - We set the FTS to the latest content as searching for past edits is not # supported. if isFTSAvailable: exec(db, crud(crUpdate, "post_fts", "content"), content, $postId) # Check if post is the first post of the thread. if subject.isSome(): let threadId = postRow[2] let row = db.getRow(sql(""" select id from post where thread = ? order by id asc """), threadId) if row[0] == $postId: exec(db, crud(crUpdate, "thread", "name"), subject.get(), threadId) proc updateThread(c: TForumData, threadId: string, queryKeys: seq[string], queryValues: seq[string]) = let threadAuthor = selectThreadAuthor(threadId.parseInt) # Verify that the current user has permissions to edit the specified thread. let canEdit = c.rank in {Admin, Moderator} or c.userid == threadAuthor.id if not canEdit: raise newForumError("You cannot edit this thread") exec(db, crud(crUpdate, "thread", queryKeys), queryValues) proc executeNewThread(c: TForumData, subject, msg, categoryID: string): (int64, int64) {.gcsafe.} = const query = sql""" insert into thread(id, name, views, modified, category) values (?, ?, 0, NOW(), ?) """ assert c.loggedIn() if not canPost(c.rank): case c.rank of EmailUnconfirmed: raise newForumError("You need to confirm your email before you can post") else: raise newForumError("You are not allowed to post") if subject.len <= 2: raise newForumError("Subject is too short", @["subject"]) if subject.len > 100: raise newForumError("Subject is too long", @["subject"]) if msg.len == 0: raise newForumError("Message is empty", @["msg"]) let catID = getInt(categoryID, -1) if catID == -1: raise newForumError("CategoryID is invalid", @["categoryId"]) if not validateRst(c, msg): raise newForumError("Message needs to be valid RST", @["msg"]) when not defined(skipRateLimitCheck): if rateLimitCheck(c): raise newForumError("You're posting too fast!") when not defined(skipStopForumSpamCheck): if c.rank == Moderated: let client = newHttpClient() resp = client.get("https://api.stopforumspam.org/api?emailhash=" & c.email.getMd5 & "&json") if resp.code == Http200: let jresp = resp.body.parseJson if jresp["success"].num == 1 and jresp["emailhash"].hasKey("confidence") and jresp["emailhash"]["confidence"].fnum > 0.0: exec( db, sql"update person set usrStatus = ? where name = ?;", AutoSpammer, c.userName ) raise newForumError("Your account has been marked by https://www.stopforumspam.com/. If you believe this is a mistake please contact a moderator. " & supportUrl) result[0] = db.nextId("thread").int try: exec(db, query, $result[0], subject, categoryID) except: raise newForumError("Subject already exists", @["subject"]) if isFTSAvailable: discard tryExec(db, crud(crCreate, "thread_fts", "id", "name"), result[0], subject) result[1] = executeReply(c, result[0].int, msg, none[int]()) if isFTSAvailable: discard tryExec(db, sql"insert into post_fts(post_fts) values('optimize')") discard tryExec(db, sql"insert into post_fts(thread_fts) values('optimize')") proc executeLogin(c: TForumData, username, password: string): string = ## Performs a login with the specified details. ## ## Optionally, `username` may contain the email of the user instead. const query = sql""" select id, name, password, email, salt from person where (name = ? or email = ?) and isDeleted = 0 """ let username = username.strip() if username.len == 0: raise newForumError("Username cannot be empty", @["username"]) for row in fastRows(db, query, username, username): if row[2] == makePassword(password, row[4], row[2]): let key = makeSessionKey() let sessionId = db.nextId("session") exec( db, sql"insert into session (id, ip, sessionKey, userid) values (?, ?, ?, ?)", $sessionId, c.req.ip, key, row[0] ) return key raise newForumError("Invalid username or password") proc validateEmail(email: string, checkDuplicated: bool) = if not ('@' in email and '.' in email): raise newForumError("Invalid email", @["email"]) if checkDuplicated: if getValue( db, sql"select email from person where email = ? and isDeleted = 0", email ).len > 0: raise newForumError("Email already exists", @["email"]) proc executeRegister(c: TForumData, name, pass, antibot, userIp, email: string) {.async, gcsafe.} = ## Registers a new user. # email validation validateEmail(email, checkDuplicated=true) # Username validation: if name.len == 0 or not allCharsInSet(name, UsernameIdent) or name.len > 20: raise newForumError("Invalid username", @["username"]) if getValue( db, sql"select name from person where LOWER(name) = LOWER(?) and isDeleted = 0", name ).len > 0: raise newForumError("Username already exists", @["username"]) # Password validation: if pass.len < 4: raise newForumError("Please choose a longer password", @["password"]) await validateCaptcha(antibot, userIp) # perform registration: var salt = makeSalt() let password = makePassword(pass, salt) # Send activation email. {.cast(gcsafe).}: await sendSecureEmail( mailer, ActivateEmail, c.req, name, password, email, salt ) let personId = db.nextId("person") exec(db, sql""" INSERT INTO person(id, name, password, email, salt, usrStatus, creation, lastOnline, previousVisitAt) VALUES (?, ?, ?, ?, ?, ?, NOW(), NOW(), NOW()) """, $personId, name, password, email, salt, $EmailUnconfirmed) proc executeLike(c: TForumData, postId: int) = # Verify the post exists and doesn't belong to the current user. const postQuery = sql""" select u.name from post p, person u where p.id = ? and p.author = u.id and p.isDeleted = 0; """ let postAuthor = getValue(db, postQuery, postId) if postAuthor.len == 0: raise newForumError("Specified post ID does not exist.", @["id"]) if postAuthor == c.username: raise newForumError("You cannot like your own post.") let likeId = db.nextId("postLike") exec(db, crud(crCreate, "postLike", "id", "author", "post"), $likeId, c.userid, postId) proc executeNewCategory(c: TForumData, name, color, description: string): int64 = let canAdd = c.rank == Admin if not canAdd: raise newForumError("You do not have permissions to add a category.") if name.len == 0: raise newForumError("Category name must not be empty!", @["name"]) result = db.nextId("category") exec(db, crud(crCreate, "category", "id", "name", "color", "description"), $result, name, color, description) proc executeUnlike(c: TForumData, postId: int) = # Verify the post and like exists for the current user. const likeQuery = sql""" select l.id from postLike l, person u where l.post = ? and l.author = u.id and u.name = ?; """ let likeId = getValue(db, likeQuery, postId, c.username) if likeId.len == 0: raise newForumError("Like doesn't exist.", @["id"]) # Delete the like. exec(db, crud(crDelete, "postLike"), likeId) proc executeLockState(c: TForumData, threadId: int, locked: bool) = # Verify that the logged in user has the correct permissions. if c.rank < Moderator: raise newForumError("You cannot lock this thread.") # Save the like. exec(db, crud(crUpdate, "thread", "isLocked"), locked.int, threadId) proc executePinState(c: TForumData, threadId: int, pinned: bool) = if c.rank < Moderator: raise newForumError("You do not have permission to pin this thread.") # (Un)pin this thread exec(db, crud(crUpdate, "thread", "isPinned"), pinned.int, threadId) proc executeDeletePost(c: TForumData, postId: int) = # Verify that this post belongs to the user. const postQuery = sql""" select p.author, p.id from post p where p.author = ? and p.id = ? """ let row = getRow(db, postQuery, c.username, postId) author = row[0] id = row[1] if id.len == 0 and not (c.rank == Admin or c.userid == author): raise newForumError("You cannot delete this post") # Set the `isDeleted` flag. exec(db, crud(crUpdate, "post", "isDeleted"), "1", postId) proc executeDeleteThread(c: TForumData, threadId: int) = # Verify that this thread belongs to the user. let author = selectThreadAuthor(threadId) if author.name != c.username and c.rank < Admin: raise newForumError("You cannot delete this thread") # Set the `isDeleted` flag. exec(db, crud(crUpdate, "thread", "isDeleted"), "1", threadId) proc executeDeleteUser(c: TForumData, username: string) = # Verify that the current user has the permissions to do this. if username != c.username and c.rank < Admin: raise newForumError("You cannot delete this user.") # Set the `isDeleted` flag. exec(db, sql"update person set isDeleted = 1 where name = ?;", username) logout(c) proc updateProfile( c: TForumData, username, email: string, rank: Rank ) {.async, gcsafe.} = if c.rank < rank: raise newForumError("You cannot set a rank that is higher than yours.") if c.username != username and c.rank < Moderator: raise newForumError("You can't change this profile.") # Check if we are only setting the rank. if email.len == 0: exec( db, sql"update person set usrStatus = ? where name = ?;", $rank, username ) return # Make sure the rank is set to EmailUnconfirmed when the email changes. let row = getRow( db, sql"select name, password, email, salt from person where name = ?", username ) let wasEmailChanged = row[2] != email if c.rank < Moderator and wasEmailChanged: if rank != EmailUnconfirmed: raise newForumError("Rank needs a change when setting new email.") {.cast(gcsafe).}: await sendSecureEmail( mailer, ActivateEmail, c.req, row[0], row[1], email, row[3] ) validateEmail(email, checkDuplicated=wasEmailChanged) exec( db, sql"update person set usrStatus = ?, email = ? where name = ?;", $rank, email, username ) include "main.tmpl" initialise() settings: port = config.port.Port routes: get "/categories.json": # TODO: Limit this query in the case of many many categories const categoriesQuery = sql""" select c.*, count(thread.category) from category c left join thread on c.id == thread.category group by c.id; """ var list = CategoryList(categories: @[]) for data in getAllRows(db, categoriesQuery): let category = Category( id: data[0].getInt, name: data[1], description: data[2], color: data[3], numTopics: data[4].parseInt ) list.categories.add(category) resp $(%list), "application/json" get "/threads.json": var start = getInt(@"start", 0) count = getInt(@"count", 30) categoryId = getInt(@"categoryId", -1) var categorySection = "" categoryArgs: seq[string] = @[$start, $count] countQuery = sql"select count(*) from thread;" countArgs: seq[string] = @[] if categoryId != -1: categorySection = "c.id == ? and " countQuery = sql"select count(*) from thread t, category c where category == c.id and c.id == ?;" countArgs.add($categoryId) categoryArgs.insert($categoryId, 0) const threadsQuery = """select t.id, t.name, views, strftime('%s', modified), isLocked, isPinned, c.id, c.name, c.description, c.color, u.id, u.name, u.email, strftime('%s', u.lastOnline), strftime('%s', u.previousVisitAt), u.usrStatus, u.isDeleted from thread t, category c, person u where t.isDeleted = 0 and category = c.id and $# u.usrStatus <> 'Spammer' and u.usrStatus <> 'Troll' and u.usrStatus <> 'AutoSpammer' and u.id = ( select p.author from post p where p.thread = t.id order by p.author limit 1 ) order by isPinned desc, modified desc limit ?, ?;""" let thrCount = getValue(db, countQuery, countArgs).parseInt() let moreCount = max(0, thrCount - (start + count)) var list = ThreadList(threads: @[], moreCount: moreCount) for data in getAllRows(db, sql(threadsQuery % categorySection), categoryArgs): let thread = selectThread(data[0 .. 9], selectUser(data[10 .. ^1])) list.threads.add(thread) resp $(%list), "application/json" get "/posts.json": createTFD() var id = getInt(@"id", -1) anchor = getInt(@"anchor", -1) cond id != -1 const count = 10 const threadsQuery = sql"""select t.id, t.name, views, strftime('%s', modified), isLocked, isPinned, c.id, c.name, c.description, c.color from thread t, category c where t.id = ? and isDeleted = 0 and category = c.id;""" let threadRow = getRow(db, threadsQuery, id) if threadRow[0].len == 0: let err = PostError( message: "Specified thread does not exist" ) resp Http404, $(%err), "application/json" let thread = selectThread(threadRow, selectThreadAuthor(id)) let postsQuery = sql( """select p.id, p.content, strftime('%s', p.creation), p.author, p.replyingTo, u.id, u.name, u.email, strftime('%s', u.lastOnline), strftime('%s', u.previousVisitAt), u.usrStatus, u.isDeleted from post p, person u where u.id = p.author and p.thread = ? and p.isDeleted = 0 order by p.id""" ) var list = PostList( posts: @[], history: @[], thread: thread ) let rows = getAllRows(db, postsQuery, id, c.userId, c.userId) var skippedPosts: seq[int] = @[] for i in 0 ..< rows.len: let id = rows[i][0].parseInt let addDetail = i < count or rows.len-i < count or id == anchor if addDetail: let replyingTo = selectReplyingTo(rows[i][4]) history = selectHistory(id) likes = selectLikes(id) post = selectPost( rows[i], skippedPosts, replyingTo, history, likes ) list.posts.add(post) skippedPosts = @[] else: skippedPosts.add(id) incrementViews(id) resp $(%list), "application/json" get "/specific_posts.json": createTFD() var ids: JsonNode try: ids = parseJson(@"ids") except JsonParsingError: let err = PostError( message: "Invalid JSON in the `ids` parameter" ) resp Http400, $(%err), "application/json" cond ids.kind == JArray let intIDs = ids.elems.map(x => x.getInt()) let postsQuery = sql(""" select p.id, p.content, strftime('%s', p.creation), p.author, p.replyingTo, u.id, u.name, u.email, strftime('%s', u.lastOnline), strftime('%s', u.previousVisitAt), u.usrStatus, u.isDeleted from post p, person u where u.id = p.author and p.id in ($#) order by p.id; """ % intIDs.join(",")) # TODO: It's horrible that I have to do this. var list: seq[Post] = @[] for row in db.getAllRows(postsQuery): let history = selectHistory(row[0].parseInt()) let likes = selectLikes(row[0].parseInt()) list.add(selectPost(row, @[], selectReplyingTo(row[4]), history, likes)) resp $(%list), "application/json" get "/post.rst": createTFD() let postId = getInt(@"id", -1) cond postId != -1 let postQuery = sql""" select content from ( select content, creation from post where id = ? union select content, creation from postRevision where original = ? ) order by creation desc limit 1; """ let content = getValue(db, postQuery, postId, postId) if content.len == 0: resp Http404, "Post not found" else: resp content, "text/x-rst" get "/profile.json": createTFD() var username = @"username" # Have to do this because SQLITE doesn't support `in` queries with # multiple columns :/ # TODO: Figure out a better way. This is horrible. let creatorSubquery = """ (select $1 from post p where p.thread = t.id order by p.id asc limit 1) """ let threadsFrom = """ from thread t, post p where ? in $1 and p.id in $2 """ % [creatorSubquery % "author", creatorSubquery % "id"] let postsFrom = """ from post p, person u, thread t where u.id = p.author and p.thread = t.id and u.name = ? """ let postsQuery = sql(""" select p.id, strftime('%s', p.creation), t.name, t.id $1 order by p.id desc limit 10; """ % postsFrom) let userQuery = sql(""" select id, name, email, strftime('%s', lastOnline), strftime('%s', previousVisitAt), usrStatus, isDeleted, strftime('%s', creation), id from person where name = ? and isDeleted = 0 """) var profile = Profile( threads: @[], posts: @[] ) let userRow = db.getRow(userQuery, username) let userID = userRow[^1] if userID.len == 0: halt() profile.user = selectUser(userRow, avatarSize=200) profile.joinTime = userRow[^2].parseInt() profile.postCount = getValue(db, sql("select count(*) " & postsFrom), username).parseInt() profile.threadCount = getValue(db, sql("select count(*) " & threadsFrom), userID).parseInt() if c.rank >= Admin or c.username == username: profile.email = some(userRow[2]) for row in db.getAllRows(postsQuery, username): profile.posts.add( PostLink( creation: row[1].parseInt(), topic: row[2], threadId: row[3].parseInt(), postId: row[0].parseInt() ) ) let threadsQuery = sql(""" select t.id, t.name, strftime('%s', p.creation), p.id $1 order by t.id desc limit 10; """ % threadsFrom) for row in db.getAllRows(threadsQuery, userID): profile.threads.add( PostLink( creation: row[2].parseInt(), topic: row[1], threadId: row[0].parseInt(), postId: row[3].parseInt() ) ) resp $(%profile), "application/json" post "/login": createTFD() let formData = request.formData cond "username" in formData cond "password" in formData try: let session = executeLogin( c, formData["username"].body, formData["password"].body ) setCookie("sid", session, httpOnly=true, sameSite=Strict, secure=true) resp Http200, "{}", "application/json" except ForumError as exc: resp Http400, $(%exc.data), "application/json" post "/signup": createTFD() let formData = request.formData {.cast(gcsafe).}: if not config.isDev: cond "g-recaptcha-response" in formData let username = formData["username"].body let password = formData["password"].body let recaptcha = if "g-recaptcha-response" in formData: formData["g-recaptcha-response"].body else: "" try: await executeRegister( c, username, password, recaptcha, request.host, formData["email"].body ) let session = executeLogin(c, username, password) setCookie("sid", session, httpOnly=true, sameSite=Strict, secure=true) resp Http200, "{}", "application/json" except ForumError as exc: resp Http400, $(%exc.data), "application/json" post "/createCategory": createTFD() let formData = request.formData let name = formData["name"].body let color = formData["color"].body.replace("#", "") let description = formData["description"].body try: let id = executeNewCategory(c, name, color, description) let category = Category(id: id.int, name: name, color: color, description: description) resp Http200, $(%category), "application/json" except ForumError as exc: resp Http400, $(%exc.data), "application/json" get "/status.json": createTFD() let user = if @"logout" == "true": logout(c); none[User]() elif c.loggedIn(): some(User( name: c.username, avatarUrl: c.email.getGravatarUrl(), lastOnline: getTime().toUnix(), previousVisitAt: c.previousVisitAt, rank: c.rank )) else: none[User]() {.cast(gcsafe).}: let usrStatus = UserStatus( user: user, recaptchaSiteKey: if not config.isDev: some(config.recaptchaSiteKey) else: none[string]() ) resp $(%usrStatus), "application/json" post "/preview": createTFD() if not c.loggedIn(): let err = PostError( errorFields: @[], message: "Not logged in." ) resp Http401, $(%err), "application/json" let formData = request.formData cond "msg" in formData let msg = formData["msg"].body try: let rendered = msg.rstToHtml() resp Http200, rendered except EParseError: let err = PostError( errorFields: @[], message: "Message needs to be valid RST! Error: " & getCurrentExceptionMsg() ) resp Http400, $(%err), "application/json" post "/createPost": createTFD() if not c.loggedIn(): let err = PostError( errorFields: @[], message: "Not logged in." ) resp Http401, $(%err), "application/json" let formData = request.formData cond "msg" in formData cond "threadId" in formData let msg = formData["msg"].body let threadId = getInt(formData["threadId"].body, -1) cond threadId != -1 let replyingToId = if "replyingTo" in formData: getInt(formData["replyingTo"].body, -1) else: -1 let replyingTo = if replyingToId == -1: none[int]() else: some(replyingToId) try: let id = executeReply(c, threadId, msg, replyingTo) resp Http200, $(%id), "application/json" except ForumError as exc: resp Http400, $(%exc.data), "application/json" post "/updatePost": createTFD() if not c.loggedIn(): let err = PostError( errorFields: @[], message: "Not logged in." ) resp Http401, $(%err), "application/json" let formData = request.formData cond "msg" in formData cond "postId" in formData let msg = formData["msg"].body let postId = getInt(formData["postId"].body, -1) cond postId != -1 let subject = if "subject" in formData: some(formData["subject"].body) else: none[string]() try: updatePost(c, postId, msg, subject) resp Http200, msg.rstToHtml(), "text/html" except ForumError as exc: resp Http400, $(%exc.data), "application/json" post "/updateThread": # TODO: Add some way of keeping track of modifications for historical # purposes createTFD() if not c.loggedIn(): let err = PostError( errorFields: @[], message: "Not logged in." ) resp Http401, $(%err), "application/json" let formData = request.formData cond "threadId" in formData let threadId = formData["threadId"].body # TODO: might want to add more properties here under a tighter permissions # model let keys = ["name", "category", "solution"] # optional parameters var queryValues: seq[string] = @[] queryKeys: seq[string] = @[] for key in keys: if key in formData: queryKeys.add(key) queryValues.add(formData[key].body) if queryKeys.len() > 0: queryValues.add(threadId) try: updateThread(c, threadId, queryKeys, queryValues) resp Http200, "{}", "application/json" except ForumError as exc: resp Http400, $(%exc.data), "application/json" post "/newthread": createTFD() if not c.loggedIn(): let err = PostError( errorFields: @[], message: "Not logged in." ) resp Http401, $(%err), "application/json" let formData = request.formData cond "msg" in formData cond "subject" in formData cond "categoryId" in formData let msg = formData["msg"].body let subject = formData["subject"].body let categoryID = formData["categoryId"].body try: let res = executeNewThread(c, subject, msg, categoryID) resp Http200, $(%[res[0], res[1]]), "application/json" except ForumError as exc: resp Http400, $(%exc.data), "application/json" post re"/(like|unlike)": createTFD() if not c.loggedIn(): let err = PostError( errorFields: @[], message: "Not logged in." ) resp Http401, $(%err), "application/json" let formData = request.formData cond "id" in formData let postId = getInt(formData["id"].body, -1) cond postId != -1 try: case request.path of "/like": executeLike(c, postId) of "/unlike": executeUnlike(c, postId) else: assert false resp Http200, "{}", "application/json" except ForumError as exc: resp Http400, $(%exc.data), "application/json" post re"/(lock|unlock)": createTFD() if not c.loggedIn(): let err = PostError( errorFields: @[], message: "Not logged in." ) resp Http401, $(%err), "application/json" let formData = request.formData cond "id" in formData let threadId = getInt(formData["id"].body, -1) cond threadId != -1 try: case request.path of "/lock": executeLockState(c, threadId, true) of "/unlock": executeLockState(c, threadId, false) else: assert false resp Http200, "{}", "application/json" except ForumError as exc: resp Http400, $(%exc.data), "application/json" post re"/(pin|unpin)": createTFD() if not c.loggedIn(): let err = PostError( errorFields: @[], message: "Not logged in." ) resp Http401, $(%err), "application/json" let formData = request.formData cond "id" in formData let threadId = getInt(formData["id"].body, -1) cond threadId != -1 try: case request.path of "/pin": executePinState(c, threadId, true) of "/unpin": executePinState(c, threadId, false) else: assert false resp Http200, "{}", "application/json" except ForumError as exc: resp Http400, $(%exc.data), "application/json" post re"/delete(Post|Thread)": createTFD() if not c.loggedIn(): let err = PostError( errorFields: @[], message: "Not logged in." ) resp Http401, $(%err), "application/json" let formData = request.formData cond "id" in formData let id = getInt(formData["id"].body, -1) cond id != -1 try: case request.path of "/deletePost": executeDeletePost(c, id) of "/deleteThread": executeDeleteThread(c, id) else: assert false resp Http200, "{}", "application/json" except ForumError as exc: resp Http400, $(%exc.data), "application/json" post "/deleteUser": createTFD() if not c.loggedIn(): let err = PostError( errorFields: @[], message: "Not logged in." ) resp Http401, $(%err), "application/json" let formData = request.formData cond "username" in formData let username = formData["username"].body try: executeDeleteUser(c, username) resp Http200, "{}", "application/json" except ForumError as exc: resp Http400, $(%exc.data), "application/json" post "/saveProfile": createTFD() if not c.loggedIn(): let err = PostError( errorFields: @[], message: "Not logged in." ) resp Http401, $(%err), "application/json" let formData = request.formData cond "username" in formData cond "email" in formData cond "rank" in formData let username = formData["username"].body let email = formData["email"].body let rank = parseEnum[Rank](formData["rank"].body) try: await updateProfile(c, username, email, rank) resp Http200, "{}", "application/json" except ForumError as exc: resp Http400, $(%exc.data), "application/json" post "/sendResetPassword": createTFD() let formData = request.formData let recaptcha = if "g-recaptcha-response" in formData: formData["g-recaptcha-response"].body else: "" if not c.loggedIn(): {.cast(gcsafe).}: let isProductionEnvironment = not config.isDev if isProductionEnvironment: if "g-recaptcha-response" notin formData: let err = PostError( errorFields: @[], message: "Not logged in and no recaptcha." ) resp Http401, $(%err), "application/json" cond "email" in formData try: await sendResetPassword( c, formData["email"].body, recaptcha, request.host ) resp Http200, "{}", "application/json" except ForumError as exc: resp Http400, $(%exc.data), "application/json" post "/resetPassword": createTFD() cond(@"nick" != "") cond(@"epoch" != "") cond(@"ident" != "") cond(@"newPassword" != "") let epoch = getInt64(@"epoch", -1) try: verifyIdentHash(c, @"nick", epoch, @"ident") var salt = makeSalt() let password = makePassword(@"newPassword", salt) exec( db, sql""" update person set password = ?, salt = ?, lastOnline = NOW() where name = ?; """, password, salt, @"nick" ) # Remove all sessions. exec( db, sql""" delete from session where userid = ( select id from person where name = ? ) """, @"nick" ) resp Http200, "{}", "application/json" except ForumError as exc: resp Http400, $(%exc.data), "application/json" post "/activateEmail": createTFD() cond(@"nick" != "") cond(@"epoch" != "") cond(@"ident" != "") let epoch = getInt64(@"epoch", -1) try: verifyIdentHash(c, @"nick", epoch, @"ident") exec( db, sql""" update person set usrStatus = ?, lastOnline = NOW() where name = ?; """, $Rank.Moderated, @"nick" ) resp Http200, "{}", "application/json" except ForumError as exc: resp Http400, $(%exc.data), "application/json" get "/t/@id": cond "id" in request.params const threadsQuery = sql"""select id from thread where id = ? and isDeleted = 0;""" let value = getValue(db, threadsQuery, @"id") if value == @"id": pass else: redirect uri("/404") get "/t/@id/@page": redirect uri("/t/" & @"id") get "/profile/@username": cond "username" in request.params let username = decodeUrl(@"username") const threadsQuery = sql"""select name from person where name = ? and isDeleted = 0;""" let value = getValue(db, threadsQuery, username) if value == username: pass else: redirect uri("/404") get "/404": resp Http404, readFile("public/karax.html") get "/about/license.html": {.cast(gcsafe).}: let content = readFile("public/license.rst") % { "hostname": config.hostname, "name": config.name }.newStringTable() resp content.rstToHtml() get "/about/rst.html": let content = readFile("public/rst.rst") resp content.rstToHtml() get "/threadActivity.xml": createTFD() {.cast(gcsafe).}: resp genThreadsRSS(c), "application/atom+xml" get "/postActivity.xml": createTFD() {.cast(gcsafe).}: resp genPostsRSS(c), "application/atom+xml" get "/search.json": cond "q" in request.params let q = @"q" cond q.len > 0 var results: seq[SearchResult] = @[] # FTS disabled for BaraDB demo — simple LIKE-based search let searchPattern = "%" & q & "%" let threadRows = getAllRows(db, sql""" SELECT t.id, t.name, 0, strftime('%s', t.modified), p.id, u.id, u.name, u.email, strftime('%s', u.lastOnline), strftime('%s', u.previousVisitAt), u.usrStatus, u.isDeleted FROM thread t JOIN post p ON p.thread = t.id JOIN person u ON u.id = p.author WHERE t.isDeleted = 0 AND t.name LIKE ? GROUP BY t.id LIMIT 40 """, searchPattern) for row in threadRows: results.add( SearchResult( kind: ThreadMatch, threadId: row[0].parseInt(), threadTitle: row[1], postId: row[4].parseInt(), postContent: "", creation: row[3].parseInt().int64, author: selectUser(row[4..11]), ) ) let postRows = getAllRows(db, sql""" SELECT p.thread, t.name, p.id, strftime('%s', p.creation), p.id, u.id, u.name, u.email, strftime('%s', u.lastOnline), strftime('%s', u.previousVisitAt), u.usrStatus, u.isDeleted FROM post p JOIN thread t ON t.id = p.thread JOIN person u ON u.id = p.author WHERE p.isDeleted = 0 AND p.content LIKE ? LIMIT 40 """, searchPattern) for row in postRows: if results.len >= 40: break let content = row[1] var outcome = "" try: outcome = content.rstToHtml() except EParseError: warn("Could not parse rst html.") outcome = xmltree.escape(content) results.add( SearchResult( kind: PostMatch, threadId: row[0].parseInt(), threadTitle: row[1], postId: row[2].parseInt(), postContent: outcome, creation: row[3].parseInt().int64, author: selectUser(row[4..11]), ) ) resp Http200, $(%results), "application/json" get re"/(.*)": cond request.matches[0].splitFile.ext == "" {.cast(gcsafe).}: resp karaxHtml