Merge branch 'new_async'

Conflicts:
	forum.nim
	main.tmpl
This commit is contained in:
Dominik Picheta 2015-02-14 14:37:47 +00:00
commit 6da5f369db
39 changed files with 1769 additions and 1116 deletions

1
.gitignore vendored
View file

@ -1,6 +1,7 @@
# Wildcard patterns. # Wildcard patterns.
*.swp *.swp
nimcache/ nimcache/
*.db
# Specific paths # Specific paths
/createdb /createdb

32
cache.nim Normal file
View file

@ -0,0 +1,32 @@
import tables, uri
type
CacheInfo = object
valid: bool
value: string
CacheHolder = ref object
caches: Table[string, CacheInfo]
proc normalizePath(x: string): string =
let u = parseUri(x)
result = u.path & (if u.query != "": '?' & u.query else: "")
proc newCacheHolder*(): CacheHolder =
new result
result.caches = initTable[string, CacheInfo]()
proc invalidate*(cache: CacheHolder, name: string) =
cache.caches.mget(name.normalizePath()).valid = false
proc invalidateAll*(cache: CacheHolder) =
for key, val in mpairs(cache.caches):
val.valid = false
template get*(cache: CacheHolder, name: string, grabValue: expr): expr =
## Check to see if the cache contains value for ``name``. If it does and the
## cache is valid then doesn't recalculate it but returns the cached version.
mixin normalizePath
let nName = name.normalizePath()
if not (cache.caches.hasKey(nName) and cache.caches[nName].valid):
cache.caches[nName] = CacheInfo(valid: true, value: grabValue)
cache.caches[nName].value

View file

@ -11,7 +11,7 @@ import cairo, os, strutils, jester
proc getCaptchaFilename*(i: int): string {.inline.} = proc getCaptchaFilename*(i: int): string {.inline.} =
result = "public/captchas/capture_" & $i & ".png" result = "public/captchas/capture_" & $i & ".png"
proc getCaptchaUrl*(req: var TRequest, i: int): string = proc getCaptchaUrl*(req: PRequest, i: int): string =
result = req.makeUri("/captchas/capture_" & $i & ".png", absolute = false) result = req.makeUri("/captchas/capture_" & $i & ".png", absolute = false)
proc createCaptcha*(file, text: string) = proc createCaptcha*(file, text: string) =

View file

