diff --git a/createdb.nim b/createdb.nim index 2d34099..13ef072 100644 --- a/createdb.nim +++ b/createdb.nim @@ -36,7 +36,7 @@ create table if not exists person( email $# not null, creation timestamp not null default (DATETIME('now')), salt varbin(128) not null, - status integer not null, + status varchar(30) not null, admin bool default false, lastOnline timestamp not null default (DATETIME('now')) );""" % [TUserName, TPassword, TEmail]), []) diff --git a/editdb.nim b/editdb.nim index ea28f93..13f77de 100644 --- a/editdb.nim +++ b/editdb.nim @@ -1,11 +1,12 @@ -import strutils, db_sqlite +import strutils, db_sqlite, ranks -var db = Open(connection="nimforum.db", user="postgres", password="", +var db = open(connection="nimforum.db", user="postgres", password="", database="nimforum") -db.exec(sql"""ALTER TABLE person add column - lastOnline timestamp -""", []) +db.exec(sql("update person set status = ?"), $User) +db.exec(sql("update person set status = ? where ban <> ''"), $Troll) +db.exec(sql("update person set status = ? where ban like '%spam%'"), $Spammer) +db.exec(sql("update person set status = ? where admin"), $Admin) -close(db) \ No newline at end of file +close(db) diff --git a/forms.tmpl b/forms.tmpl index 70bf163..862aff1 100644 --- a/forms.tmpl +++ b/forms.tmpl @@ -6,7 +6,10 @@ # # #proc genThreadsList(c: var TForumData, count: var int): string = -# const query = sql"select id, name, views, modified from thread order by modified desc limit ?, ?" +# const query = sql"""select id, name, views, modified from thread +# where id in (select thread from post where author in +# (select id from person where ban <> 'MODERATED')) +# order by modified desc limit ?, ?""" # const threadId = 0 # const name = 1 # const views = 2 @@ -113,7 +116,9 @@ # #proc genPostsList(c: var TForumData, threadId: string, count: var int): string = # const query = sql"""select p.id, u.name, p.header, p.content, p.creation, p.author, u.email from post p, -# person u where u.id = p.author and p.thread = ? order by p.id limit ?, ?""" +# person u +# where u.id = p.author and p.thread = ? and p.ban <> 'MODERATED' +# order by p.id limit ?, ?""" # const postId = 0 # const userName = 1 # const postHeader = 2 @@ -146,7 +151,7 @@ ${xmlEncode(%userName)} #if c.userId == %postAuthor and c.currentPost.subject.len == 0:
Edit post - #elif c.isAdmin and c.currentPost.subject.len == 0: + #elif c.rank >= Moderator and c.currentPost.subject.len == 0:
Edit post #end if @@ -184,15 +189,15 @@
#if action == "doreply": - ${HiddenField(c, "subject", title)} + ${hiddenField(c, "subject", title)} #else: - ${FieldValid(c, "subject", "Subject:")} - ${TextWidget(c, "subject", title, maxlength=100)} + ${fieldValid(c, "subject", "Subject:")} + ${textWidget(c, "subject", title, maxlength=100)}
#end if - ${FieldValid(c, "content", "Content:")}
- ${TextAreaWidget(c, "content", content)}
- ${FormSession(c, action)} + ${fieldValid(c, "content", "Content:")}
+ ${textAreaWidget(c, "content", content)}
+ ${formSession(c, action)} # if isEdit: Delete Post
@@ -226,20 +231,20 @@ - - + + - + - - + + - - + +
${FieldValid(c, "name", "Username:")}${TextWidget(c, "name", reuseText, maxlength=20)}${fieldValid(c, "name", "Username:")}${textWidget(c, "name", reuseText, maxlength=20)}
${FieldValid(c, "new_password", "Password:")}${fieldValid(c, "new_password", "Password:")}
${FieldValid(c, "email", "E-Mail:")}${TextWidget(c, "email", reuseText, maxlength=300)}${fieldValid(c, "email", "E-Mail:")}${textWidget(c, "email", reuseText, maxlength=300)}
${FieldValid(c, "antibot", "What is " & antibot(c) & "?")}${TextWidget(c, "antibot", "", maxlength=4)}${fieldValid(c, "antibot", "What is " & antibot(c) & "?")}${textWidget(c, "antibot", "", maxlength=4)}
#if c.errorMsg != "": @@ -288,7 +293,7 @@ # # #proc genSearchResults(c: var TForumData, -# results: iterator: db_sqlite.Row {.closure, tags: [FReadDB].}, +# results: iterator: db_sqlite.Row {.closure, tags: [ReadDbEffect].}, # count: var int): string = # const threadId = 0 # const threadName = 1 @@ -326,7 +331,7 @@
${xmlEncode(%userName)}
#if c.userId == %postAuthor and c.currentPost.subject.len == 0:
Edit post - #elif c.isAdmin and c.currentPost.subject.len == 0: + #elif c.rank >= Moderator and c.currentPost.subject.len == 0:
Edit post #end if @@ -383,12 +388,12 @@ - + - - + +
${FieldValid(c, "nick", "Your nickname:")}${fieldValid(c, "nick", "Your nickname:")}
${FieldValid(c, "antibot", "What is " & antibot(c) & "?")}${TextWidget(c, "antibot", "", maxlength=4)}${fieldValid(c, "antibot", "What is " & antibot(c) & "?")}${textWidget(c, "antibot", "", maxlength=4)}
#if c.errorMsg != "": diff --git a/forum.nim b/forum.nim index bc9a47c..f13b500 100644 --- a/forum.nim +++ b/forum.nim @@ -7,9 +7,9 @@ # import - os, strutils, times, md5, strtabs, cgi, math, db_sqlite, matchers, + os, strutils, times, md5, strtabs, cgi, math, db_sqlite, captchas, scgi, jester, asyncdispatch, asyncnet, cache, sequtils, - parseutils, utils, random, rst + parseutils, utils, random, rst, ranks when not defined(windows): import bcrypt # TODO @@ -25,8 +25,6 @@ const MaxPagesFromCurrent = 8 noPageNums = ["/login", "/register", "/dologin", "/doregister", "/profile"] noHomeBtn = ["/", "/login", "/register", "/dologin", "/doregister", "/profile"] - banReasonDeactivated = "DEACTIVATED" - banReasonEmailUnconfirmed = "EMAILCONFIRMATION" type TCrud = enum crCreate, crRead, crUpdate, crDelete @@ -35,7 +33,7 @@ type threadid: int postid: int userName, userPass, email: string - isAdmin: bool + rank: Rank TPost = tuple[subject, content: string] @@ -70,6 +68,7 @@ type lastOnline: int email: string ban: string + rank: Rank ForumError = object of Exception @@ -103,27 +102,27 @@ proc loggedIn(c: TForumData): bool = const reuseText = "\1" -proc TextWidget(c: TForumData, name, defaultText: string, +proc textWidget(c: TForumData, name, defaultText: string, maxlength = 30, size = -1): string = let x = if defaultText != reuseText: defaultText else: xmlEncode(c.req.params.getOrDefault(name)) return """""" % [ name, $maxlength, x, if size != -1: "size=\"" & $size & "\"" else: ""] -proc HiddenField(c: TForumData, name, defaultText: string): string = +proc hiddenField(c: TForumData, name, defaultText: string): string = let x = xmlencode( if defaultText != reuseText: defaultText else: c.req.params.getOrDefault(name) ) return """""" % [name, x] -proc TextAreaWidget(c: TForumData, name, defaultText: string): string = +proc textAreaWidget(c: TForumData, name, defaultText: string): string = let x = if defaultText != reuseText: defaultText else: xmlEncode(c.req.params.getOrDefault(name)) return """""" % [ name, x] -proc FieldValid(c: TForumData, name, text: string): string = +proc fieldValid(c: TForumData, name, text: string): string = if name == c.invalidField: result = """$1""" % text else: @@ -141,12 +140,12 @@ proc genThreadUrl(c: TForumData, postId = "", action = "", threadid = "", pageNu result.add("#" & postId) result = c.req.makeUri(result, absolute = false) -proc FormSession(c: var TForumData, nextAction: string): string = +proc formSession(c: var TForumData, nextAction: string): string = return """ """ % [ $c.threadId, $c.postid] -proc UrlButton(c: var TForumData, text, url: string): string = +proc urlButton(c: var TForumData, text, url: string): string = return ("""$2""") % [ url, text] @@ -343,10 +342,10 @@ proc register(c: var TForumData, name, pass, antibot, # add account to person table exec(db, - sql("INSERT INTO person(name, password, email, salt, status, lastOnline, " & - "ban) VALUES (?, ?, ?, ?, 'user', DATETIME('now'), ?)"), name, + sql("INSERT INTO person(name, password, email, salt, status, lastOnline) " & + "VALUES (?, ?, ?, ?, ?, DATETIME('now'))"), name, password, email, salt, - when defined(dev): "" else: banReasonEmailUnconfirmed) + when defined(dev): $User else: $EmailUnconfirmed) return true @@ -384,15 +383,19 @@ proc logout(c: var TForumData) = c.userpass = "" exec(db, query, c.req.ip, c.req.cookies["sid"]) -proc getBanErrorMsg(banValue: string): string = - case banValue - of "": return "" - of banReasonDeactivated: - return "Your account has been deactivated." - of banReasonEmailUnconfirmed: - return "You need to confirm your email first." - else: +proc getBanErrorMsg(banValue: string; rank: Rank): string = + if banValue.len > 0: return "You have been banned: " & banValue + case rank + of Spammer: return "You are a spammer." + of Troll: return "You are a troll." + of Inactive: return "Your account has been deactivated." + of EmailUnconfirmed: + return "You need to confirm your email first." + of Moderated: + return "Your posts await moderation." + of User, Moderator, Admin: + return "" proc checkLoggedIn(c: var TForumData) = if not c.req.cookies.hasKey("sid"): return @@ -407,14 +410,13 @@ proc checkLoggedIn(c: var TForumData) = c.req.ip, pass) let row = getRow(db, - sql"select name, email, admin, ban from person where id = ?", c.userid) + sql"select name, email, status, ban from person where id = ?", c.userid) c.username = ||row[0] c.email = ||row[1] - c.isAdmin = parseBool(||row[2]) - # Check ban status. - let banErrorMsg = getBanErrorMsg(||row[3]) - if banErrorMsg.len > 0: - discard c.setError("name", banErrorMsg) + c.rank = parseEnum[Rank](||row[2]) + let ban = getBanErrorMsg(||row[3], c.rank) + if ban.len > 0: + discard c.setError("name", ban) logout(c) return @@ -494,7 +496,7 @@ template checkLogin(c: untyped) = if not loggedIn(c): return setError(c, "", "User is not logged in") template checkOwnership(c, postId: untyped) = - if not c.isAdmin: + if c.rank < Moderator: let x = getValue(db, sql"select author from post where id = ?", postId) if x != c.userId: @@ -617,6 +619,13 @@ proc rateLimitCheck(c: var TForumData): bool = proc makeThreadURL(c: var TForumData): string = c.req.makeUri("/t/" & $c.threadId) +template postChecks() {.dirty.} = + if spamCheck(c, subject, content): + echo("[WARNING] Found spam: ", subject) + return true + if rateLimitCheck(c): + return setError(c, "subject", "You're posting too fast.") + proc reply(c: var TForumData): bool = # reply to an existing thread checkLogin(c) @@ -624,17 +633,15 @@ proc reply(c: var TForumData): bool = if c.isPreview: setPreviewData(c) else: - if spamCheck(c, subject, content): - echo("[WARNING] Found spam: ", subject) - return true - if rateLimitCheck(c): - return setError(c, "subject", "You're posting too fast.") + postChecks() writeToDb(c, crCreate, true) exec(db, sql"update thread set modified = DATETIME('now') where id = ?", $c.threadId) - asyncCheck sendMailToMailingList(c.config, c.username, c.email, - subject, content, threadId=c.threadId, postId=c.postID, is_reply=true, threadUrl=c.makeThreadURL()) + if c.rank >= User: + asyncCheck sendMailToMailingList(c.config, c.username, c.email, + subject, content, threadId=c.threadId, postId=c.postID, is_reply=true, + threadUrl=c.makeThreadURL()) result = true proc newThread(c: var TForumData): bool = @@ -646,11 +653,7 @@ proc newThread(c: var TForumData): bool = setPreviewData(c) c.threadID = transientThread else: - if spamCheck(c, subject, content): - echo("[WARNING] Found spam: ", subject) - return true - if rateLimitCheck(c): - return setError(c, "subject", "You're posting too fast.") + postChecks() c.threadID = tryInsertID(db, query, c.req.params["subject"]).int if c.threadID < 0: return setError(c, "subject", "Subject already exists") discard tryExec(db, crud(crCreate, "thread_fts", "id", "name"), @@ -658,26 +661,29 @@ proc newThread(c: var TForumData): bool = writeToDb(c, crCreate, false) discard tryExec(db, sql"insert into post_fts(post_fts) values('optimize')") discard tryExec(db, sql"insert into post_fts(thread_fts) values('optimize')") - asyncCheck sendMailToMailingList(c.config, c.username, c.email, - subject, content, threadId=c.threadID, postId=c.postID, is_reply=false, threadUrl=c.makeThreadURL()) + if c.rank >= User: + asyncCheck sendMailToMailingList(c.config, c.username, c.email, + subject, content, threadId=c.threadID, postId=c.postID, is_reply=false, + threadUrl=c.makeThreadURL()) result = true proc login(c: var TForumData, name, pass: string): bool = # get form data: const query = - sql"select id, name, password, email, salt, admin, ban from person where name = ?" + sql"select id, name, password, email, salt, status, ban from person where name = ?" if name.len == 0: return c.setError("name", "Username cannot be nil.") var success = false for row in fastRows(db, query, name): if row[2] == makePassword(pass, row[4], row[2]): - if row[6].len > 0: - return c.setError("name", getBanErrorMsg(row[6])) + c.rank = parseEnum[Rank](row[5]) + let ban = getBanErrorMsg(row[6], c.rank) + if ban.len > 0: + return c.setError("name", ban) c.userid = row[0] c.username = row[1] c.userpass = row[2] c.email = row[3] - c.isAdmin = row[5].parseBool success = true break if success: @@ -705,6 +711,11 @@ proc setBan(c: var TForumData, nick, reason: string): bool = sql("update person set ban = ? where name = ?") return tryExec(db, query, reason, nick) +proc setStatus(c: var TForumData, nick: string, status: Rank): bool = + const query = + sql("update person set status = ? where name = ?") + return tryExec(db, query, $status, nick) + proc deleteAll(c: var TForumData, nick: string): bool = const query = sql("delete from post where author = (select id from person where name = ?)") @@ -874,7 +885,7 @@ proc gatherUserInfo(c: var TForumData, nick: string, ui: var TUserInfo): bool = if uid == "": return false result = true const totalPostsQuery = - sql"SELECT count(*) FROM post WHERE author = ?" + sql"select count(*) from post where author = ?" ui.posts = getValue(db, totalPostsQuery, uid).parseInt const totalThreadsQuery = sql("select count(*) from thread where id in (select thread from post where" & @@ -882,11 +893,13 @@ proc gatherUserInfo(c: var TForumData, nick: string, ui: var TUserInfo): bool = ui.threads = getValue(db, totalThreadsQuery, uid).parseInt const lastOnlineQuery = - sql"select strftime('%s', lastOnline) from person where id = ?" - let lastOnlineDBVal = getValue(db, lastOnlineQuery, uid) - ui.lastOnline = if lastOnlineDBVal != "": lastOnlineDBVal.parseInt else: -1 - ui.email = getValue(db, sql"select email from person where id = ?", uid) - ui.ban = getValue(db, sql"select ban from person where id = ?", uid) + sql"""select strftime('%s', lastOnline), email, ban, status + from person where id = ?""" + let row = db.getRow lastOnlineQuery + ui.lastOnline = if row[0].len > 0: row[0].parseInt else: -1 + ui.email = row[1] + ui.ban = row[2] + ui.rank = parseEnum[Rank](row[3]) proc genSetUserStatusUrl(c: var TForumData, nick: string, typ: string): string = c.req.makeUri("/setUserStatus?nick=$1&type=$2" % [nick, typ]) @@ -930,21 +943,12 @@ proc genProfile(c: var TForumData, ui: TUserInfo): string = ), tr( th("Status"), - td(case ui.ban - of banReasonDeactivated: - "Deactivated" - of banReasonEmailUnconfirmed: - "Awaiting email confirmation" - of "": - "Active" - else: - "Banned: " & ui.ban - ) + td($ui.rank) ), tr( th(""), - td(if c.isAdmin and ui.ban != banReasonDeactivated: - if ui.ban == "": + td(if c.rank >= Moderator and c.rank > ui.rank: + if ui.rank >= EmailUnconfirmed: htmlgen.a( href=c.genSetUserStatusUrl(ui.nick, "ban"), "Ban user") @@ -955,22 +959,22 @@ proc genProfile(c: var TForumData, ui: TUserInfo): string = ), tr( th(""), - td(if c.userName == ui.nick or c.isAdmin: - if ui.ban == "": - htmlgen.a(href=c.genSetUserStatusUrl(ui.nick, "deactivate"), - "Deactivate user") - elif ui.ban == banReasonDeactivated: - htmlgen.a(href=c.genSetUserStatusUrl(ui.nick, "activate"), - "Activate user") - elif ui.ban == banReasonEmailUnconfirmed: + td(if c.rank >= Moderator and c.rank > ui.rank: + if ui.rank == EmailUnconfirmed: htmlgen.a(href=c.genSetUserStatusUrl(ui.nick, "activate"), "Confirm user's email") + elif ui.rank > Moderated: + htmlgen.a(href=c.genSetUserStatusUrl(ui.nick, "deactivate"), + "Deactivate user") + elif ui.rank <= Moderated: + htmlgen.a(href=c.genSetUserStatusUrl(ui.nick, "activate"), + "Activate user") else: "" else: "") ), tr( th(""), - td(if c.isAdmin: + td(if c.rank >= Moderator: htmlgen.a(href=c.req.makeUri("/deleteAll?nick=$1" % ui.nick), "Delete all user's posts and threads") else: "") @@ -1197,9 +1201,7 @@ routes: "?") del = true of "deactivate": - formBody.add "" & - "" + formBody.add "" content = htmlgen.p("Are you sure you wish to deactivate ", htmlgen.b(@"nick"), "?") @@ -1218,10 +1220,11 @@ routes: post "/dosetban": createTFD() cond(@"nick" != "") - if not c.isAdmin and @"nick" != c.userName: + if c.rank < Moderator: resp genMain(c, "You cannot ban this user.", "Error - Nim Forum") if @"reason" == "" and @"del" != "true": resp genMain(c, "Invalid ban reason.", "Error - Nim Forum") + let result = if @"del" == "true": # Remove the ban. @@ -1251,7 +1254,7 @@ routes: post "/dodeleteall/?": createTFD() cond(@"nick" != "") - if not c.isAdmin: + if c.rank < Moderator: resp genMain(c, "You cannot delete this user's data.", "Error - Nim Forum") let result = deleteAll(c, @"nick") if result: @@ -1264,7 +1267,7 @@ routes: createTFD() cond(@"nick" != "") cond(@"pass" != "") - if not c.isAdmin: + if c.rank < Moderator: resp genMain(c, "You cannot change this user's pass.", "Error - Nim Forum") let res = setPassword(c, @"nick", @"pass") if res: @@ -1281,9 +1284,9 @@ routes: cond(parseBiggestInt(@"epoch", epoch) > 0) var success = false if verifyIdentHash(c, @"nick", $epoch, @"ident"): - let ban = db.getValue(sql"select ban from person where name = ?", @"nick") - if ban == banReasonEmailUnconfirmed: - success = setBan(c, @"nick", "") + let ban = parseEnum[Rank](db.getValue(sql"select status from person where name = ?", @"nick")) + if ban == EmailUnconfirmed: + success = setStatus(c, @"nick", Moderated) if success: resp genMain(c, "Account activated", "Nim Forum") @@ -1361,11 +1364,11 @@ routes: for i in 0 .. q.len-1: if q[i].int < 32: q[i] = ' ' elif q[i] == '\'': q[i] = '"' - c.search = q.replace("\"","""); + c.search = q.replace("\"",""") if @"page".len > 0: parseInt(@"page", c.pageNum, 0..1000_000) cond(c.pageNum > 0) - iterator searchResults(): db_sqlite.TRow {.closure, tags: [FReadDB].} = + iterator searchResults(): db_sqlite.Row {.closure, tags: [ReadDbEffect].} = const queryFT = "fts.sql".slurp.sql for rowFT in fastRows(db, queryFT, [q,q,$ThreadsPerPage,$c.pageNum,$ThreadsPerPage,q, diff --git a/ranks.nim b/ranks.nim new file mode 100644 index 0000000..995fdd1 --- /dev/null +++ b/ranks.nim @@ -0,0 +1,12 @@ + +type + Rank* = enum ## serialized as 'status' + Spammer ## spammer: every post is invisible + Troll ## troll: cannot write new posts + Inactive ## member is not inactive + EmailUnconfirmed ## member with unconfirmed email address + Moderated ## new member: posts manually reviewed before everybody + ## can see them + User ## Ordinary user + Moderator ## Moderator: can ban/troll/moderate users + Admin ## Admin: can do everything diff --git a/utils.nim b/utils.nim index a8dc3e1..002ee94 100644 --- a/utils.nim +++ b/utils.nim @@ -1,7 +1,20 @@ import asyncdispatch, smtp, strutils, json, os, rst, rstgen, xmltree, strtabs, - htmlparser, streams + htmlparser, streams, parseutils from times import getTime, getGMTime, format +proc parseInt*(s: string, value: var int, validRange: Slice[int]) {. + noSideEffect.} = + ## parses `s` into an integer in the range `validRange`. If successful, + ## `value` is modified to contain the result. Otherwise no exception is + ## raised and `value` is not touched; this way a reasonable default value + ## won't be overwritten. + var x = value + try: + discard parseutils.parseInt(s, x, 0) + except OverflowError: + discard + if x in validRange: value = x + type Config* = object smtpAddress: string