nimforum/src/forum.nim
2018-05-27 20:58:38 +01:00

1459 lines
No EOL
40 KiB
Nim

#
#
# 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, md5, strtabs, math, db_sqlite,
scgi, jester, asyncdispatch, asyncnet, sequtils,
parseutils, random, rst, recaptcha, json, re, sugar,
strformat, logging
import cgi except setCookie
import options
import auth, email, utils, buildcss
import frontend/threadlist except User
import frontend/[
category, postlist, error, header, post, profile, user, karaxutils, search
]
from htmlgen import tr, th, td, span, input
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
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")
# -----------------------------------------------------------------------------
template `||`(x: untyped): untyped = (if not isNil(x): x else: "")
proc validateCaptcha(recaptchaResp, ip: string) {.async.} =
# captcha validation:
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.} =
# 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"])
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 ip = ? and key = ?"
c.username = ""
c.userpass = ""
exec(db, query, c.req.ip, 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 = DATETIME('now') " &
"where ip = ? and key = ?"),
c.req.ip, sid) > 0:
c.userid = getValue(db,
sql"select userid from session where ip = ? and key = ?",
c.req.ip, sid)
let row = getRow(db,
sql"select name, email, status 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.minutes > 30:
c.previousVisitAt = personRow[0].parseInt
db.exec(
sql"""
update person set
previousVisitAt = lastOnline, lastOnline = DATETIME('now')
where id = ?;
""",
c.userid
)
else:
db.exec(sql"update person set lastOnline = DATETIME('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 =
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.hours > 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 = db.getAllRows(sql("SELECT name FROM sqlite_master WHERE " &
"type='table' AND name='post_fts'")).len == 1
buildCSS(config)
# Read karax.html and set its properties.
karaxHtml = readFile("public/karax.html") %
{
"title": config.title,
"timestamp": encodeUrl(CompileDate & CompileTime),
"ga": config.ga
}.newStringTable()
template createTFD() =
var c {.inject.}: TForumData
new(c)
init(c)
c.req = request
if request.cookies.len > 0:
checkLoggedIn(c)
#[ DB functions. TODO: Move to another module? ]#
proc selectUser(userRow: seq[string], avatarSize: int=80): User =
result = User(
name: userRow[0],
avatarUrl: userRow[1].getGravatarUrl(avatarSize),
lastOnline: userRow[2].parseInt,
previousVisitAt: userRow[3].parseInt,
rank: parseEnum[Rank](userRow[4]),
isDeleted: userRow[5] == "1"
)
# Don't give data about a deleted user.
if result.isDeleted:
result.name = "DeletedUser"
result.avatarUrl = getGravatarUrl(result.name & userRow[1], avatarSize)
proc selectPost(postRow: seq[string], skippedPosts: seq[int],
replyingTo: Option[PostLink], history: seq[PostInfo],
likes: seq[User]): Post =
return Post(
id: postRow[0].parseInt,
replyingTo: replyingTo,
author: selectUser(postRow[5..10]),
likes: likes,
seen: false, # TODO:
history: history,
info: PostInfo(
creation: postRow[2].parseInt,
content: postRow[1].rstToHtml()
),
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.name, u.email, strftime('%s', u.lastOnline),
strftime('%s', u.previousVisitAt), u.status,
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..8]))
))
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: row[1].rstToHtml()
))
proc selectLikes(postId: int): seq[User] =
const likeQuery = sql"""
select u.name, u.email, strftime('%s', u.lastOnline),
strftime('%s', u.previousVisitAt), u.status,
u.isDeleted
from like 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 name, email, strftime('%s', lastOnline),
strftime('%s', previousVisitAt), status, 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 name, email, strftime('%s', lastOnline),
strftime('%s', previousVisitAt), status, 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[5].parseInt,
name: threadRow[6],
description: threadRow[7],
color: threadRow[8]
),
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.
)
# 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 =
# 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")
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.")
let retID = insertID(
db,
crud(crCreate, "post", "author", "ip", "content", "thread", "replyingTo"),
c.userId, c.req.ip, content, $threadId,
if replyingTo.isSome(): $replyingTo.get()
else: nil
)
discard tryExec(
db,
crud(crCreate, "post_fts", "id", "content"),
retID.int, content
)
exec(db, sql"update thread set modified = DATETIME('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).weeks > 8
let canEdit = c.rank == Admin or c.userid == postRow[0]
if isArchived:
raise newForumError("This post is archived 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.
exec(
db,
crud(crCreate, "postRevision", "content", "original"),
content,
$postId
)
# - We set the FTS to the latest content as searching for past edits is not
# supported.
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 executeNewThread(c: TForumData, subject, msg: string): (int64, int64) =
const
query = sql"""
insert into thread(name, views, modified) values (?, 0, DATETIME('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"])
if not validateRst(c, msg):
raise newForumError("Message needs to be valid RST", @["msg"])
if rateLimitCheck(c):
raise newForumError("You're posting too fast!")
result[0] = tryInsertID(db, query, subject).int
if result[0] < 0:
raise newForumError("Subject already exists", @["subject"])
discard tryExec(db, crud(crCreate, "thread_fts", "id", "name"),
result[0], subject)
result[1] = executeReply(c, result[0].int, msg, none[int]())
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()
exec(
db,
sql"insert into session (ip, key, userid) values (?, ?, ?)",
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.} =
## 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 name = ? 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.
await sendSecureEmail(
mailer, ActivateEmail, c.req, name, password, email, salt
)
# Add account to person table
exec(db, sql"""
INSERT INTO person(name, password, email, salt, status, lastOnline)
VALUES (?, ?, ?, ?, ?, DATETIME('now'))
""", 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.")
# Save the like.
exec(db, crud(crCreate, "like", "author", "post"), c.userid, postId)
proc executeUnlike(c: TForumData, postId: int) =
# Verify the post and like exists for the current user.
const likeQuery = sql"""
select l.id from like 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, "like"), 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 executeDeletePost(c: TForumData, postId: int) =
# Verify that this post belongs to the user.
const postQuery = sql"""
select p.id from post p
where p.author = ? and p.id = ?
"""
let id = getValue(db, postQuery, c.username, postId)
if id.len == 0 and c.rank < Admin:
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.} =
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 status = ? 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.")
await sendSecureEmail(
mailer, ActivateEmail, c.req, row[0], row[1], row[2], row[3]
)
validateEmail(email, checkDuplicated=wasEmailChanged)
exec(
db,
sql"update person set status = ?, email = ? where name = ?;",
$rank, email, username
)
include "main.tmpl"
initialise()
settings:
port = config.port.Port
routes:
get "/threads.json":
var
start = getInt(@"start", 0)
count = getInt(@"count", 30)
const threadsQuery =
sql"""select t.id, t.name, views, strftime('%s', modified), isLocked,
c.id, c.name, c.description, c.color,
u.name, u.email, strftime('%s', u.lastOnline),
strftime('%s', u.previousVisitAt), u.status, u.isDeleted
from thread t, category c, person u
where t.isDeleted = 0 and category = c.id and
u.status <> 'Spammer' and u.status <> 'Troll' and
u.status <> 'Banned' and
u.id in (
select u.id from post p, person u
where p.author = u.id and p.thread = t.id
order by u.id
limit 1
)
order by modified desc limit ?, ?;"""
let thrCount = getValue(db, sql"select count(*) from thread;").parseInt()
let moreCount = max(0, thrCount - (start + count))
var list = ThreadList(threads: @[], moreCount: moreCount)
for data in getAllRows(db, threadsQuery, start, count):
let thread = selectThread(data[0 .. 8], selectUser(data[9 .. ^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,
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)
let thread = selectThread(threadRow, selectThreadAuthor(id))
let postsQuery =
sql(
"""select p.id, p.content, strftime('%s', p.creation), p.author,
p.replyingTo,
u.name, u.email, strftime('%s', u.lastOnline),
strftime('%s', u.previousVisitAt), u.status,
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])
let history = selectHistory(id)
let likes = selectLikes(id)
let 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 = parseJson(@"ids")
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.name, u.email, strftime('%s', u.lastOnline),
strftime('%s', u.previousVisitAt), u.status,
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 name, email, strftime('%s', lastOnline),
strftime('%s', previousVisitAt), status, 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[1])
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)
resp Http200, "{}", "application/json"
except ForumError as exc:
resp Http400, $(%exc.data), "application/json"
post "/signup":
createTFD()
let formData = request.formData
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)
resp Http200, "{}", "application/json"
except ForumError:
let exc = (ref ForumError)(getCurrentException())
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]()
let status = UserStatus(
user: user,
recaptchaSiteKey:
if not config.isDev:
some(config.recaptchaSiteKey)
else:
none[string]()
)
resp $(%status), "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: 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 "/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
let msg = formData["msg"].body
let subject = formData["subject"].body
# TODO: category
try:
let res = executeNewThread(c, subject, msg)
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"/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:
let exc = (ref ForumError)(getCurrentException())
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():
if not config.isDev:
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:
let exc = (ref ForumError)(getCurrentException())
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 = DATETIME('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 status = ?, lastOnline = DATETIME('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":
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()
resp genThreadsRSS(c), "application/atom+xml"
get "/postActivity.xml":
createTFD()
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] = @[]
const queryFT = "fts.sql".slurp.sql
const count = 40
let data = [
q, q, $count, $0, q,
q, $count, $0, q
]
for rowFT in fastRows(db, queryFT, data):
var content = rowFT[3]
try: content = content.rstToHtml() except EParseError: discard
results.add(
SearchResult(
kind: SearchResultKind(rowFT[^1].parseInt()),
threadId: rowFT[0].parseInt(),
threadTitle: rowFT[1],
postId: rowFT[2].parseInt(),
postContent: content,
creation: rowFT[4].parseInt(),
author: selectUser(rowFT[5 .. 10]),
)
)
resp Http200, $(%results), "application/json"
get re"/(.*)":
cond request.matches[0].splitFile.ext == ""
resp karaxHtml