@ -8,7 +8,7 @@
import strutils, db_sqlite import strutils, db_sqlite
var db = Open(connection="nimforum.db", user="postgres", password="", var db = open(connection="nimforum.db", user="postgres", password="",
database="nimforum") database="nimforum")
const const
@ -16,7 +16,7 @@ const
TPassword = "varchar(32)" TPassword = "varchar(32)"
TEmail = "varchar(30)" TEmail = "varchar(30)"
db.Exec(sql""" db.exec(sql"""
create table if not exists thread( create table if not exists thread(
id integer primary key, id integer primary key,
name varchar(100) not null, name varchar(100) not null,
@ -24,11 +24,11 @@ create table if not exists thread(
modified timestamp not null default (DATETIME('now')) modified timestamp not null default (DATETIME('now'))
);""", []) );""", [])
db.Exec(sql""" db.exec(sql"""
create unique index if not exists ThreadNameIx on thread (name); create unique index if not exists ThreadNameIx on thread (name);
""", []) """, [])
db.Exec(sql(""" db.exec(sql("""
create table if not exists person( create table if not exists person(
id integer primary key, id integer primary key,
name $# not null, name $# not null,
@ -42,14 +42,19 @@ create table if not exists person(
);""" % [TUserName, TPassword, TEmail]), []) );""" % [TUserName, TPassword, TEmail]), [])
# echo "person table already exists" # echo "person table already exists"
db.Exec(sql""" db.exec(sql("""
alter table person
add ban varchar(128) not null default ''
"""))
db.exec(sql"""
create unique index if not exists UserNameIx on person (name); create unique index if not exists UserNameIx on person (name);
""", []) """, [])
# ----------------------- Forum ------------------------------------------------ # ----------------------- Forum ------------------------------------------------
if not db.TryExec(sql""" if not db.tryExec(sql"""
create table if not exists post( create table if not exists post(
id integer primary key, id integer primary key,
author integer not null, author integer not null,
@ -66,7 +71,7 @@ create table if not exists post(
# -------------------- Session ------------------------------------------------- # -------------------- Session -------------------------------------------------
if not db.TryExec(sql(""" if not db.tryExec(sql("""
create table if not exists session( create table if not exists session(
id integer primary key, id integer primary key,
ip inet not null, ip inet not null,
@ -77,7 +82,7 @@ create table if not exists session(
);""" % [TPassword]), []): );""" % [TPassword]), []):
echo "session table already exists" echo "session table already exists"
if not db.TryExec(sql""" if not db.tryExec(sql"""
create table if not exists antibot( create table if not exists antibot(
id integer primary key, id integer primary key,
ip inet not null, ip inet not null,
@ -86,8 +91,35 @@ create table if not exists antibot(
);""", []): );""", []):
echo "antibot table already exists" echo "antibot table already exists"
# -------------------- Search --------------------------------------------------
if not db.tryExec(sql"""
CREATE VIRTUAL TABLE thread_fts USING fts4 (
id INTEGER PRIMARY KEY,
name VARCHAR(100) NOT NULL
);""", []):
echo "thread_fts table already exists or fts4 not supported"
else:
db.exec(sql"""
INSERT INTO thread_fts
SELECT id, name FROM thread;
""", [])
if not db.tryExec(sql"""
CREATE VIRTUAL TABLE post_fts USING fts4 (
id INTEGER PRIMARY KEY,
header VARCHAR(100) NOT NULL,
content VARCHAR(1000) NOT NULL
);""", []):
echo "post_fts table already exists or fts4 not supported"
else:
db.exec(sql"""
INSERT INTO post_fts
SELECT id, header, content FROM post;
""", [])
# ------------------------------------------------------------------------------
#discard stdin.readline() #discard stdin.readline()
Close(db) close(db)

View file

@ -13,43 +13,74 @@
# #
# result = "" # result = ""
# count = 0 # count = 0
<table id="threads"> <div id="talk-heads">
<tr> <div class="topic">
<th>Topics</th> <div>
<th>Author</th> Topic
<th>Posts</th> <a href="${c.req.makeUri("/threadActivity.xml")}">
<th>Views</th> <img src="/images/Feed-icon.svg" class="rssfeed">
<th>Last reply</th> </a>
</tr> </div>
# for row in Rows(db, query, $((c.pageNum-1) * ThreadsPerPage), $ThreadsPerPage): </div>
<div class="users"><div>Users</div></div>
<div class="detail"><div>Details</div></div>
<div class="activity">
<div>
Activity
<a href="${c.req.makeUri("/postActivity.xml")}">
<img src="/images/Feed-icon.svg" class="rssfeed">
</a>
</div>
</div>
</div>
<div id="talk-threads">
# for row in rows(db, query, $((c.pageNum-1) * ThreadsPerPage), $ThreadsPerPage):
# inc(count) # inc(count)
<tr> <div>
<td class="topic"> <div class="topic">
${UrlButton(c, XMLencode(%name), c.genThreadUrl(threadid = %threadid))} <div>
<a href="${c.genThreadUrl(threadid = %threadid)}"
title="${xmlEncode(%name)}">${xmlEncode(%name)}</a>
${genPagenumLocalNav(c, (%threadid).parseInt)} ${genPagenumLocalNav(c, (%threadid).parseInt)}
</td> </div>
#let authorName = getValue(db, sql("select name from person where id = " & </div>
# "(select author from post where id = " &
# "(select min(id) from post where thread = ?))"), %threadId) #let users = getAllRows(db,
#let profileUrl = c.req.makeUri("profile/", false) & XMLEncode(authorName) # sql("select distinct name, email from person where id in " &
<td class="author"><a href="$profileUrl">${authorName}</a></td> # "(select author from post where thread = ?)"), %threadId)
# let posts = GetValue(db, sql"select count(*) from post where thread = ?", %threadId) <div class="users">
<td class="posts">$posts</td> <div>
<td class="views">${XMLencode(%views)}</td> #for i in 0 .. min(6, users.len-1):
<img src="${getGravatarUrl(users[i][1], 20)}" title="${users[i][0]}">
#end for
</div>
</div>
#let latestReplyAuthor = getValue(db, sql("select name from person where id = " & #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) # "(select max(id) from post where thread = ?))"), %threadId)
#let replyProfileUrl = c.req.makeUri("profile/", false) &
# xmlEncode(latestReplyAuthor)
# let posts = getValue(db, sql"select count(*) from post where thread = ?", %threadId)
<div class="detail">
<div><div title="Views">${xmlEncode(%views)}</div></div>
<div><div title="Posts">$posts</div></div>
</div>
#let latestReplyDate = getValue(db, sql("SELECT strftime('%s', " & #let latestReplyDate = getValue(db, sql("SELECT strftime('%s', " &
# "(select creation from post where id = (select max(id) from post where thread = ?)))"), %threadId) # "(select creation from post where id = (select max(id) from post where thread = ?)))"), %threadId)
<td class="lastreply"> #let timeStr = formatTimestamp(latestReplyDate.parseInt())
<span>${formatTimestamp(latestReplyDate.parseInt())}</span><br/> <div class="activity">
#let replyProfileUrl = c.req.makeUri("profile/", false) & <div>
# XMLEncode(latestReplyAuthor) <a href="$replyProfileUrl">$latestReplyAuthor</a> replied $timeStr
<span><a href="$replyProfileUrl">${latestReplyAuthor}</a></span> </div>
</td> </div>
</tr> </div>
# end for # end for
</table> </div>
#end proc #end proc
# #
# #
@ -57,26 +88,26 @@
# title, content, author, date: string): string = # title, content, author, date: string): string =
# result = "" # result = ""
<a name="preview"></a> <a name="preview"></a>
<table class="post"> <div id="talk-thread">
<tr> <div>
<th colspan="2"> <div class="author">
<span>${XMLEncode(title)}</span> <div>
<span style="float:right;">${XMLencode(date)}</span> #let profileUrl = c.req.makeUri("profile/", false) & xmlEncode(author)
</th> <a class="name" href="$profileUrl">${xmlEncode(author)}</a>
</tr> </div>
<tr> </div>
<td class="left"> <div class="topic">
<span>${XMLencode(author)}</span> <div>
</td>
<td class="content">
#try: #try:
${content.rstToHtml} ${content.rstToHtml}
#except EParseError: #except EParseError:
# c.errorMsg = getCurrentExceptionMsg() # c.errorMsg = getCurrentExceptionMsg()
#end #end
</td> <span class="date">${xmlEncode(date)}</span>
</tr> </div>
</table> </div>
</div>
</div>
#end proc #end proc
# #
# #
@ -92,39 +123,47 @@
# const userEmail = 6 # const userEmail = 6
# result = "" # result = ""
# count = 0 # count = 0
# for row in FastRows(db, query, threadId, $((c.pageNum-1) * PostsPerPage), $PostsPerPage): # let posts = getAllRows(db, query, threadId, $((c.pageNum-1) * PostsPerPage), $PostsPerPage)
# if posts.len < 1: return ""
# end if
<div id="talk-head">
<div class="info-post">
<div>
<a href="${c.req.makeUri("/")}"><b>forum index</b></a> &gt;
<a href="${c.req.makeUri("/t/" & $threadId)}">${posts[0][postHeader]}</a>
</div>
</div>
</div>
<div id="talk-thread">
# for row in posts:
# inc(count) # inc(count)
<a name="${%postId}"></a> <a name="${%postId}"></a>
<table class="post"> <div id="${%postId}">
<tr> <div class="author">
<th colspan="2"> <div>
<span>${XMLencode(%postHeader)}</span> #let profileUrl = c.req.makeUri("profile/", false) & xmlEncode(%userName)
<span style="float:right;">${XMLencode(%postCreation)}</span> <div class="avatar">${genGravatar(%userEmail)}</div>
</th> <a class="name" href="$profileUrl">${xmlEncode(%userName)}</a>
</tr>
<tr>
<td class="left">
#let profileUrl = c.req.makeUri("profile/", false) & XMLencode(%userName)
<span><a href="$profileUrl">${XMLencode(%userName)}</a></span>
<hr/>
<a href="$profileUrl">${genGravatar(%userEmail)}</a>
#if c.userId == %postAuthor and c.currentPost.subject.len == 0: #if c.userId == %postAuthor and c.currentPost.subject.len == 0:
<hr/>${UrlButton(c, "Edit post", c.genThreadUrl(%postId, "edit"))} <hr/><a href="${c.genThreadUrl(%postId, "edit")}">Edit post</a>
#elif c.isAdmin and c.currentPost.subject.len == 0: #elif c.isAdmin and c.currentPost.subject.len == 0:
<hr/><span style="color:red"> <hr/><a style="color: red;" href="${c.genThreadUrl(%postId, "edit")}">Edit post</a>
${UrlButton(c, "Edit post", c.genThreadUrl(%postId, "edit"))}</span>
#end if #end if
</td> </div>
<td class="content"> </div>
<div class="topic">
<div>
#try: #try:
${(%postContent).rstToHtml} ${(%postContent).rstToHtml}
#except EParseError: #except EParseError:
# c.errorMsg = getCurrentExceptionMsg() # c.errorMsg = getCurrentExceptionMsg()
#end #end
</td> <span class="date">${xmlEncode(%postCreation)}</span>
</tr> </div>
</table> </div>
</div>
# end for # end for
</div>
#end proc #end proc
# #
# #
@ -134,25 +173,39 @@
<br /> <br />
<a name="reply"></a> <a name="reply"></a>
<div id="replywrapper"> <div id="replywrapper">
<div id="replytop"> <div id="talk-head">
<span>${topText}</span> <div class="info-post">
<div>
<a href="${c.req.makeUri("/")}"><b>forum index</b></a> &gt;
$topText
</div>
</div>
</div> </div>
<form action="${c.req.makeUri(action, false) & "#preview"}" method="POST"> <form action="${c.req.makeUri(action, false) & "#preview"}" method="POST">
#if action == "doreply":
${HiddenField(c, "subject", title)}
#else:
${FieldValid(c, "subject", "Subject:")} ${FieldValid(c, "subject", "Subject:")}
${TextWidget(c, "subject", title, maxlength=100)} ${TextWidget(c, "subject", title, maxlength=100)}
<br /> <br />
#end if
${FieldValid(c, "content", "Content:")}<br /> ${FieldValid(c, "content", "Content:")}<br />
${TextAreaWidget(c, "content", content, width=100, height=20)}<br /> ${TextAreaWidget(c, "content", content)}<br />
${FormSession(c, action)} ${FormSession(c, action)}
# if isEdit: # if isEdit:
<input type="checkbox" name="delete" value="Delete">Delete Post<br /> <input type="checkbox" name="delete" value="Delete">Delete Post<br />
# end if
#if c.errorMsg != "":
<div style="float: left; width: 100%;">
<span class="error">$c.errorMsg</span>
</div>
#end if #end if
<br/> <br/>
<input type="submit" name="previewBtn" value="Preview" /> <input type="submit" name="previewBtn" value="Preview" />
<input type="submit" name="postBtn" value="Submit" /> <input type="submit" name="postBtn" value="Submit" />
<a href="http://nimrod-lang.org/rst.html">Syntax Cheatsheet</a> <a href="http://nim-lang.org/rst.html">Syntax Cheatsheet</a>
</form> </form>
</div> </div>
#end proc #end proc
@ -160,8 +213,15 @@
# #
#proc genFormRegister(c: var TForumData): string = #proc genFormRegister(c: var TForumData): string =
# result = "" # result = ""
<div id="talk-head">
<div class="info-post">
<div>
<a href="${c.req.makeUri("/")}"><b>forum index</b></a> &gt;
Register
</div>
</div>
</div>
<form action="${c.req.makeUri("/doregister", false)}" method="POST"> <form action="${c.req.makeUri("/doregister", false)}" method="POST">
<b>Register</b><br />
<table border="0"> <table border="0">
<tr> <tr>
<td>${FieldValid(c, "name", "Username:")}</td> <td>${FieldValid(c, "name", "Username:")}</td>
@ -180,6 +240,11 @@
<td>${TextWidget(c, "antibot", "", maxlength=4)}</td> <td>${TextWidget(c, "antibot", "", maxlength=4)}</td>
</tr> </tr>
</table> </table>
#if c.errorMsg != "":
<div style="float: left; width: 100%;">
<span class="error">$c.errorMsg</span>
</div>
#end if
<input type="submit" value="Register"> <input type="submit" value="Register">
</form> </form>
#end proc #end proc
@ -205,38 +270,100 @@
# #
#proc genListOnline(c: var TForumData, stats: TForumStats): string = #proc genListOnline(c: var TForumData, stats: TForumStats): string =
# result = "" # result = ""
<div id="whoisonline"> # var active: seq[string] = @[]
<div class="wioHeader"> # for i in stats.activeUsers:
<span>Who is online?<span> # active.add(i.nick)
</div>
<div class="content">
<span>Out of ${stats.totalUsers} users ${stats.activeUsers.len} are online${if stats.activeUsers.len == 0: "." else: ":"}
#for index, usr in stats.activeUsers:
# let profileHref = """<a href="""" & c.req.makeUri("profile/", false) &
# XMLencode(usr.nick) & """">"""
# let hrefEnd = """</a>"""
# if usr.isAdmin:
#if index != 0: result.add ','
#end if
#result.add("""<span class="user admin"> """ & profileHref &
# usr.nick & hrefEnd & """</span>""")
# else:
#if index != 0: result.add ','
#end if
#result.add("""<span class="user"> """ & profileHref &
# usr.nick & hrefEnd & """</span>""")
# end if
# end for # end for
</span>
<hr/>
#if stats.newestMember.nick != "":
# let profileUrl = c.req.makeUri("profile/", false) & # let profileUrl = c.req.makeUri("profile/", false) &
# XMLEncode(stats.newestMember.nick) # xmlEncode(stats.newestMember.nick)
<span>Total threads: ${stats.totalThreads} | Total posts: ${stats.totalPosts} | Newest member: <a href="$profileUrl">${stats.newestMember.nick}</a></span> <span class="forum-user-info" title="${active.join(", ")}">
#else: <b>${stats.activeUsers.len}</b> of <b>${stats.totalUsers}</b> users online</span> &nbsp;|&nbsp;
<span>Total threads: ${stats.totalThreads} | Total posts: ${stats.totalPosts}</span> <b>${stats.totalThreads}</b> threads &nbsp;|&nbsp; <b>${stats.totalPosts}</b> posts &nbsp;|&nbsp;
newest member: <a href="$profileUrl">${stats.newestMember.nick}</a>
#end proc
#
#
#
#
#proc genSearchResults(c: var TForumData,
# results: iterator: db_sqlite.TRow {.closure, tags: [FReadDB].},
# count: var int): string =
# const threadId = 0
# const threadName = 1
# const postId = 2
# const postHeader = 3
# const postContent = 4
# const userName = 5
# const postCreation = 6
# const postAuthor = 7
# const userEmail = 8
# const what = 9
# result = ""
# count = 0
# var whCount: array[bool, int]
<div id="talk-head">
<div class="info-post">
<div>
Search results for: <i style="color: #332299">${xmlEncode(c.search.replace("&quot;","\""))}</i>.
</div>
</div>
</div>
<div id="talk-thread" class="searchResults">
# for row in results():
# inc(count)
# let isThread = %what == "0"
# inc(whCount[isThread])
# let postUrl = c.genThreadUrl(%postId,"",%threadId,"")
# let threadUrl = c.genThreadUrl("","",%threadId)
# var headersDiffer = false
<div>
<div class="author">
<div>
#let profileUrl = c.req.makeUri("profile/", false) & xmlEncode(%userName)
<div><a href="$profileUrl">${genGravatar(%userEmail, 40)}</a></div>
<div style="padding: 8px 0"><a href="$profileUrl">${xmlEncode(%userName)}</a></div>
#if c.userId == %postAuthor and c.currentPost.subject.len == 0:
<hr/><a href="${c.genThreadUrl(%postId, "edit", %threadId)}">Edit post</a>
#elif c.isAdmin and c.currentPost.subject.len == 0:
<hr/><a style="color: red;" href="${c.genThreadUrl(%postId, "edit", %threadId)}">Edit post</a>
#end if #end if
</div> </div>
</div> </div>
<div class="topic">
<div>
#if %postHeader != "":
<div class="postTitle">
<span class="titleHeader">Post:</span>
<a href="${postUrl}">
<span>${%postHeader}</span>
</a>
</div>
#end if
#if not isThread:
#try:
${(%postContent).rstToHtml}
#except EParseError:
# c.errorMsg = getCurrentExceptionMsg()
${xmlEncode(%postContent)}
#end
#end if
<span class="date">${xmlEncode(%postCreation)}</span>
</div>
</div>
</div>
# end for
</div>
# if c.pageNum > 1:
<form action="/search/${$(c.pageNum-1)}" method="post" class="searchNav">
<input type="hidden" name="q" value="${c.search}">
<input type="submit" value="Previous ${ThreadsPerPage} results">
</form>
# end if
# if whCount[true] == ThreadsPerPage or whCount[false] == ThreadsPerPage:
<form action="/search/${$(c.pageNum+1)}" method="post" class="searchNav">
<input type="hidden" name="q" value="${c.search}">
<input type="submit" value="Next ${ThreadsPerPage} results (if any)">
</form>
# end if
#end proc #end proc
#

467
forum.nim
View file

@ -1,6 +1,6 @@
# #
# #
# The Nimrod Forum # The Nim Forum
# (c) Copyright 2012 Andreas Rumpf, Dominik Picheta # (c) Copyright 2012 Andreas Rumpf, Dominik Picheta
# Look at license.txt for more info. # Look at license.txt for more info.
# All rights reserved. # All rights reserved.
@ -8,7 +8,10 @@
import import
os, strutils, times, md5, strtabs, cgi, math, db_sqlite, matchers, os, strutils, times, md5, strtabs, cgi, math, db_sqlite, matchers,
rst, rstgen, captchas, sockets, scgi, jester rst, rstgen, captchas, scgi, jester, asyncdispatch, asyncnet, cache, sequtils
when not defined(windows):
import bcrypt # TODO
from htmlgen import tr, th, td, span from htmlgen import tr, th, td, span
@ -18,13 +21,15 @@ const
ThreadsPerPage = 15 ThreadsPerPage = 15
PostsPerPage = 10 PostsPerPage = 10
MaxPagesFromCurrent = 8
noPageNums = ["/login", "/register", "/dologin", "/doregister", "/profile"] noPageNums = ["/login", "/register", "/dologin", "/doregister", "/profile"]
noHomeBtn = ["/", "/login", "/register", "/dologin", "/doregister", "/profile"] noHomeBtn = ["/", "/login", "/register", "/dologin", "/doregister", "/profile"]
banReasonDeactivated = "DEACTIVATED"
type type
TCrud = enum crCreate, crRead, crUpdate, crDelete TCrud = enum crCreate, crRead, crUpdate, crDelete
TSession = object of TObject TSession = object of RootObj
threadid: int threadid: int
postid: int postid: int
userName, userPass, email: string userName, userPass, email: string
@ -33,7 +38,7 @@ type
TPost = tuple[subject, content: string] TPost = tuple[subject, content: string]
TForumData = object of TSession TForumData = object of TSession
req: TRequest req: PRequest
userid: string userid: string
actionContent: string actionContent: string
errorMsg, loginErrorMsg: string errorMsg, loginErrorMsg: string
@ -43,6 +48,8 @@ type
isThreadsList: bool isThreadsList: bool
pageNum: int pageNum: int
totalPosts: int totalPosts: int
search: string
noPagenumumNav: bool
TStyledButton = tuple[text: string, link: string] TStyledButton = tuple[text: string, link: string]
@ -59,10 +66,12 @@ type
threads: int threads: int
lastOnline: int lastOnline: int
email: string email: string
ban: string
var var
db: TDbConn db: TDbConn
docConfig: PStringTable docConfig: StringTableRef
isFTSAvailable: bool
proc init(c: var TForumData) = proc init(c: var TForumData) =
c.userPass = "" c.userPass = ""
@ -77,6 +86,8 @@ proc init(c: var TForumData) =
c.invalidField = "" c.invalidField = ""
c.currentPost = (subject: "", content: "") c.currentPost = (subject: "", content: "")
c.search = ""
proc loggedIn(c: TForumData): bool = proc loggedIn(c: TForumData): bool =
result = c.userName.len > 0 result = c.userName.len > 0
@ -90,16 +101,20 @@ const
proc TextWidget(c: TForumData, name, defaultText: string, proc TextWidget(c: TForumData, name, defaultText: string,
maxlength = 30, size = -1): string = maxlength = 30, size = -1): string =
let x = if defaultText != reuseText: defaultText let x = if defaultText != reuseText: defaultText
else: XMLencode(c.req.params[name]) else: xmlEncode(c.req.params[name])
return """<input type="text" name="$1" maxlength="$2" value="$3" $4/>""" % [ return """<input type="text" name="$1" maxlength="$2" value="$3" $4/>""" % [
name, $maxlength, x, if size != -1: "size=\"" & $size & "\"" else: ""] name, $maxlength, x, if size != -1: "size=\"" & $size & "\"" else: ""]
proc TextAreaWidget(c: TForumData, name, defaultText: string, proc HiddenField(c: TForumData, name, defaultText: string): string =
width = 80, height = 20): string =
let x = if defaultText != reuseText: defaultText let x = if defaultText != reuseText: defaultText
else: XMLencode(c.req.params[name]) else: xmlEncode(c.req.params[name])
return """<textarea name="$1" cols="$2" rows="$3">$4</textarea>""" % [ return """<input type="hidden" name="$1" value="$2"/>""" % [name, x]
name, $width, $height, x]
proc TextAreaWidget(c: TForumData, name, defaultText: string): string =
let x = if defaultText != reuseText: defaultText
else: xmlEncode(c.req.params[name])
return """<textarea name="$1">$2</textarea>""" % [
name, x]
proc FieldValid(c: TForumData, name, text: string): string = proc FieldValid(c: TForumData, name, text: string): string =
if name == c.invalidField: if name == c.invalidField:
@ -115,6 +130,8 @@ proc genThreadUrl(c: TForumData, postId = "", action = "", threadid = "", pageNu
result.add("?action=" & action) result.add("?action=" & action)
if postId != "": if postId != "":
result.add("&postid=" & postid) result.add("&postid=" & postid)
elif postId != "":
result.add("#" & postId)
result = c.req.makeUri(result, absolute = false) result = c.req.makeUri(result, absolute = false)
proc FormSession(c: var TForumData, nextAction: string): string = proc FormSession(c: var TForumData, nextAction: string): string =
@ -144,15 +161,47 @@ proc genButtons(c: var TForumData, btns: seq[TStyledButton]): string =
result.add(("""<a class="$3active button" href="$1$4">$2</a>""") % [ result.add(("""<a class="$3active button" href="$1$4">$2</a>""") % [
btns[i].link, btns[i].text, class, anchor]) btns[i].link, btns[i].text, class, anchor])
proc toInterval(diff: int64): TimeInterval =
var remaining = diff
let years = remaining div 31536000
remaining -= years * 31536000
let months = remaining div 2592000
remaining -= months * 2592000
let days = remaining div 86400
remaining -= days * 86400
let hours = remaining div 3600
remaining -= hours * 3600
let minutes = remaining div 60
remaining -= minutes * 60
result = initInterval(0, remaining.int, minutes.int, hours.int, days.int,
months.int, years.int)
proc formatTimestamp(t: int): string = proc formatTimestamp(t: int): string =
let t2 = getGMTime(TTime(t)) let t2 = Time(t)
return t2.format("ddd',' d MMM yyyy HH':'mm 'UTC'") let now = getTime()
let diff = (now - t2).toInterval()
if diff.years > 0:
return getGMTime(t2).format("MMMM d',' yyyy")
elif diff.months > 0:
return $diff.months & (if diff.months > 1: " months ago" else: " month ago")
elif diff.days > 0:
return $diff.days & (if diff.days > 1: " days ago" else: " day ago")
elif diff.hours > 0:
return $diff.hours & (if diff.hours > 1: " hours ago" else: " hour ago")
elif diff.minutes > 0:
return $diff.minutes &
(if diff.minutes > 1: " minutes ago" else: " minute ago")
else:
return "just now"
proc getGravatarUrl(email: string, size = 80): string =
let emailMD5 = email.toLower.toMD5
return ("http://www.gravatar.com/avatar/" & $emailMD5 & "?s=" & $size &
"&d=identicon")
proc genGravatar(email: string, size: int = 80): string = proc genGravatar(email: string, size: int = 80): string =
let emailMD5 = email.toLower.toMD5 result = "<img width=\"$1\" height=\"$2\" src=\"$3\" />" %
result = "<img src=\"$1\" />" % [$size, $size, getGravatarUrl(email, size)]
("http://www.gravatar.com/avatar/" & $emailMD5 & "?s=" & $size &
"&d=identicon")
proc randomSalt(): string = proc randomSalt(): string =
result = "" result = ""
@ -176,20 +225,32 @@ proc devRandomSalt(): string =
proc makeSalt(): string = proc makeSalt(): string =
## Creates a salt using a cryptographically secure random number generator. ## Creates a salt using a cryptographically secure random number generator.
##
## Ensures that the resulting salt contains no ``\0``.
try: try:
result = devRandomSalt() result = devRandomSalt()
except EIO: except IOError:
result = randomSalt() result = randomSalt()
proc makePassword(password, salt: string): string = var newResult = ""
for i in 0 .. <result.len:
if result[i] != '\0':
newResult.add result[i]
return newResult
proc makePassword(password, salt: string, comparingTo = ""): string =
## Creates an MD5 hash by combining password and salt. ## Creates an MD5 hash by combining password and salt.
when defined(windows):
result = getMD5(salt & getMD5(password)) result = getMD5(salt & getMD5(password))
else:
let bcryptSalt = if comparingTo != "": comparingTo else: genSalt(8)
result = hash(getMD5(salt & getMD5(password)), bcryptSalt)
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
template `||`(x: expr): expr = (if not isNil(x): x else: "") template `||`(x: expr): expr = (if not isNil(x): x else: "")
proc validThreadId(c: TForumData): bool = proc validThreadId(c: TForumData): bool =
result = GetValue(db, sql"select id from thread where id = ?", result = getValue(db, sql"select id from thread where id = ?",
$c.threadId).len > 0 $c.threadId).len > 0
proc antibot(c: var TForumData): string = proc antibot(c: var TForumData): string =
@ -197,12 +258,12 @@ proc antibot(c: var TForumData): string =
let b = math.random(1000)+1 let b = math.random(1000)+1
let answer = $(a+b) let answer = $(a+b)
Exec(db, sql"delete from antibot where ip = ?", c.req.ip) exec(db, sql"delete from antibot where ip = ?", c.req.ip)
let CaptchaId = TryInsertID(db, let captchaId = tryInsertID(db,
sql"insert into antibot(ip, answer) values (?, ?)", c.req.ip, sql"insert into antibot(ip, answer) values (?, ?)", c.req.ip,
answer).int mod 10_000 answer).int mod 10_000
let CaptchaFile = getCaptchaFilename(CaptchaId) let captchaFile = getCaptchaFilename(captchaId)
createCaptcha(CaptchaFile, $a & "+" & $b) createCaptcha(captchaFile, $a & "+" & $b)
result = """<img src="$1" />""" % c.req.getCaptchaUrl(captchaId) result = """<img src="$1" />""" % c.req.getCaptchaUrl(captchaId)
const const
@ -217,7 +278,7 @@ proc register(c: var TForumData, name, pass, antibot, email: string): bool =
# Username validation: # Username validation:
if name.len == 0 or not allCharsInSet(name, SecureChars): if name.len == 0 or not allCharsInSet(name, SecureChars):
return setError(c, "name", "Invalid username!") return setError(c, "name", "Invalid username!")
if GetValue(db, sql"select name from person where name = ?", name).len > 0: if getValue(db, sql"select name from person where name = ?", name).len > 0:
return setError(c, "name", "Username already exists!") return setError(c, "name", "Username already exists!")
# Password validation: # Password validation:
@ -225,7 +286,7 @@ proc register(c: var TForumData, name, pass, antibot, email: string): bool =
return setError(c, "new_password", "Invalid password!") return setError(c, "new_password", "Invalid password!")
# antibot validation: # antibot validation:
let correctRes = GetValue(db, let correctRes = getValue(db,
sql"select answer from antibot where ip = ?", c.req.ip) sql"select answer from antibot where ip = ?", c.req.ip)
if antibot != correctRes: if antibot != correctRes:
return setError(c, "antibot", "You seem to be a bot!") return setError(c, "antibot", "You seem to be a bot!")
@ -236,8 +297,9 @@ proc register(c: var TForumData, name, pass, antibot, email: string): bool =
# perform registration: # perform registration:
var salt = makeSalt() var salt = makeSalt()
Exec(db, sql("INSERT INTO person(name, password, email, salt, status, lastOnline) " & exec(db,
"VALUES (?, ?, ?, ?, 'user', DATETIME('now'))"), name, sql("INSERT INTO person(name, password, email, salt, status, lastOnline, " &
"ban) VALUES (?, ?, ?, ?, 'user', DATETIME('now'), '')"), name,
makePassword(pass, salt), email, salt) makePassword(pass, salt), email, salt)
# return setError(c, "", "Could not create your account!") # return setError(c, "", "Could not create your account!")
return true return true
@ -245,12 +307,12 @@ proc register(c: var TForumData, name, pass, antibot, email: string): bool =
proc checkLoggedIn(c: var TForumData) = proc checkLoggedIn(c: var TForumData) =
let pass = c.req.cookies["sid"] let pass = c.req.cookies["sid"]
if pass.len == 0: return if pass.len == 0: return
if ExecAffectedRows(db, if execAffectedRows(db,
sql("update session set lastModified = DATETIME('now') " & sql("update session set lastModified = DATETIME('now') " &
"where ip = ? and password = ?"), "where ip = ? and password = ?"),
c.req.ip, pass) > 0: c.req.ip, pass) > 0:
c.userpass = pass c.userpass = pass
c.userid = GetValue(db, c.userid = getValue(db,
sql"select userid from session where ip = ? and password = ?", sql"select userid from session where ip = ? and password = ?",
c.req.ip, pass) c.req.ip, pass)
@ -270,11 +332,11 @@ proc logout(c: var TForumData) =
const query = sql"delete from session where ip = ? and password = ?" const query = sql"delete from session where ip = ? and password = ?"
c.username = "" c.username = ""
c.userpass = "" c.userpass = ""
Exec(db, query, c.req.ip, c.req.cookies["sid"]) exec(db, query, c.req.ip, c.req.cookies["sid"])
proc incrementViews(c: var TForumData) = proc incrementViews(c: var TForumData) =
const query = sql"update thread set views = views + 1 where id = ?" const query = sql"update thread set views = views + 1 where id = ?"
Exec(db, query, $c.threadId) exec(db, query, $c.threadId)
proc isPreview(c: TForumData): bool = proc isPreview(c: TForumData): bool =
result = c.req.params["previewBtn"].len > 0 # TODO: Could be wrong? result = c.req.params["previewBtn"].len > 0 # TODO: Could be wrong?
@ -353,6 +415,8 @@ template setPreviewData(c: expr) {.immediate, dirty.} =
template writeToDb(c, cr, setPostId: expr) = template writeToDb(c, cr, setPostId: expr) =
let retID = insertID(db, crud(cr, "post", "author", "ip", "header", "content", "thread"), let retID = insertID(db, crud(cr, "post", "author", "ip", "header", "content", "thread"),
c.userId, c.req.ip, subject, content, $c.threadId, "") c.userId, c.req.ip, subject, content, $c.threadId, "")
discard tryExec(db, crud(cr, "post_fts", "id", "header", "content"),
retID.int, subject, content)
if setPostId: if setPostId:
c.postId = retID.int c.postId = retID.int
@ -363,19 +427,36 @@ proc edit(c: var TForumData, postId: int): bool =
setPreviewData(c) setPreviewData(c)
elif c.isDelete: elif c.isDelete:
checkOwnership(c, $postId) checkOwnership(c, $postId)
if not TryExec(db, crud(crDelete, "post"), $postId): if not tryExec(db, crud(crDelete, "post"), $postId):
return setError(c, "", "database error") return setError(c, "", "database error")
discard tryExec(db, crud(crDelete, "post_fts"), $postId)
# delete corresponding thread: # delete corresponding thread:
if ExecAffectedRows(db, if execAffectedRows(db,
sql"delete from thread where id not in (select thread from post)") > 0: sql"delete from thread where id not in (select thread from post)") > 0:
# whole thread has been deleted, so: # whole thread has been deleted, so:
c.threadId = unselectedThread c.threadId = unselectedThread
discard tryExec(db, sql"delete from thread_fts where id not in (select thread from post)")
else:
# Update corresponding thread's modified field.
let getModifiedSql = "(select creation from post where post.thread = ?" &
" order by creation desc limit 1)"
let updateSql = sql("update thread set modified=" & getModifiedSql &
" where id = ?")
if not tryExec(db, updateSql, $c.threadId, $c.threadId):
return setError(c, "", "database error")
result = true result = true
else: else:
checkOwnership(c, $postId) checkOwnership(c, $postId)
retrPost(c) retrPost(c)
exec(db, crud(crUpdate, "post", "header", "content"), exec(db, crud(crUpdate, "post", "header", "content"),
subject, content, $postId) subject, content, $postId)
exec(db, crud(crUpdate, "post_fts", "header", "content"),
subject, content, $postId)
# Check if post is the first post of the thread.
let rows = db.getAllRows(sql("select id, thread, creation from post " &
"where thread = ? order by creation asc"), $c.threadId)
if rows[0][0] == $postId:
exec(db, crud(crUpdate, "thread", "name"), subject, $c.threadId)
result = true result = true
proc reply(c: var TForumData): bool = proc reply(c: var TForumData): bool =
@ -398,20 +479,30 @@ proc newThread(c: var TForumData): bool =
setPreviewData(c) setPreviewData(c)
c.threadID = transientThread c.threadID = transientThread
else: else:
c.threadID = TryInsertID(db, query, c.req.params["subject"]).int c.threadID = tryInsertID(db, query, c.req.params["subject"]).int
if c.threadID < 0: return setError(c, "subject", "Subject already exists") if c.threadID < 0: return setError(c, "subject", "Subject already exists")
discard tryExec(db, crud(crCreate, "thread_fts", "id", "name"),
c.threadID, c.req.params["subject"])
writeToDb(c, crCreate, false) 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')")
result = true result = true
proc login(c: var TForumData, name, pass: string): bool = proc login(c: var TForumData, name, pass: string): bool =
# get form data: # get form data:
const query = const query =
sql"select id, name, password, email, salt, admin from person where name = ?" sql"select id, name, password, email, salt, admin, ban from person where name = ?"
if name.len == 0: if name.len == 0:
return c.setError("name", "Username cannot be nil.") return c.setError("name", "Username cannot be nil.")
var success = false var success = false
for row in FastRows(db, query, name): for row in fastRows(db, query, name):
if row[2] == makePassword(pass, row[4]): if row[2] == makePassword(pass, row[4], row[2]):
case row[6]
of "": discard
of banReasonDeactivated:
return c.setError("name", "Your account has been deactivated.")
else:
return c.setError("name", "You have been banned: " & row[6])
c.userid = row[0] c.userid = row[0]
c.username = row[1] c.username = row[1]
c.userpass = row[2] c.userpass = row[2]
@ -421,24 +512,39 @@ proc login(c: var TForumData, name, pass: string): bool =
break break
if success: if success:
# create session: # create session:
Exec(db, exec(db,
sql"insert into session (ip, password, userid) values (?, ?, ?)", sql"insert into session (ip, password, userid) values (?, ?, ?)",
c.req.ip, c.userpass, c.userid) c.req.ip, c.userpass, c.userid)
return true return true
else: else:
return c.setError("password", "Login failed!") return c.setError("password", "Login failed!")
proc setBan(c: var TForumData, nick, reason: string): bool =
const query =
sql("update person set ban = ? where name = ?")
return tryExec(db, query, reason, nick)
proc hasReplyBtn(c: var TForumData): bool =
result = c.req.pathInfo != "/donewthread" and c.req.pathInfo != "/doreply"
result = result and c.req.params["action"] != "reply"
# If the user is not logged in and there are no page numbers then we shouldn't
# generate the div.
let pages = ceil(c.totalPosts / PostsPerPage).int
result = result and (pages > 1 or c.loggedIn)
return c.threadId >= 0 and result
proc genActionMenu(c: var TForumData): string = proc genActionMenu(c: var TForumData): string =
result = "" result = ""
var btns: seq[TStyledButton] = @[] var btns: seq[TStyledButton] = @[]
# TODO: Make this detection better? # TODO: Make this detection better?
if c.req.pathInfo.normalizeUri notin noHomeBtn and not c.isThreadsList: if c.req.pathInfo.normalizeUri notin noHomeBtn and not c.isThreadsList:
btns.add(("Thread List", c.req.makeUri("/", false))) btns.add(("Thread List", c.req.makeUri("/", false)))
#echo c.loggedIn
if c.loggedIn: if c.loggedIn:
let hasReplyBtn = c.req.pathInfo != "/donewthread" and c.req.pathInfo != "/doreply" let hasReplyBtn = c.req.pathInfo != "/donewthread" and c.req.pathInfo != "/doreply"
if c.threadId >= 0 and hasReplyBtn: if c.threadId >= 0 and hasReplyBtn:
let replyUrl = c.genThreadUrl(action = "reply", let replyUrl = c.genThreadUrl(action = "reply",
pageNum = $(ceil(c.totalPosts / postsPerPage).int)) & "#reply" pageNum = $(ceil(c.totalPosts / PostsPerPage).int)) & "#reply"
btns.add(("Reply", replyUrl)) btns.add(("Reply", replyUrl))
btns.add(("New Thread", c.req.makeUri("/newthread", false))) btns.add(("New Thread", c.req.makeUri("/newthread", false)))
result = c.genButtons(btns) result = c.genButtons(btns)
@ -461,7 +567,7 @@ proc getStats(c: var TForumData, simple: bool): TForumStats =
sql"select id, name, admin, strftime('%s', lastOnline), strftime('%s', creation) from person" sql"select id, name, admin, strftime('%s', lastOnline), strftime('%s', creation) from person"
for row in fastRows(db, getUsersQuery): for row in fastRows(db, getUsersQuery):
let secs = if row[3] == "": 0 else: row[3].parseint let secs = if row[3] == "": 0 else: row[3].parseint
let lastOnlineSeconds = getTime() - TTime(secs) let lastOnlineSeconds = getTime() - Time(secs)
if lastOnlineSeconds < (60 * 5): # 5 minutes if lastOnlineSeconds < (60 * 5): # 5 minutes
result.activeUsers.add((row[1], row[0].parseInt, row[2].parseBool)) result.activeUsers.add((row[1], row[0].parseInt, row[2].parseBool))
if row[4].parseInt > newestMemberCreation: if row[4].parseInt > newestMemberCreation:
@ -489,7 +595,7 @@ proc genPagenumNav(c: var TForumData, stats: TForumStats): string =
prevUrl = firstUrl prevUrl = firstUrl
else: else:
prevUrl = c.req.makeUri(firstUrl & "/" & $(c.pageNum-1)) prevUrl = c.req.makeUri(firstUrl & "/" & $(c.pageNum-1))
totalPages = ceil(c.totalPosts / postsPerPage).int totalPages = ceil(c.totalPosts / PostsPerPage).int
lastUrl = c.req.makeUri(firstUrl & "/" & $(totalPages)) lastUrl = c.req.makeUri(firstUrl & "/" & $(totalPages))
nextUrl = c.req.makeUri(firstUrl & "/" & $(c.pageNum+1)) nextUrl = c.req.makeUri(firstUrl & "/" & $(c.pageNum+1))
@ -499,30 +605,22 @@ proc genPagenumNav(c: var TForumData, stats: TForumStats): string =
var firstTag = "" var firstTag = ""
var prevTag = "" var prevTag = ""
if c.pageNum == 1: if c.pageNum == 1:
firstTag = span("First") firstTag = span("<<")
prevTag = span("Prev") prevTag = span("<••")
else: else:
firstTag = htmlgen.a(href=firstUrl, "First") firstTag = htmlgen.a(href=firstUrl, "<<")
prevTag = htmlgen.a(href=prevUrl, "Prev") prevTag = htmlgen.a(href=prevUrl, "<••")
result.add(htmlgen.`div`(class = "left", prevTag.add(htmlgen.link(rel="previous", href=prevUrl))
firstTag, result.add(firstTag)
prevTag)) result.add(prevTag)
# Right
var lastTag = ""
var nextTag = ""
if c.pageNum == totalPages:
lastTag = span("Last")
nextTag = span("Next")
else:
lastTag = htmlgen.a(href=lastUrl, "Last")
nextTag = htmlgen.a(href=nextUrl, "Next")
result.add(htmlgen.`div`(class = "right",
nextTag,
lastTag))
# Numbers # Numbers
var pages = "" # Tags var pages = "" # Tags
for i in 1..totalPages: # cutting numbers to the left and to the right tp MaxPagesFromCurrent
let firstToShow = max(1, c.pageNum - MaxPagesFromCurrent)
let lastToShow = min(totalPages, c.pageNum + MaxPagesFromCurrent)
if firstToShow > 1: pages.add(span("..."))
for i in firstToShow .. lastToShow:
if i == c.pageNum: if i == c.pageNum:
pages.add(span($(i))) pages.add(span($(i)))
else: else:
@ -533,14 +631,25 @@ proc genPagenumNav(c: var TForumData, stats: TForumStats): string =
pageUrl = c.req.makeUri(firstUrl & "/" & $(i)) pageUrl = c.req.makeUri(firstUrl & "/" & $(i))
pages.add(htmlgen.a(href = pageUrl, $(i))) pages.add(htmlgen.a(href = pageUrl, $(i)))
result.add(htmlgen.`div`(class = "middle", if lastToShow < totalPages: pages.add(span("..."))
pages)) result.add(pages)
result = htmlgen.`div`(id = "pagenumbers", result) # Right
var lastTag = ""
var nextTag = ""
if c.pageNum == totalPages:
lastTag = span(">>")
nextTag = span("••>")
else:
lastTag = htmlgen.a(href=lastUrl, ">>")
nextTag = htmlgen.a(href=nextUrl, "••>")
nextTag.add(htmlgen.link(rel="next",href=nextUrl))
result.add(nextTag)
result.add(lastTag)
proc gatherTotalPostsByID(c: var TForumData, thrid: int): int = proc gatherTotalPostsByID(c: var TForumData, thrid: int): int =
## Gets the total post count of a thread. ## Gets the total post count of a thread.
result = GetValue(db, sql"select count(*) from post where thread = ?", $thrid).parseInt result = getValue(db, sql"select count(*) from post where thread = ?", $thrid).parseInt
proc gatherTotalPosts(c: var TForumData) = proc gatherTotalPosts(c: var TForumData) =
if c.totalPosts > 0: return if c.totalPosts > 0: return
@ -551,13 +660,13 @@ proc gatherTotalPosts(c: var TForumData) =
proc getPagesInThread(c: var TForumData): int = proc getPagesInThread(c: var TForumData): int =
c.gatherTotalPosts() # Get total post count c.gatherTotalPosts() # Get total post count
result = ceil(c.totalPosts / postsPerPage).int-1 result = ceil(c.totalPosts / PostsPerPage).int-1
proc getPagesInThreadByID(c: var TForumData, thrid: int): int = proc getPagesInThreadByID(c: var TForumData, thrid: int): int =
result = ceil(c.gatherTotalPostsByID(thrid) / postsPerPage).int result = ceil(c.gatherTotalPostsByID(thrid) / PostsPerPage).int
proc getThreadTitle(thrid: int, pageNum: int): string = proc getThreadTitle(thrid: int, pageNum: int): string =
result = GetValue(db, sql"select name from thread where id = ?", $thrid) result = getValue(db, sql"select name from thread where id = ?", $thrid)
if pageNum notin {0,1}: if pageNum notin {0,1}:
result.add(" - Page " & $pageNum) result.add(" - Page " & $pageNum)
@ -579,7 +688,7 @@ proc genPagenumLocalNav(c: var TForumData, thrid: int): string =
else: else:
inc(i) inc(i)
result = htmlgen.`div`(class = "localnums", result) result = htmlgen.span(class = "pages", result)
proc gatherUserInfo(c: var TForumData, nick: string, ui: var TUserInfo): bool = proc gatherUserInfo(c: var TForumData, nick: string, ui: var TUserInfo): bool =
ui.nick = nick ui.nick = nick
@ -600,12 +709,28 @@ proc gatherUserInfo(c: var TForumData, nick: string, ui: var TUserInfo): bool =
let lastOnlineDBVal = getValue(db, lastOnlineQuery, uid) let lastOnlineDBVal = getValue(db, lastOnlineQuery, uid)
ui.lastOnline = if lastOnlineDBVal != "": lastOnlineDBVal.parseInt else: -1 ui.lastOnline = if lastOnlineDBVal != "": lastOnlineDBVal.parseInt else: -1
ui.email = getValue(db, sql"select email from person where id = ?", uid) ui.email = getValue(db, sql"select email from person where id = ?", uid)
ui.ban = getValue(db, sql"select ban from person where id = ?", uid)
proc genSetUserStatusUrl(c: var TForumData, nick: string, typ: string): string =
c.req.makeUri("/setUserStatus?nick=$1&type=$2" % [nick, typ])
proc genProfile(c: var TForumData, ui: TUserInfo): string = proc genProfile(c: var TForumData, ui: TUserInfo): string =
result = "" result = ""
result.add(htmlgen.`div`(id = "talk-head",
htmlgen.`div`(class="info-post",
htmlgen.`div`(
htmlgen.a(href = c.req.makeUri("/"),
span(style = "font-weight: bold;", "forum index")
),
" > " & ui.nick & "'s profile"
)
)
)
)
result.add(htmlgen.`div`(id = "avatar", genGravatar(ui.email, 250))) result.add(htmlgen.`div`(id = "avatar", genGravatar(ui.email, 250)))
let t2 = if ui.lastOnline != -1: getGMTime(TTime(ui.lastOnline)) let t2 = if ui.lastOnline != -1: getGMTime(Time(ui.lastOnline))
else: getGMTime(GetTime()) else: getGMTime(getTime())
result.add(htmlgen.`div`(id = "info", result.add(htmlgen.`div`(id = "info",
htmlgen.table( htmlgen.table(
@ -625,6 +750,41 @@ proc genProfile(c: var TForumData, ui: TUserInfo): string =
th("Last Online"), th("Last Online"),
td(if ui.lastOnline != -1: t2.format("dd/MM/yy HH':'mm 'UTC'") td(if ui.lastOnline != -1: t2.format("dd/MM/yy HH':'mm 'UTC'")
else: "Never") else: "Never")
),
tr(
th("Status"),
td(case ui.ban
of banReasonDeactivated:
"Deactivated"
of "":
"Active"
else:
"Banned: " & ui.ban
)
),
tr(
th(""),
td(if c.isAdmin and ui.ban != banReasonDeactivated:
if ui.ban == "":
htmlgen.a(
href=c.genSetUserStatusUrl(ui.nick, "ban"),
"Ban user")
else:
htmlgen.a(href=c.genSetUserStatusUrl(ui.nick, "unban"),
"Unban user")
else: "")
),
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")
else: ""
else: "")
) )
) )
)) ))
@ -651,12 +811,15 @@ template createTFD(): stmt =
if request.cookies.len > 0: if request.cookies.len > 0:
checkLoggedIn(c) checkLoggedIn(c)
routes:
get "/": get "/":
createTFD() createTFD()
c.isThreadsList = true c.isThreadsList = true
var count = 0 var count = 0
resp genMain(c, genThreadsList(c, count), let threadList = genThreadsList(c, count)
let data = genMain(c, threadList,
additionalHeaders = genRSSHeaders(c), showRssLinks = true) additionalHeaders = genRSSHeaders(c), showRssLinks = true)
resp data
get "/threadActivity.xml": get "/threadActivity.xml":
createTFD() createTFD()
@ -667,17 +830,19 @@ get "/postActivity.xml":
createTFD() createTFD()
resp genPostsRSS(c), "application/atom+xml" resp genPostsRSS(c), "application/atom+xml"
get "/t/@threadid/?@page?/?": get "/t/@threadid/?@page?/?@postid?/?":
createTFD() createTFD()
parseInt(@"threadid", c.threadId, -1..1000_000) parseInt(@"threadid", c.threadId, -1..1000_000)
if c.threadid == unselectedThread:
# Thread has just beed deleted if c.threadId == unselectedThread:
# Thread has just been deleted.
redirect(uri("/")) redirect(uri("/"))
if @"page".len > 0: if @"page".len > 0:
parseInt(@"page", c.pageNum, 0..1000_000) parseInt(@"page", c.pageNum, 0..1000_000)
if @"postid".len > 0:
parseInt(@"postid", c.postId, 0..1000_000)
cond (c.pageNum > 0) cond (c.pageNum > 0)
if (@"postid").len > 0:
parseInt(@"postid", c.postId, -1..1000_000)
var count = 0 var count = 0
var pSubject = getThreadTitle(c.threadid, c.pageNum) var pSubject = getThreadTitle(c.threadid, c.pageNum)
cond validThreadId(c) cond validThreadId(c)
@ -686,7 +851,7 @@ get "/t/@threadid/?@page?/?":
var title = "" var title = ""
case @"action" case @"action"
of "reply": of "reply":
let subject = GetValue(db, let subject = getValue(db,
sql"select header from post where id = (select max(id) from post where thread = ?)", sql"select header from post where id = (select max(id) from post where thread = ?)",
$c.threadId).prependRe $c.threadId).prependRe
body = genPostsList(c, $c.threadId, count) body = genPostsList(c, $c.threadId, count)
@ -701,14 +866,15 @@ get "/t/@threadid/?@page?/?":
let content = ||row[1] let content = ||row[1]
body = genFormPost(c, "doedit", "Edit", header, content, true) body = genFormPost(c, "doedit", "Edit", header, content, true)
title = "Editing post" title = "Editing post"
resp c.genMain(body, title & " - Nimrod Forum") else: discard
resp c.genMain(body, title & " - Nim Forum")
else: else:
incrementViews(c) incrementViews(c)
let posts = genPostsList(c, $c.threadId, count) let posts = genPostsList(c, $c.threadId, count)
cond count != 0 cond count != 0
resp genMain(c, posts, pSubject & " - Nimrod Forum") resp genMain(c, posts, pSubject & " - Nim Forum")
get "/page/@page/?": get "/page/?@page?/?":
createTFD() createTFD()
c.isThreadsList = true c.isThreadsList = true
cond (@"page" != "") cond (@"page" != "")
@ -718,7 +884,7 @@ get "/page/@page/?":
let list = genThreadsList(c, count) let list = genThreadsList(c, count)
if count == 0: if count == 0:
pass() pass()
resp genMain(c, list, "Page " & $c.pageNum & " - Nimrod Forum", resp genMain(c, list, "Page " & $c.pageNum & " - Nim Forum",
genRSSHeaders(c), showRssLinks = true) genRSSHeaders(c), showRssLinks = true)
get "/profile/@nick/?": get "/profile/@nick/?":
@ -727,13 +893,13 @@ get "/profile/@nick/?":
var userinfo: TUserInfo var userinfo: TUserInfo
if gatherUserInfo(c, @"nick", userinfo): if gatherUserInfo(c, @"nick", userinfo):
resp genMain(c, c.genProfile(userinfo), resp genMain(c, c.genProfile(userinfo),
@"nick" & "'s profile - Nimrod Forum") @"nick" & "'s profile - Nim Forum")
else: else:
halt() halt()
get "/login/?": get "/login/?":
createTFD() createTFD()
resp genMain(c, genFormLogin(c), "Log in - Nimrod Forum") resp genMain(c, genFormLogin(c), "Log in - Nim Forum")
get "/logout/?": get "/logout/?":
createTFD() createTFD()
@ -742,7 +908,7 @@ get "/logout/?":
get "/register/?": get "/register/?":
createTFD() createTFD()
resp genMain(c, genFormRegister(c), "Register - Nimrod Forum") resp genMain(c, genFormRegister(c), "Register - Nim Forum")
template readIDs(): stmt = template readIDs(): stmt =
# Retrieve the threadid, postid and pagenum # Retrieve the threadid, postid and pagenum
@ -760,14 +926,20 @@ template handleError(action: string, topText: string, isEdit: bool): stmt =
body.add genPostPreview(c, @"subject", @"content", body.add genPostPreview(c, @"subject", @"content",
c.userName, $getGMTime(getTime())) c.userName, $getGMTime(getTime()))
body.add genFormPost(c, action, topText, reuseText, reuseText, isEdit) body.add genFormPost(c, action, topText, reuseText, reuseText, isEdit)
resp genMain(c, body(), "Nimrod Forum - Error") resp genMain(c, body(), "Nim Forum - " &
(if c.isPreview: "Preview" else: "Error"))
post "/dologin": post "/dologin":
createTFD() createTFD()
if login(c, @"name", @"password"): if login(c, @"name", @"password"):
finishLogin() finishLogin()
else: else:
resp c.genMain(genFormLogin(c)) c.isThreadsList = true
var count = 0
let threadList = genThreadsList(c, count)
let data = genMain(c, threadList,
additionalHeaders = genRSSHeaders(c), showRssLinks = true)
resp data
post "/doregister": post "/doregister":
createTFD() createTFD()
@ -801,7 +973,8 @@ post "/doedit":
createTFD() createTFD()
readIDs() readIDs()
if edit(c, c.postId): if edit(c, c.postId):
redirect(c.genThreadUrl(pageNum = $(c.getPagesInThread+1)) & "#" & $c.postId) redirect(c.genThreadUrl(postId = $c.postId,
pageNum = $(c.getPagesInThread+1)))
else: else:
body = "" body = ""
handleError("doedit", "Edit", true) handleError("doedit", "Edit", true)
@ -809,22 +982,130 @@ post "/doedit":
get "/newthread/?": get "/newthread/?":
createTFD() createTFD()
resp genMain(c, genFormPost(c, "donewthread", "New thread", "", "", false), resp genMain(c, genFormPost(c, "donewthread", "New thread", "", "", false),
"New Thread - Nimrod Forum") "New Thread - Nim Forum")
get "/setUserStatus/?":
createTFD()
cond (@"nick" != "")
cond (@"type" != "")
var formBody = "<input type=\"hidden\" name=\"nick\" value=\"" &
@"nick" & "\">"
var del = false
var content = ""
echo("Got type: ", @"type")
case @"type"
of "ban":
formBody.add "<input type='text' name='reason' />" &
"<input type='submit' name='banBtn' value='Ban' />"
content =
htmlgen.p("Please enter a reason for banning this user:")
of "unban":
formBody.add "<input type='submit' name='unbanBtn' value='Unban' />"
content =
htmlgen.p("Are you sure you wish to unban ", htmlgen.b(@"nick"),
"?")
del = true
of "deactivate":
formBody.add "<input type='hidden' name='reason' value='" &
banReasonDeactivated & "' />" &
"<input type='submit' name='actBtn' value='Deactivate' />"
content =
htmlgen.p("Are you sure you wish to deactivate ", htmlgen.b(@"nick"),
"?")
of "activate":
formBody.add "<input type='submit' name='actBtn' value='Activate' />"
content =
htmlgen.p("Are you sure you wish to activate ", htmlgen.b(@"nick"),
"?")
del = true
formBody.add "<input type='hidden' name='del' value='" & $del & "'/>"
content = htmlgen.form(action = c.req.makeUri("/dosetban"),
`method` = "POST", formBody) & content
resp genMain(c, content, "Set user status - Nim Forum")
post "/dosetban":
createTFD()
cond (@"nick" != "")
if not c.isAdmin and @"nick" != c.userName:
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.
setBan(c, @"nick", "")
else:
setBan(c, @"nick", @"reason")
if result:
redirect(c.req.makeUri("/profile/" & @"nick"))
else:
resp genMain(c, "Failed to change the ban status of user.",
"Error - Nim Forum")
const licenseRst = slurp("static/license.rst") const licenseRst = slurp("static/license.rst")
get "/license": get "/license":
createTFD() createTFD()
resp genMain(c, rstToHtml(licenseRst), "Content license - Nimrod Forum") resp genMain(c, rstToHtml(licenseRst), "Content license - Nim Forum")
post "/search/?@page?":
cond isFTSAvailable
createTFD()
c.isThreadsList = true
c.noPagenumumNav = true
var count = 0
var q = @"q"
for i in 0 .. q.len-1:
if q[i].int < 32: q[i] = ' '
elif q[i] == '\'': q[i] = '"'
c.search = q.replace("\"","&quot;");
if @"page".len > 0:
parseInt(@"page", c.pageNum, 0..1000_000)
cond (c.pageNum > 0)
iterator searchResults(): db_sqlite.TRow {.closure, tags: [FReadDB].} =
const queryFT = "fts.sql".slurp.sql
for rowFT in fastRows(db, queryFT,
[q,q,$ThreadsPerPage,$c.pageNum,$ThreadsPerPage,q,
q,q,$ThreadsPerPage,$c.pageNum,$ThreadsPerPage,q]):
yield rowFT
resp genMain(c, genSearchResults(c, searchResults, count),
additionalHeaders = genRSSHeaders(c), showRssLinks = true)
# tries first to read html, then to read rst, convert ot html, cache and return
template textPage(path: string): stmt =
createTFD()
#c.isThreadsList = true
var page = ""
if existsFile(path):
page = readFile(path)
else:
let basePath =
if path[path.high] == '/': path & "index"
elif path.endsWith(".html"): path[-5 .. -1]
else: path
if existsFile(basePath & ".html"):
page = readFile(basePath & ".html")
elif existsFile(basePath & ".rst"):
page = readFile(basePath & ".rst").rstToHtml
writeFile(basePath & ".html", page)
resp genMain(c, page)
get "/search-help":
textPage "static/search-help"
when isMainModule: when isMainModule:
docConfig = rstgen.defaultConfig() docConfig = rstgen.defaultConfig()
docConfig["doc.smiley_format"] = "/images/smilieys/$1.png"
math.randomize() math.randomize()
db = Open(connection="nimforum.db", user="postgres", password="", db = open(connection="nimforum.db", user="postgres", password="",
database="nimforum") database="nimforum")
isFTSAvailable = db.getAllRows(sql("SELECT name FROM sqlite_master WHERE " &
"type='table' AND name='post_fts'")).len == 1
var http = true var http = true
if paramCount() > 0: if paramCount() > 0:
if paramStr(1) == "scgi": if paramStr(1) == "scgi":
http = false http = false
run("", port = TPort(9000), http = http)
#run("", port = TPort(9000), http = http)
runForever()
db.close() db.close()

View file

@ -3,4 +3,3 @@
--path:"$nimrod/lib/packages/docutils" --path:"$nimrod/lib/packages/docutils"
--path:"$nimrod" --path:"$nimrod"
--path:"../jester"

74
fts.sql Normal file
View file

@ -0,0 +1,74 @@
-- selects just threads,
-- those where title doesn't coinside with some of its posts' titles
-- by now selects only the threads title (no post snippet)
SELECT
thread_id,
snippet(thread_fts, '<b>', '</b>', '<b>...</b>') AS thread,
0 AS post_id,
'' AS header,
'' AS content,
person.name AS author,
cdate,
author_id,
person.email AS email,
0 AS what
FROM (
SELECT
thread_fts.id AS thread_id,
post.id AS post_id,
post.creation AS cdate,
MIN(post.creation) AS cdate,
post.author AS author_id
FROM thread_fts
JOIN post ON post.thread=thread_id
WHERE thread_fts MATCH ?
GROUP BY thread_id, post_id
HAVING thread_id NOT IN (
SELECT thread
FROM post_fts JOIN post USING(id)
WHERE post_fts MATCH ?
)
LIMIT ? OFFSET (? - 1) * ?
)
JOIN thread_fts ON thread_fts.id=thread_id
JOIN person ON person.id=author_id
WHERE thread_fts MATCH ?
UNION
-- the main query, selects posts
SELECT
thread.id AS thread_id,
thread.name AS thread,
post.id AS post_id,
CASE what WHEN 1
THEN snippet(post_fts, '<b>', '</b>', '...', what)
ELSE post_fts.header END AS header,
CASE what WHEN 2
THEN snippet(post_fts, '**', '**', '...', what, -45)
ELSE SUBSTR(post_fts.content, 1, 200) END AS content,
person.name AS author,
cdate,
post.author AS author_id,
person.email AS email,
what
FROM post_fts JOIN (
-- inner query, selects ids of matching posts, orders and limits them,
-- so snippets only for limited count of posts are created (in outer query)
SELECT id, post.creation AS cdate, thread, 1 AS what, post.author AS author
FROM post_fts JOIN post USING(id)
WHERE post_fts.header MATCH ?
GROUP BY post.header
HAVING SUBSTR(post.header,1,3)<>'Re:'
UNION
SELECT id, post.creation AS cdate, thread, 2 AS what, post.author AS author
FROM post_fts JOIN post USING(id)
WHERE post_fts.content MATCH ?
ORDER BY what, cdate DESC
LIMIT ? OFFSET (? - 1) * ?
) AS post USING(id)
JOIN thread ON thread.id=thread
JOIN person ON person.id=author
WHERE post_fts MATCH ?
ORDER BY what ASC, cdate DESC
LIMIT 300 -- hardcoded limit just in case
;

241
main.tmpl
View file

@ -10,87 +10,170 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<title>${XmlEncode(title)}</title> <title>${xmlEncode(title)}</title>
<link rel="stylesheet" href="${c.req.makeUri("css/normalize.css", absolute = false)}"> <link rel="stylesheet" href="${c.req.makeUri("css/style.css", absolute = false)}">
<link rel="stylesheet" href="${c.req.makeUri("css/style.css", absolute = false)}">${additional_headers} <link rel="shortcut icon" href="${c.req.makeUri("favicon.ico", absolute = false)}">${additional_headers}
<meta charset="UTF-8"> <meta charset="UTF-8">
</head> </head>
<body> <body>
<div id="wrapper"> <!--- #head --->
#let frontQuery = c.req.makeUri("/") #let frontQuery = c.req.makeUri("/")
<div id="nimbtn"> <header id="head" class="forum">
<a href="${frontQuery}">Forum</a> <div class="wide-layout tall">
<div id="head-logo"></div>
<a id="head-logo-link" href="/"></a>
<nav id="head-links">
<a href="http://nim-lang.org/">home</a>
<a href="http://nim-lang.org/documentation.html">docs</a>
<a href="http://nim-lang.org/learn.html">learn</a>
<a href="http://nim-lang.org/download.html">download</a>
<a href="${frontQuery}" class="active">forum</a>
<a href="http://nim-lang.org/question.html">faq</a>
</nav>
</div> </div>
</header>
<div id="header"> <!--- #neck --->
<span>Nimrod's <a href="http://nimrod-lang.org/">homepage</a></span> <section id="neck">
<span><a href="http://nimrod-lang.org/documentation.html">stable docs</a></span> <div class="wide-layout tall">
<span><a href="http://build.nimrod-lang.org/docs/overview.html">development docs</a></span> <div style="right: 131px;" id="glow-arrow" class="forum"></div>
<span><a href="https://github.com/Araq/Nimrod/issues">github issues</a></span>
#if c.loggedIn:
<a href="${frontQuery}logout" class="right">Logout</a>
#let profileUrl = c.req.makeUri("profile/", false) &
# XMLencode(c.username)
<span id="welcome"><a href="${profileUrl}">$c.username</a></span>
<a href="$profileUrl">${genGravatar(c.email, 26)}</a>
#else:
<a href="${frontQuery}register" class="right">Register</a>
<a href="${frontQuery}login" class="right">Login</a>
#end if
</div>
<div id="topbar">
${c.genActionMenu}
</div>
<form method="get" action="http://www.google.com/search">
<table align="right" cellpadding="5pt" cellspacing="5pt"><tr><td>
<input type="text" name="q" size="25" maxlength="255" value="" />
</td><td>
<input type="submit" value="Google Search this forum" />
<input type="hidden" name="sitesearch"
value="http://forum.nimrod-lang.org" />
</td></tr></table>
</form>
<div id="content">
$content
<span style="color:red">$c.errorMsg</span>
</div>
#if c.req.pathInfo.normalizeUri notin noPageNums:
${c.genPagenumNav(stats)}
#end if
<div id="topbar">
${c.genActionMenu}
</div> </div>
</section>
<!--- #body --->
<section id="body" class="forum">
<div id="body-border"></div>
<div id="body-border-left"></div>
<div id="body-border-right"></div>
<div id="body-border-bottom"></div>
<div id="glow-line"></div>
<div id="glow-line-bottom"></div>
<div class="talk-layout">
<article id="content">
${content}
#if c.isThreadsList: #if c.isThreadsList:
<div id="talk-info">
<div class="info">
<div>
${c.genListOnline(stats)} ${c.genListOnline(stats)}
</div>
</div>
</div>
#if not c.noPagenumumNav:
<div id="talk-nav">
${genPagenumNav(c, stats)}
</div>
#end if
#elif hasReplyBtn(c):
<div id="talk-info">
<div class="info-post">
<div>
${genPagenumNav(c, stats)}
</div>
</div>
<div class="user-post">
#if c.loggedIn():
#let replyUri = c.req.makeUri(c.req.path & "?action=reply#reply")
<a href="$replyUri">
<div>
<span class="reply">Reply</span>
</div>
</a>
#end if
</div>
</div>
#end if #end if
#if showRssLinks: </article>
<span id="rss"> <div id="sidebar">
<a href="${c.req.makeUri("/threadActivity.xml")}" <div class="title">Search</div>
><img <div class="content">
src="${c.req.makeUri("/images/Feed-icon.svg", absolute = false)}" #if isFTSAvailable:
class="rssfeed" <a href="/search-help" target="_blank" class="searchHelp">?</a>
>Thread activity</a> <form method="post" action="/search" class="searchForm">
<a href="${c.req.makeUri("/postActivity.xml")}" <input type="text" name="q" maxlength="255" value="${c.search}" title="Search this forum" />
><img <input type="submit" value="Search" class="button search">
src="${c.req.makeUri("/images/Feed-icon.svg", absolute = false)}" </form>
class="rssfeed" #else:
>Posts activity</a> <form method="get" action="http://www.google.com/search" target="_blank" class="searchForm">
</span> <input type="text" name="q" maxlength="255" value="${c.search}" title="Search this forum" />
<input type="submit" value="Search" class="button search">
<input type="hidden" name="sitesearch" value="http://forum.nimrod-lang.org" />
</form>
#end if #end if
</div>
#if c.loggedIn:
<div class="title">Your account</div>
<div class="content user">
#let profileUrl = c.req.makeUri("profile/", false) &
# xmlEncode(c.username)
<a href="$profileUrl" class="user">${c.username}</a>
<a href="$profileUrl" class="avatar">${genGravatar(c.email)}</a>
<a href="${frontQuery}newthread" class="button">New Thread</a>
<a href="$profileUrl" class="button">My Profile</a>
<a href="${frontQuery}logout" class="button logout">Logout</a>
<div id="footerPush"></div>
</div> </div>
<div id="footer"> #else:
<span>Written in <a href="http://nimrod-lang.org/">Nimrod</a> using <a href="https://github.com/dom96/jester">Jester</a></span> <div class="title">Login</div>
<span> | <a href="https://github.com/nimrod-code/nimforum">Fork on Github</a></span> <div class="content">
<span> | User contributions licensed under <a href="http://creativecommons.org/licenses/by-sa/3.0/">cc-wiki</a> with <a href="/license">attribution required</a></span> <form name="login" action="${frontQuery}dologin" method="POST">
<span style="float:right;">Generated in ${int((epochTime()-c.startTime)*1000.0)}ms</span> <span>Username: </span><input type="text" name="name" />
<span>Password: </span><input type="password" name="password" />
<input type="submit" style="display: none;"
id="hdnLogin" value="Login" />
</form>
#if c.errorMsg != "" and c.req.pathInfo.normalizeUri == "/dologin":
<span class="error">$c.errorMsg</span>
#end if
<a href="${frontQuery}register" class="button"
style="float: left;">Register</a>
<a href="#" onclick="document.forms['login'].submit()"
class="button">Login</a>
</div> </div>
#end if
</div>
</div>
</section>
<!--- #foot --->
<footer id="foot" class="forum">
<div class="talk-layout tall">
<div id="foot-links">
<div>
<h4>Documentation</h4>
<a href="http://nim-lang.org/documentation.html">Stable Documentation</a>
<a href="https://github.com/Araq/Nim/issues">Github Issues &amp; Requests</a>
</div>
<div>
<h4>Community</h4>
<a href="http://forum.nim-lang.org">User Forum</a>
<a href="http://webchat.freenode.net/?channels=nim">Online IRC</a>
<a href="http://irclogs.nim-lang.org/">IRC Logs</a>
</div>
</div>
<div id="foot-legal">
<h4>Written in Nim - Powered by <a href="https://github.com/dom96/jester">Jester</a></h4>
Web Design by <a href="http://reign-studios.net/philipwitte/">Philip Witte</a> &amp; <a href="http://picheta.me">Dominik Picheta</a><br>
Copyright © 2015 - <a href="http://nim-lang.org/blog/">Andreas Rumpf</a> &amp; <a href="https://github.com/Araq/Nimrod/graphs/contributors">Contributors</a>
</div>
</div>
</footer>
<script>
(function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){
(i[r].q=i[r].q||[]).push(arguments)},i[r].l=1*new Date();a=s.createElement(o),
m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m)
})(window,document,'script','//www.google-analytics.com/analytics.js','ga');
ga('create', 'UA-58103537-1', 'auto');
ga('send', 'pageview');
</script>
<script src="${frontQuery}js/arrow.js"></script>
<script src="${frontQuery}js/forum.js"></script>
</body> </body>
</html> </html>
#end proc #end proc
@ -120,7 +203,7 @@
# const postContent = 5 # const postContent = 5
# const postId = 6 # const postId = 6
# let frontQuery = c.req.makeUri("/") # let frontQuery = c.req.makeUri("/")
# let recent = GetValue(db, sql"""SELECT # let recent = getValue(db, sql"""SELECT
# strftime('%Y-%m-%dT%H:%M:%SZ', (modified)) FROM thread # strftime('%Y-%m-%dT%H:%M:%SZ', (modified)) FROM thread
# ORDER BY modified DESC LIMIT 1""") # ORDER BY modified DESC LIMIT 1""")
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
@ -130,22 +213,22 @@
<link href="${frontQuery}" /> <link href="${frontQuery}" />
<id>${frontQuery}</id> <id>${frontQuery}</id>
<updated>${recent}</updated> <updated>${recent}</updated>
# for row in Rows(db, query, 10): # for row in rows(db, query, 10):
<entry> <entry>
<title>${XMLencode(%name)}</title> <title>${xmlEncode(%name)}</title>
<id>urn:entry:${%threadid}</id> <id>urn:entry:${%threadid}</id>
# let url = c.genThreadUrl(threadid = %threadid, # let url = c.genThreadUrl(threadid = %threadid,
# pageNum = $(ceil(parseInt(%postCount) / postsPerPage).int)) & # pageNum = $(ceil(parseInt(%postCount) / PostsPerPage).int)) &
# "#" & %postId # "#" & %postId
<link rel="alternate" type="text/html" <link rel="alternate" type="text/html"
href="${c.req.makeUri(url)}"/> href="${c.req.makeUri(url)}"/>
<published>${%threadDate}</published> <published>${%threadDate}</published>
<updated>${%threadDate}</updated> <updated>${%threadDate}</updated>
<author><name>${XMLEncode(%postAuthor)}</name></author> <author><name>${xmlEncode(%postAuthor)}</name></author>
<content type="html" <content type="html"
>Posts ${%postCount}, ${XMLEncode(%postAuthor)} said: >Posts ${%postCount}, ${xmlEncode(%postAuthor)} said:
&lt;p&gt; &lt;p&gt;
${XMLEncode(rstToHtml(%postContent))}</content> ${xmlEncode(rstToHtml(%postContent))}</content>
</entry> </entry>
# end for # end for
</feed> </feed>
@ -169,7 +252,7 @@ ${XMLEncode(rstToHtml(%postContent))}</content>
# const postHumanDate = 6 # const postHumanDate = 6
# const postPosition = 7 # const postPosition = 7
# let frontQuery = c.req.makeUri("/") # let frontQuery = c.req.makeUri("/")
# let recent = GetValue(db, sql"""SELECT # let recent = getValue(db, sql"""SELECT
# strftime('%Y-%m-%dT%H:%M:%SZ', creation) FROM post # strftime('%Y-%m-%dT%H:%M:%SZ', creation) FROM post
# ORDER BY creation DESC LIMIT 1""") # ORDER BY creation DESC LIMIT 1""")
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
@ -179,22 +262,22 @@ ${XMLEncode(rstToHtml(%postContent))}</content>
<link href="${frontQuery}" /> <link href="${frontQuery}" />
<id>${frontQuery}</id> <id>${frontQuery}</id>
<updated>${recent}</updated> <updated>${recent}</updated>
# for row in Rows(db, query, 10): # for row in rows(db, query, 10):
<entry> <entry>
<title>${XMLencode(%postHeader)}</title> <title>${xmlEncode(%postHeader)}</title>
<id>urn:entry:${%postId}</id> <id>urn:entry:${%postId}</id>
# let url = c.genThreadUrl(threadid = %postThread, # let url = c.genThreadUrl(threadid = %postThread,
# pageNum = $(ceil(parseInt(%postPosition) / postsPerPage).int)) & # pageNum = $(ceil(parseInt(%postPosition) / PostsPerPage).int)) &
# "#" & %postId # "#" & %postId
<link rel="alternate" type="text/html" <link rel="alternate" type="text/html"
href="${c.req.makeUri(url)}"/> href="${c.req.makeUri(url)}"/>
<published>${%postRssDate}</published> <published>${%postRssDate}</published>
<updated>${%postRssDate}</updated> <updated>${%postRssDate}</updated>
<author><name>${XMLEncode(%postAuthor)}</name></author> <author><name>${xmlEncode(%postAuthor)}</name></author>
<content type="html" <content type="html"
>On ${XMLEncode(%postHumanDate)}, ${XMLEncode(%postAuthor)} said: >On ${xmlEncode(%postHumanDate)}, ${xmlEncode(%postAuthor)} said:
&lt;p&gt; &lt;p&gt;
${XMLEncode(rstToHtml(%postContent))}</content> ${xmlEncode(rstToHtml(%postContent))}</content>
</entry> </entry>
# end for # end for
</feed> </feed>

11
nimforum.babel Normal file
View file

@ -0,0 +1,11 @@
[Package]
name = "nimforum"
version = "0.1.0"
author = "Dominik Picheta"
description = "Nimrod forum"
license = "MIT"
bin = "forum"
[Deps]
Requires: "nimrod >= 0.9.2, cairo#head, jester#head, bcrypt#head"

12
public/css/arrow.js Normal file
View file

@ -0,0 +1,12 @@
"use strict";
function positionGlowArrow() {
var headLinks = document.getElementById("head-links");
var activeLink = headLinks.getElementsByClassName("active")[0]
if (activeLink == undefined || activeLink == null)
return;
var offset = (headLinks.offsetWidth - activeLink.offsetLeft) - (activeLink.offsetWidth / 2) - 133;
var glowArrow = document.getElementById("glow-arrow");
glowArrow.style.right = offset + "px";
}

5
public/css/forum.js Normal file
View file

@ -0,0 +1,5 @@
"use strict";
window.onload = function() {
positionGlowArrow();
};

View file

@ -1,284 +0,0 @@
/*
* HTML5 Boilerplate
*
* What follows is the result of much research on cross-browser styling.
* Credit left inline and big thanks to Nicolas Gallagher, Jonathan Neal,
* Kroc Camen, and the H5BP dev community and team.
*
* Detailed information about this CSS: h5bp.com/css
*
* ==|== normalize ==========================================================
*/
/* =============================================================================
HTML5 display definitions
========================================================================== */
article, aside, details, figcaption, figure, footer, header, hgroup, nav, section { display: block; }
audio, canvas, video { display: inline-block; *display: inline; *zoom: 1; }
audio:not([controls]) { display: none; }
[hidden] { display: none; }
/* =============================================================================
Base
========================================================================== */
/*
* 1. Correct text resizing oddly in IE6/7 when body font-size is set using em units
* 2. Prevent iOS text size adjust on device orientation change, without disabling user zoom: h5bp.com/g
*/
html { font-size: 100%; -webkit-text-size-adjust: 100%; -ms-text-size-adjust: 100%; }
html, button, input, select, textarea { font-family: sans-serif; color: #222; }
body { margin: 0; font-size: 1em; line-height: 1.4; }
/* =============================================================================
Links
========================================================================== */
a { color: #00e; }
a:visited { color: #551a8b; }
a:hover { color: #06e; }
a:focus { outline: thin dotted; }
/* Improve readability when focused and hovered in all browsers: h5bp.com/h */
a:hover, a:active { outline: 0; }
/* =============================================================================
Typography
========================================================================== */
abbr[title] { border-bottom: 1px dotted; }
b, strong { font-weight: bold; }
blockquote { margin: 1em 40px; }
dfn { font-style: italic; }
hr { display: block; height: 1px; border: 0; border-top: 1px solid #ccc; margin: 1em 0; padding: 0; }
ins { background: #ff9; color: #000; text-decoration: none; }
mark { background: #ff0; color: #000; font-style: italic; font-weight: bold; }
/* Redeclare monospace font family: h5bp.com/j */
pre, code, kbd, samp { font-family: monospace, serif; _font-family: 'courier new', monospace; font-size: 1em; }
/* Improve readability of pre-formatted text in all browsers */
pre { white-space: pre; white-space: pre-wrap; word-wrap: break-word; }
q { quotes: none; }
q:before, q:after { content: ""; content: none; }
small { font-size: 85%; }
/* Position subscript and superscript content without affecting line-height: h5bp.com/k */
sub, sup { font-size: 75%; line-height: 0; position: relative; vertical-align: baseline; }
sup { top: -0.5em; }
sub { bottom: -0.25em; }
/* =============================================================================
Lists
========================================================================== */
ul, ol { margin: 1em 0; padding: 0 0 0 40px; }
dd { margin: 0 0 0 40px; }
nav ul, nav ol { list-style: none; list-style-image: none; margin: 0; padding: 0; }
/* =============================================================================
Embedded content
========================================================================== */
/*
* 1. Improve image quality when scaled in IE7: h5bp.com/d
* 2. Remove the gap between images and borders on image containers: h5bp.com/i/440
*/
img { border: 0; -ms-interpolation-mode: bicubic; vertical-align: middle; }
/*
* Correct overflow not hidden in IE9
*/
svg:not(:root) { overflow: hidden; }
/* =============================================================================
Figures
========================================================================== */
figure { margin: 0; }
/* =============================================================================
Forms
========================================================================== */
form { margin: 0; }
fieldset { border: 0; margin: 0; padding: 0; }
/* Indicate that 'label' will shift focus to the associated form element */
label { cursor: pointer; }
/*
* 1. Correct color not inheriting in IE6/7/8/9
* 2. Correct alignment displayed oddly in IE6/7
*/
legend { border: 0; *margin-left: -7px; padding: 0; white-space: normal; }
/*
* 1. Correct font-size not inheriting in all browsers
* 2. Remove margins in FF3/4 S5 Chrome
* 3. Define consistent vertical alignment display in all browsers
*/
button, input, select, textarea { font-size: 100%; margin: 0; vertical-align: baseline; *vertical-align: middle; }
/*
* 1. Define line-height as normal to match FF3/4 (set using !important in the UA stylesheet)
*/
button, input { line-height: normal; }
/*
* 1. Display hand cursor for clickable form elements
* 2. Allow styling of clickable form elements in iOS
* 3. Correct inner spacing displayed oddly in IE7 (doesn't effect IE6)
*/
button, input[type="button"], input[type="reset"], input[type="submit"] { cursor: pointer; -webkit-appearance: button; *overflow: visible; }
/*
* Re-set default cursor for disabled elements
*/
button[disabled], input[disabled] { cursor: default; }
/*
* Consistent box sizing and appearance
*/
input[type="checkbox"], input[type="radio"] { box-sizing: border-box; padding: 0; *width: 13px; *height: 13px; }
input[type="search"] { -webkit-appearance: textfield; -moz-box-sizing: content-box; -webkit-box-sizing: content-box; box-sizing: content-box; }
input[type="search"]::-webkit-search-decoration, input[type="search"]::-webkit-search-cancel-button { -webkit-appearance: none; }
/*
* Remove inner padding and border in FF3/4: h5bp.com/l
*/
button::-moz-focus-inner, input::-moz-focus-inner { border: 0; padding: 0; }
/*
* 1. Remove default vertical scrollbar in IE6/7/8/9
* 2. Allow only vertical resizing
*/
textarea { overflow: auto; vertical-align: top; resize: vertical; }
/* Colors for form validity */
input:valid, textarea:valid { }
input:invalid, textarea:invalid { background-color: #f0dddd; }
/* =============================================================================
Tables
========================================================================== */
table { border-collapse: collapse; border-spacing: 0; }
td { vertical-align: top; }
/* =============================================================================
Chrome Frame Prompt
========================================================================== */
.chromeframe { margin: 0.2em 0; background: #ccc; color: black; padding: 0.2em 0; }
/* ==|== primary styles =====================================================
Author:
========================================================================== */
/* ==|== media queries ======================================================
EXAMPLE Media Query for Responsive Design.
This example overrides the primary ('mobile first') styles
Modify as content requires.
========================================================================== */
@media only screen and (min-width: 35em) {
/* Style adjustments for viewports that meet the condition */
}
/* ==|== non-semantic helper classes ========================================
Please define your styles before this section.
========================================================================== */
/* For image replacement */
.ir { display: block; border: 0; text-indent: -999em; overflow: hidden; background-color: transparent; background-repeat: no-repeat; text-align: left; direction: ltr; *line-height: 0; }
.ir br { display: none; }
/* Hide from both screenreaders and browsers: h5bp.com/u */
.hidden { display: none !important; visibility: hidden; }
/* Hide only visually, but have it available for screenreaders: h5bp.com/v */
.visuallyhidden { border: 0; clip: rect(0 0 0 0); height: 1px; margin: -1px; overflow: hidden; padding: 0; position: absolute; width: 1px; }
/* Extends the .visuallyhidden class to allow the element to be focusable when navigated to via the keyboard: h5bp.com/p */
.visuallyhidden.focusable:active, .visuallyhidden.focusable:focus { clip: auto; height: auto; margin: 0; overflow: visible; position: static; width: auto; }
/* Hide visually and from screenreaders, but maintain layout */
.invisible { visibility: hidden; }
/* Contain floats: h5bp.com/q */
.clearfix:before, .clearfix:after { content: ""; display: table; }
.clearfix:after { clear: both; }
.clearfix { *zoom: 1; }
/* ==|== print styles =======================================================
Print styles.
Inlined to avoid required HTTP connection: h5bp.com/r
========================================================================== */
@media print {
* { background: transparent !important; color: black !important; box-shadow:none !important; text-shadow: none !important; filter:none !important; -ms-filter: none !important; } /* Black prints faster: h5bp.com/s */
a, a:visited { text-decoration: underline; }
a[href]:after { content: " (" attr(href) ")"; }
abbr[title]:after { content: " (" attr(title) ")"; }
.ir a:after, a[href^="javascript:"]:after, a[href^="#"]:after { content: ""; } /* Don't show links for images, or javascript/internal links */
pre, blockquote { border: 1px solid #999; page-break-inside: avoid; }
thead { display: table-header-group; } /* h5bp.com/t */
tr, img { page-break-inside: avoid; }
img { max-width: 100% !important; }
@page { margin: 0.5cm; }
p, h2, h3 { orphans: 3; widows: 3; }
h2, h3 { page-break-after: avoid; }
}

File diff suppressed because it is too large Load diff

BIN
public/images/bg.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 490 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 424 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

BIN
public/images/glow-line.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

BIN
public/images/head-link.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 799 B

BIN
public/images/head.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 B

BIN
public/images/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 897 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

12
public/js/arrow.js Normal file
View file

@ -0,0 +1,12 @@
"use strict";
function positionGlowArrow() {
var headLinks = document.getElementById("head-links");
var activeLink = headLinks.getElementsByClassName("active")[0]
if (activeLink == undefined || activeLink == null)
return;
var offset = (headLinks.offsetWidth - activeLink.offsetLeft) - (activeLink.offsetWidth / 2) - 133;
var glowArrow = document.getElementById("glow-arrow");
glowArrow.style.right = offset + "px";
}

5
public/js/forum.js Normal file
View file

@ -0,0 +1,5 @@
"use strict";
window.onload = function() {
positionGlowArrow();
};

35
static/search-help.rst Normal file
View file

@ -0,0 +1,35 @@
Full-text search for Nim forum
==============================
Syntax (using *SQLite* dll compiled without *Enhanced Query Syntax* support):
-----------------------------------------------------------------------------
- Only alphanumeric characters are searched.
- Only full words and words beginnings (e.g. ``Nim*`` for both ``Nimrod`` and ``Nim``) are searched
- All words are joined with implicit **AND** operator; there's no explicit one
- There's explicit **OR** operator (upper-case) and it has higher priority
- Words can be prepended with **-** to be excluded from search
- No parentheses support
- Quotes for phrases search, e.g. ``"programming language"``
- Distances between words/phrases can be specified putting ``NEAR`` or ``NEAR/some_number`` between them
Syntax - differences in *Enhanced Query Syntax* (should be enabled in *SQLite* dll):
------------------------------------------------------------------------------------
- **AND** and **NOT** logical operators available
- Precedence of operators is, from highest to lowest: **NOT**, **AND**, **OR**
- Parentheses for grouping are supported
Where search is performed:
--------------------------
- **Threads' titles** - these results are outputed first
- **Posts' titles** - middle precedence
- **Posts' contents** - the latest
How results are shown:
----------------------
- All results are ordered by date (posts' edits don't affect)
- Matched tokens in text are marked (bold or dotted underline)
- Threads title is the link to the thread and posts title is the link to the post