diff --git a/forms.tmpl b/forms.tmpl
index cd0133d..6f536b7 100644
--- a/forms.tmpl
+++ b/forms.tmpl
@@ -10,7 +10,7 @@
# const threadId = 0
# const name = 1
# const views = 2
-#
+#
# result = ""
# count = 0
@@ -46,7 +46,7 @@
#let users = getAllRows(db,
- # sql("select distinct name, email from person where id in " &
+ # sql("select distinct name, email from person where id in " &
# "(select author from post where thread = ?)"), %threadId)
@@ -55,9 +55,9 @@
#end for
-
+
#let latestReplyAuthor = getValue(db, sql("select name from person where id = " &
- # "(select author from post where id = " &
+ # "(select author from post where id = " &
# "(select max(id) from post where thread = ?))"), %threadId)
#let replyProfileUrl = c.req.makeUri("profile/", false) &
@@ -112,7 +112,7 @@
#
#
#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,
+# 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 ?, ?"""
# const postId = 0
# const userName = 1
@@ -204,7 +204,7 @@
-
+
Syntax Cheatsheet
@@ -367,3 +367,33 @@
# end if
#end proc
#
+#
+#proc genFormResetPassword(c: var TForumData): string =
+# result = ""
+
+
+#end proc
diff --git a/forum.nim b/forum.nim
index 3eff43d..0801d8b 100644
--- a/forum.nim
+++ b/forum.nim
@@ -8,12 +8,13 @@
import
os, strutils, times, md5, strtabs, cgi, math, db_sqlite, matchers,
- rst, rstgen, captchas, scgi, jester, asyncdispatch, asyncnet, cache, sequtils
+ rst, rstgen, captchas, scgi, jester, asyncdispatch, asyncnet, cache, sequtils,
+ parseutils, utils
when not defined(windows):
import bcrypt # TODO
-from htmlgen import tr, th, td, span
+from htmlgen import tr, th, td, span, input
const
unselectedThread = -1
@@ -25,6 +26,7 @@ const
noPageNums = ["/login", "/register", "/dologin", "/doregister", "/profile"]
noHomeBtn = ["/", "/login", "/register", "/dologin", "/doregister", "/profile"]
banReasonDeactivated = "DEACTIVATED"
+ banReasonEmailUnconfirmed = "EMAILCONFIRMATION"
type
TCrud = enum crCreate, crRead, crUpdate, crDelete
@@ -50,6 +52,7 @@ type
totalPosts: int
search: string
noPagenumumNav: bool
+ config: Config
TStyledButton = tuple[text: string, link: string]
@@ -72,6 +75,7 @@ var
db: TDbConn
docConfig: StringTableRef
isFTSAvailable: bool
+ config: Config
proc init(c: var TForumData) =
c.userPass = ""
@@ -246,6 +250,20 @@ proc makePassword(password, salt: string, comparingTo = ""): string =
let bcryptSalt = if comparingTo != "": comparingTo else: genSalt(8)
result = hash(getMD5(salt & getMD5(password)), bcryptSalt)
+proc makeIdentHash(user, password, epoch, secret: string,
+ comparingTo = ""): string =
+ ## Creates a hash verifying the identity of a user. Used for password reset
+ ## links and email activation links.
+ ## If ``epoch`` is smaller than the epoch of the user's last login then
+ ## the link is invalid.
+ ## The ``secret`` is the 'salt' field in the ``person`` table.
+ echo(user, password, epoch, secret)
+ when defined(windows):
+ result = getMD5(user & password & epoch & secret)
+ else:
+ let bcryptSalt = if comparingTo != "": comparingTo else: genSalt(8)
+ result = hash(user & password & epoch & secret, bcryptSalt)
+
# -----------------------------------------------------------------------------
template `||`(x: expr): expr = (if not isNil(x): x else: "")
@@ -274,7 +292,14 @@ proc setError(c: var TForumData, field, msg: string): bool {.inline.} =
c.errorMsg = "Error: " & msg
return false
-proc register(c: var TForumData, name, pass, antibot, email: string): bool =
+proc isCaptchaCorrect(c: var TForumData, antibot: string): bool =
+ ## Determines whether the user typed in the captcha correctly.
+ let correctRes = getValue(db,
+ sql"select answer from antibot where ip = ?", c.req.ip)
+ return antibot == correctRes
+
+proc register(c: var TForumData, name, pass, antibot,
+ email: string): bool =
# Username validation:
if name.len == 0 or not allCharsInSet(name, SecureChars):
return setError(c, "name", "Invalid username!")
@@ -285,25 +310,68 @@ proc register(c: var TForumData, name, pass, antibot, email: string): bool =
if pass.len < 4:
return setError(c, "new_password", "Invalid password!")
- # antibot validation:
- let correctRes = getValue(db,
- sql"select answer from antibot where ip = ?", c.req.ip)
- if antibot != correctRes:
- return setError(c, "antibot", "You seem to be a bot!")
+ # captcha validation:
+ if not isCaptchaCorrect(c, antibot):
+ return setError(c, "antibot", "Answer to captcha incorrect!")
# email validation
- if not validEmailAddress(email):
+ if not ('@' in email and '.' in email):
return setError(c, "email", "Invalid email address")
# perform registration:
var salt = makeSalt()
+ let password = makePassword(pass, salt)
+
+ # Send activation email.
+ let epoch = $int(epochTime())
+ let activateUrl = c.req.makeUri("/activateEmail?nick=$1&epoch=$2&ident=$3" %
+ [encodeUrl(name), encodeUrl(epoch),
+ encodeUrl(makeIdentHash(name, password, epoch, salt))])
+
+ let emailSentFut = sendEmailActivation(c.config, email, name, activateUrl)
+ # Block until we send the email.
+ # TODO: This is a workaround for 'var T' not being usable in async procs.
+ while not emailSentFut.finished:
+ poll()
+ if emailSentFut.failed:
+ echo("[WARNING] Couldn't send activation email: ", emailSentFut.error.msg)
+ return setError(c, "email", "Couldn't send activation email")
+
+ # add account to person table
exec(db,
sql("INSERT INTO person(name, password, email, salt, status, lastOnline, " &
- "ban) VALUES (?, ?, ?, ?, 'user', DATETIME('now'), '')"), name,
- makePassword(pass, salt), email, salt)
- # return setError(c, "", "Could not create your account!")
+ "ban) VALUES (?, ?, ?, ?, 'user', DATETIME('now'), ?)"), name,
+ password, email, salt,
+ banReasonEmailUnconfirmed)
+
return true
+proc resetPassword(c: var TForumData, nick, antibot: string): bool =
+ # Validate captcha
+ if not isCaptchaCorrect(c, antibot):
+ return setError(c, "antibot", "Answer to captcha incorrect!")
+ # Gather some extra information to determine ident hash.
+ let epoch = $int(epochTime())
+ let row = db.getRow(
+ sql"select password, salt, email from person where name = ?", nick)
+ if row[0] == "":
+ return setError(c, "nick", "Nickname not found")
+ # Generate URL for the email.
+ # TODO: Get rid of the stupid `%` in main.tmpl as it screws up strutils.%
+ let resetUrl = c.req.makeUri(
+ strutils.`%`("/emailResetPassword?nick=$1&epoch=$2&ident=$3",
+ [encodeUrl(nick), encodeUrl(epoch),
+ encodeUrl(makeIdentHash(nick, row[0], epoch, row[1]))]))
+ echo "User's reset URL is: ", resetUrl
+ # Send the email.
+ let emailSentFut = sendPassReset(c.config, row[2], nick, resetUrl)
+ # TODO: This is a workaround for 'var T' not being usable in async procs.
+ while not emailSentFut.finished:
+ poll()
+ if emailSentFut.failed:
+ echo("[WARNING] Couldn't send activation email: ", emailSentFut.error.msg)
+ return setError(c, "email", "Couldn't send activation email")
+
proc checkLoggedIn(c: var TForumData) =
let pass = c.req.cookies["sid"]
if pass.len == 0: return
@@ -519,6 +587,15 @@ proc login(c: var TForumData, name, pass: string): bool =
else:
return c.setError("password", "Login failed!")
+proc verifyIdentHash(c: var TForumData, name, epoch, ident: string): bool =
+ const query =
+ sql"select password, salt, strftime('%s', lastOnline) from person where name = ?"
+ var row = getRow(db, query, name)
+ if row[0] == "": return false
+ let newIdent = makeIdentHash(name, row[0], epoch, row[1], ident)
+ if row[2].parseInt > epoch.parseInt: return false
+ result = newIdent == ident
+
proc setBan(c: var TForumData, nick, reason: string): bool =
const query =
sql("update person set ban = ? where name = ?")
@@ -762,6 +839,8 @@ proc genProfile(c: var TForumData, ui: TUserInfo): string =
td(case ui.ban
of banReasonDeactivated:
"Deactivated"
+ of banReasonEmailUnconfirmed:
+ "Awaiting email confirmation"
of "":
"Active"
else:
@@ -789,6 +868,9 @@ proc genProfile(c: var TForumData, ui: TUserInfo): string =
elif ui.ban == banReasonDeactivated:
htmlgen.a(href=c.genSetUserStatusUrl(ui.nick, "activate"),
"Activate user")
+ elif ui.ban == banReasonEmailUnconfirmed:
+ htmlgen.a(href=c.genSetUserStatusUrl(ui.nick, "activate"),
+ "Confirm user's email")
else: ""
else: "")
)
@@ -814,6 +896,7 @@ template createTFD(): stmt =
c.startTime = epochTime()
c.isThreadsList = false
c.pageNum = 1
+ c.config = config
if request.cookies.len > 0:
checkLoggedIn(c)
@@ -950,8 +1033,9 @@ routes:
post "/doregister":
createTFD()
if c.register(@"name", @"new_password", @"antibot", @"email"):
- discard c.login(@"name", @"new_password")
- finishLogin()
+ resp genMain(c, "You are now registered. You must now confirm your" &
+ " email address by clicking the link sent to " & @"email",
+ "Registration successful - Nim Forum")
else:
resp c.genMain(genFormRegister(c))
@@ -1060,6 +1144,80 @@ routes:
else:
resp genMain(c, "Failure", "Nim Forum")
+ get "/activateEmail/?":
+ createTFD()
+ cond (@"nick" != "")
+ cond (@"epoch" != "")
+ cond (@"ident" != "")
+ var epoch: BiggestInt = 0
+ 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", "")
+
+ if success:
+ resp genMain(c, "Account activated", "Nim Forum")
+ else:
+ resp genMain(c, "Account activation failed", "Nim Forum")
+
+ get "/emailResetPassword/?":
+ createTFD()
+ cond (@"nick" != "")
+ cond (@"epoch" != "")
+ cond (@"ident" != "")
+ var epoch: BiggestInt = 0
+ cond(parseBiggestInt(@"epoch", epoch) > 0)
+ if verifyIdentHash(c, @"nick", $epoch, @"ident"):
+ let formBody = input(`type`="hidden", name="nick", value = @"nick") &
+ input(`type`="hidden", name="epoch", value = @"epoch") &
+ input(`type`="hidden", name="ident", value = @"ident") &
+ input(`type`="password", name="password") &
+ " " &
+ input(`type`="submit", name="submitBtn",
+ value="Change my password")
+ let message = htmlgen.p("Please enter a new password for ",
+ htmlgen.b(@"nick"), ':')
+ let content = htmlgen.form(action=c.req.makeUri("/doemailresetpassword"),
+ `method`="POST", message & formBody)
+
+ resp genMain(c, content, "Reset password - Nim Forum")
+ else:
+ resp genMain(c, "Invalid ident hash", "Error - Nim Forum")
+
+ post "/doemailresetpassword":
+ createTFD()
+ cond (@"nick" != "")
+ cond (@"epoch" != "")
+ cond (@"ident" != "")
+ cond (@"password" != "")
+ var epoch: BiggestInt = 0
+ cond(parseBiggestInt(@"epoch", epoch) > 0)
+ if verifyIdentHash(c, @"nick", $epoch, @"ident"):
+ let res = setPassword(c, @"nick", @"password")
+ if res:
+ resp genMain(c, "Password reset successfully!", "Nim Forum")
+ else:
+ resp genMain(c, "Password reset failure", "Nim Forum")
+ else:
+ resp genMain(c, "Invalid ident hash", "Nim Forum")
+
+ get "/resetPassword/?":
+ createTFD()
+
+ resp genMain(c, genFormResetPassword(c), "Reset Password - Nim Forum")
+
+ post "/doresetpassword":
+ createTFD()
+ echo(request.params)
+ cond (@"nick" != "")
+
+ if resetPassword(c, @"nick", @"antibot"):
+ resp genMain(c, "Email sent!", "Reset Password - Nim Forum")
+ else:
+ resp genMain(c, genFormResetPassword(c), "Reset Password - Nim Forum")
+
const licenseRst = slurp("static/license.rst")
get "/license":
createTFD()
@@ -1117,6 +1275,7 @@ when isMainModule:
database="nimforum")
isFTSAvailable = db.getAllRows(sql("SELECT name FROM sqlite_master WHERE " &
"type='table' AND name='post_fts'")).len == 1
+ config = loadConfig()
var http = true
if paramCount() > 0:
if paramStr(1) == "scgi":
diff --git a/main.tmpl b/main.tmpl
index 03b28c0..b28af9a 100644
--- a/main.tmpl
+++ b/main.tmpl
@@ -117,6 +117,7 @@
+ Reset password
#if c.errorMsg != "" and c.req.pathInfo.normalizeUri == "/dologin":
$c.errorMsg
#end if
diff --git a/public/css/style.css b/public/css/style.css
index 9d7d0f2..15100e6 100644
--- a/public/css/style.css
+++ b/public/css/style.css
@@ -516,6 +516,20 @@ div#sidebar .content .search
width: 95%;
}
+div#sidebar .content a#passreset {
+ color: #CEDAE9;
+ font-size: 9pt;
+ display: block;
+ text-decoration: none;
+ margin-top: -4pt;
+}
+
+div#sidebar .content a#passreset:hover {
+ color: #fff;
+}
+
+
+
span.error
{
float: left;