Compare commits

..

2 commits

Author SHA1 Message Date
Joey Yakimowich-Payne
9bab05a6af Fix tests 2020-03-05 17:24:23 -07:00
Joey Yakimowich-Payne
eee155f343 Make mobile viewing more friendly 2020-03-05 17:14:02 -07:00
23 changed files with 129 additions and 514 deletions

View file

@ -1,80 +0,0 @@
# This is a basic workflow to help you get started with Actions
name: CI
# Controls when the action will run.
on:
# Triggers the workflow on push or pull request events but only for the master branch
push:
branches: [ master ]
pull_request:
branches: [ master ]
# Allows you to run this workflow manually from the Actions tab
workflow_dispatch:
# A workflow run is made up of one or more jobs that can run sequentially or in parallel
jobs:
test_stable:
runs-on: ubuntu-latest
strategy:
matrix:
firefox: [ '73.0' ]
include:
- nim-version: 'stable'
cache-key: 'stable'
steps:
- uses: actions/checkout@v2
- name: Checkout submodules
run: git submodule update --init --recursive
- name: Setup firefox
uses: browser-actions/setup-firefox@latest
with:
firefox-version: ${{ matrix.firefox }}
- name: Get Date
id: get-date
run: echo "::set-output name=date::$(date "+%Y-%m-%d")"
shell: bash
- name: Cache choosenim
uses: actions/cache@v2
with:
path: ~/.choosenim
key: ${{ runner.os }}-choosenim-${{ matrix.cache-key }}
- name: Cache nimble
uses: actions/cache@v2
with:
path: ~/.nimble
key: ${{ runner.os }}-nimble-${{ hashFiles('*.nimble') }}
- uses: jiro4989/setup-nim-action@v1
with:
nim-version: "${{ matrix.nim-version }}"
- name: Install geckodriver
run: |
sudo apt-get -qq update
sudo apt-get install autoconf libtool libsass-dev
wget https://github.com/mozilla/geckodriver/releases/download/v0.29.1/geckodriver-v0.29.1-linux64.tar.gz
mkdir geckodriver
tar -xzf geckodriver-v0.29.1-linux64.tar.gz -C geckodriver
export PATH=$PATH:$PWD/geckodriver
- name: Install choosenim
run: |
export CHOOSENIM_CHOOSE_VERSION="stable"
curl https://nim-lang.org/choosenim/init.sh -sSf > init.sh
sh init.sh -y
export PATH=$HOME/.nimble/bin:$PATH
nimble refresh -y
- name: Run tests
run: |
export MOZ_HEADLESS=1
nimble -y install
nimble -y test

45
.travis.yml Normal file
View file

@ -0,0 +1,45 @@
os:
- linux
language: c
cache:
directories:
- "$HOME/.nimble"
- "$HOME/.choosenim"
addons:
firefox: "73.0"
before_install:
- sudo apt-get -qq update
- sudo apt-get install autoconf libtool
- git clone -b 3.5.4 https://github.com/sass/libsass.git
- cd libsass
- autoreconf --force --install
- |
./configure \
--disable-tests \
--disable-static \
--enable-shared \
--prefix=/usr
- sudo make -j5 install
- cd ..
- wget https://github.com/mozilla/geckodriver/releases/download/v0.26.0/geckodriver-v0.26.0-linux64.tar.gz
- mkdir geckodriver
- tar -xzf geckodriver-v0.26.0-linux64.tar.gz -C geckodriver
- export PATH=$PATH:$PWD/geckodriver
install:
- export CHOOSENIM_CHOOSE_VERSION="stable"
- |
curl https://nim-lang.org/choosenim/init.sh -sSf > init.sh
sh init.sh -y
- export PATH=$HOME/.nimble/bin:$PATH
- nimble refresh -y
script:
- export MOZ_HEADLESS=1
- nimble -y install
- nimble -y test

View file

@ -80,6 +80,7 @@ nimble frontend
nimble backend
```
Development typically involves running `nimble devdb` which sets up the
database for development and testing, then `nimble backend`
which compiles and runs the forum's backend, and `nimble frontend`
@ -87,38 +88,6 @@ separately to build the frontend. When making changes to the frontend it
should be enough to simply run `nimble frontend` again to rebuild. This command
will also build the SASS ``nimforum.scss`` file in the `public/css` directory.
### With docker
You can easily launch site on localhost if you have `docker` and `docker-compose`.
You don't have to setup dependencies (libsass, sglite, pcre, etc...) on you host PC.
To get up and running:
```bash
cd docker
docker-compose build
docker-compose up
```
And you can access local NimForum site.
Open http://localhost:5000 .
# Troubleshooting
You might have to run `nimble install karax@#5f21dcd`, if setup fails
with:
```
andinus@circinus ~/projects/forks/nimforum> nimble --verbose devdb
[...]
Installing karax@#5f21dcd
Tip: 24 messages have been suppressed, use --verbose to show them.
Error: No binaries built, did you specify a valid binary name?
[...]
Error: Exception raised during nimble script execution
```
The hash needs to be replaced with the one specified in output.
# Copyright

View file

@ -1,14 +0,0 @@
FROM nimlang/nim:1.2.6-ubuntu
RUN apt-get update -yqq \
&& apt-get install -y --no-install-recommends \
libsass-dev \
sqlite3 \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY . /app
# install dependencies
RUN nimble install -Y

View file

@ -1,12 +0,0 @@
version: "3.7"
services:
forum:
build:
context: ../
dockerfile: ./docker/Dockerfile
volumes:
- "../:/app"
ports:
- "5000:5000"
entrypoint: "/app/docker/entrypoint.sh"

View file

@ -1,19 +0,0 @@
#!/bin/sh
set -eu
git submodule update --init --recursive
# setup
nimble c -d:release src/setup_nimforum.nim
./src/setup_nimforum --dev
# build frontend
nimble c -r src/buildcss
nimble js -d:release src/frontend/forum.nim
mkdir -p public/js
cp src/frontend/forum.js public/js/forum.js
# build backend
nimble c src/forum.nim
./src/forum

View file

@ -1,5 +1,5 @@
# Package
version = "2.1.0"
version = "2.0.2"
author = "Dominik Picheta"
description = "The Nim forum"
license = "MIT"
@ -13,13 +13,13 @@ skipExt = @["nim"]
# Dependencies
requires "nim >= 1.0.6"
requires "jester#405be2e"
requires "bcrypt#440c5676ff6"
requires "jester#d8a03aa"
requires "bcrypt#head"
requires "hmac#9c61ebe2fd134cf97"
requires "recaptcha#d06488e"
requires "sass#649e0701fa5c"
requires "karax#5f21dcd"
requires "karax#f6bda9a"
requires "webdriver#429933a"

View file

@ -22,7 +22,7 @@ table th {
// Custom styles.
// - Navigation bar.
$navbar-height: 60px;
$default-category-color: #a3a3a3;
$default-category-color: #98c766;
$logo-height: $navbar-height - 20px;
.navbar-button {
@ -301,14 +301,10 @@ $threads-meta-color: #545d70;
}
}
.thread-replies, .thread-time, .views-text, .popular-text, .centered-header {
.thread-replies, .thread-time, .thread-users, .views-text, .centered-header {
text-align: center;
}
.thread-users {
text-align: left;
}
.thread-time {
color: $threads-meta-color;

View file

@ -44,18 +44,8 @@ proc sendMail(
warn("Cannot send mail: no smtp from address configured (smtpFromAddr).")
return
var client: AsyncSmtp
if mailer.config.smtpTls:
client = newAsyncSmtp(useSsl=false)
await client.connect(mailer.config.smtpAddress, Port(mailer.config.smtpPort))
await client.startTls()
elif mailer.config.smtpSsl:
client = newAsyncSmtp(useSsl=true)
await client.connect(mailer.config.smtpAddress, Port(mailer.config.smtpPort))
else:
client = newAsyncSmtp(useSsl=false)
await client.connect(mailer.config.smtpAddress, Port(mailer.config.smtpPort))
var client = newAsyncSmtp()
await client.connect(mailer.config.smtpAddress, Port(mailer.config.smtpPort))
if mailer.config.smtpUser.len > 0:
await client.auth(mailer.config.smtpUser, mailer.config.smtpPassword)
@ -64,9 +54,6 @@ proc sendMail(
var headers = otherHeaders
headers.add(("From", mailer.config.smtpFromAddr))
let dateHeader = now().utc().format("ddd, dd MMM yyyy hh:mm:ss") & " +0000"
headers.add(("Date", dateHeader))
let encoded = createMessage(subject, message,
toList, @[], headers)

View file

@ -276,26 +276,25 @@ template createTFD() =
new(c)
init(c)
c.req = request
if cookies(request).len > 0:
if request.cookies.len > 0:
checkLoggedIn(c)
#[ DB functions. TODO: Move to another module? ]#
proc selectUser(userRow: seq[string], avatarSize: int=80): User =
result = User(
id: userRow[0],
name: userRow[1],
avatarUrl: userRow[2].getGravatarUrl(avatarSize),
lastOnline: userRow[3].parseInt,
previousVisitAt: userRow[4].parseInt,
rank: parseEnum[Rank](userRow[5]),
isDeleted: userRow[6] == "1"
name: userRow[0],
avatarUrl: userRow[1].getGravatarUrl(avatarSize),
lastOnline: userRow[2].parseInt,
previousVisitAt: userRow[3].parseInt,
rank: parseEnum[Rank](userRow[4]),
isDeleted: userRow[5] == "1"
)
# Don't give data about a deleted user.
if result.isDeleted:
result.name = "DeletedUser"
result.avatarUrl = getGravatarUrl(result.name & userRow[2], avatarSize)
result.avatarUrl = getGravatarUrl(result.name & userRow[1], avatarSize)
proc selectPost(postRow: seq[string], skippedPosts: seq[int],
replyingTo: Option[PostLink], history: seq[PostInfo],
@ -303,7 +302,7 @@ proc selectPost(postRow: seq[string], skippedPosts: seq[int],
return Post(
id: postRow[0].parseInt,
replyingTo: replyingTo,
author: selectUser(postRow[5..11]),
author: selectUser(postRow[5..10]),
likes: likes,
seen: false, # TODO:
history: history,
@ -319,7 +318,7 @@ proc selectReplyingTo(replyingTo: string): Option[PostLink] =
const replyingToQuery = sql"""
select p.id, strftime('%s', p.creation), p.thread,
u.id, u.name, u.email, strftime('%s', u.lastOnline),
u.name, u.email, strftime('%s', u.lastOnline),
strftime('%s', u.previousVisitAt), u.status,
u.isDeleted,
t.name
@ -335,7 +334,7 @@ proc selectReplyingTo(replyingTo: string): Option[PostLink] =
topic: row[^1],
threadId: row[2].parseInt(),
postId: row[0].parseInt(),
author: some(selectUser(row[3..9]))
author: some(selectUser(row[3..8]))
))
proc selectHistory(postId: int): seq[PostInfo] =
@ -354,7 +353,7 @@ proc selectHistory(postId: int): seq[PostInfo] =
proc selectLikes(postId: int): seq[User] =
const likeQuery = sql"""
select u.id, u.name, u.email, strftime('%s', u.lastOnline),
select u.name, u.email, strftime('%s', u.lastOnline),
strftime('%s', u.previousVisitAt), u.status,
u.isDeleted
from like h, person u
@ -369,7 +368,7 @@ proc selectLikes(postId: int): seq[User] =
proc selectThreadAuthor(threadId: int): User =
const authorQuery =
sql"""
select id, name, email, strftime('%s', lastOnline),
select name, email, strftime('%s', lastOnline),
strftime('%s', previousVisitAt), status, isDeleted
from person where id in (
select author from post
@ -387,7 +386,7 @@ proc selectThread(threadRow: seq[string], author: User): Thread =
where thread = ?;"""
const usersListQuery =
sql"""
select u.id, name, email, strftime('%s', lastOnline),
select name, email, strftime('%s', lastOnline),
strftime('%s', previousVisitAt), status, u.isDeleted,
count(*)
from person u, post p where p.author = u.id and p.thread = ?
@ -400,10 +399,10 @@ proc selectThread(threadRow: seq[string], author: User): Thread =
id: threadRow[0].parseInt,
topic: threadRow[1],
category: Category(
id: threadRow[6].parseInt,
name: threadRow[7],
description: threadRow[8],
color: threadRow[9]
id: threadRow[5].parseInt,
name: threadRow[6],
description: threadRow[7],
color: threadRow[8]
),
users: @[],
replies: posts[0].parseInt-1,
@ -412,7 +411,6 @@ proc selectThread(threadRow: seq[string], author: User): Thread =
creation: posts[1].parseInt,
isLocked: threadRow[4] == "1",
isSolved: false, # TODO: Add a field to `post` to identify the solution.
isPinned: threadRow[5] == "1"
)
# Gather the users list.
@ -500,10 +498,10 @@ proc updatePost(c: TForumData, postId: int, content: string,
# Verify that the current user has permissions to edit the specified post.
let creation = fromUnix(postRow[1].parseInt)
let isArchived = (getTime() - creation).inHours >= 2
let isArchived = (getTime() - creation).inWeeks > 8
let canEdit = c.rank == Admin or c.userid == postRow[0]
if isArchived and c.rank < Admin:
raise newForumError("This post is too old and can no longer be edited")
if isArchived:
raise newForumError("This post is archived and can no longer be edited")
if not canEdit:
raise newForumError("You cannot edit this post")
@ -534,7 +532,7 @@ proc updateThread(c: TForumData, threadId: string, queryKeys: seq[string], query
let threadAuthor = selectThreadAuthor(threadId.parseInt)
# Verify that the current user has permissions to edit the specified thread.
let canEdit = c.rank in {Admin, Moderator} or c.userid == threadAuthor.id
let canEdit = c.rank == Admin or c.userid == threadAuthor.name
if not canEdit:
raise newForumError("You cannot edit this thread")
@ -710,25 +708,15 @@ proc executeLockState(c: TForumData, threadId: int, locked: bool) =
# Save the like.
exec(db, crud(crUpdate, "thread", "isLocked"), locked.int, threadId)
proc executePinState(c: TForumData, threadId: int, pinned: bool) =
if c.rank < Moderator:
raise newForumError("You do not have permission to pin this thread.")
# (Un)pin this thread
exec(db, crud(crUpdate, "thread", "isPinned"), pinned.int, threadId)
proc executeDeletePost(c: TForumData, postId: int) =
# Verify that this post belongs to the user.
const postQuery = sql"""
select p.author, p.id from post p
select p.id from post p
where p.author = ? and p.id = ?
"""
let
row = getRow(db, postQuery, c.username, postId)
author = row[0]
id = row[1]
let id = getValue(db, postQuery, c.username, postId)
if id.len == 0 and not (c.rank == Admin or c.userid == author):
if id.len == 0 and c.rank < Admin:
raise newForumError("You cannot delete this post")
# Set the `isDeleted` flag.
@ -783,7 +771,7 @@ proc updateProfile(
raise newForumError("Rank needs a change when setting new email.")
await sendSecureEmail(
mailer, ActivateEmail, c.req, row[0], row[1], email, row[3]
mailer, ActivateEmail, c.req, row[0], row[1], row[2], row[3]
)
validateEmail(email, checkDuplicated=wasEmailChanged)
@ -841,27 +829,27 @@ routes:
categoryArgs.insert($categoryId, 0)
const threadsQuery =
"""select t.id, t.name, views, strftime('%s', modified), isLocked, isPinned,
"""select t.id, t.name, views, strftime('%s', modified), isLocked,
c.id, c.name, c.description, c.color,
u.id, u.name, u.email, strftime('%s', u.lastOnline),
u.name, u.email, strftime('%s', u.lastOnline),
strftime('%s', u.previousVisitAt), u.status, u.isDeleted
from thread t, category c, person u
where t.isDeleted = 0 and category = c.id and $#
u.status <> 'Spammer' and u.status <> 'Troll' and
u.id = (
select p.author from post p
where p.thread = t.id
order by p.author
u.id in (
select u.id from post p, person u
where p.author = u.id and p.thread = t.id
order by u.id
limit 1
)
order by isPinned desc, modified desc limit ?, ?;"""
order by modified desc limit ?, ?;"""
let thrCount = getValue(db, countQuery, countArgs).parseInt()
let moreCount = max(0, thrCount - (start + count))
var list = ThreadList(threads: @[], moreCount: moreCount)
for data in getAllRows(db, sql(threadsQuery % categorySection), categoryArgs):
let thread = selectThread(data[0 .. 9], selectUser(data[10 .. ^1]))
let thread = selectThread(data[0 .. 8], selectUser(data[9 .. ^1]))
list.threads.add(thread)
resp $(%list), "application/json"
@ -876,24 +864,19 @@ routes:
count = 10
const threadsQuery =
sql"""select t.id, t.name, views, strftime('%s', modified), isLocked, isPinned,
sql"""select t.id, t.name, views, strftime('%s', modified), isLocked,
c.id, c.name, c.description, c.color
from thread t, category c
where t.id = ? and isDeleted = 0 and category = c.id;"""
let threadRow = getRow(db, threadsQuery, id)
if threadRow[0].len == 0:
let err = PostError(
message: "Specified thread does not exist"
)
resp Http404, $(%err), "application/json"
let thread = selectThread(threadRow, selectThreadAuthor(id))
let postsQuery =
sql(
"""select p.id, p.content, strftime('%s', p.creation), p.author,
p.replyingTo,
u.id, u.name, u.email, strftime('%s', u.lastOnline),
u.name, u.email, strftime('%s', u.lastOnline),
strftime('%s', u.previousVisitAt), u.status,
u.isDeleted
from post p, person u
@ -932,20 +915,15 @@ routes:
get "/specific_posts.json":
createTFD()
var ids: JsonNode
try:
var
ids = parseJson(@"ids")
except JsonParsingError:
let err = PostError(
message: "Invalid JSON in the `ids` parameter"
)
resp Http400, $(%err), "application/json"
cond ids.kind == JArray
let intIDs = ids.elems.map(x => x.getInt())
let postsQuery = sql("""
select p.id, p.content, strftime('%s', p.creation), p.author,
p.replyingTo,
u.id, u.name, u.email, strftime('%s', u.lastOnline),
u.name, u.email, strftime('%s', u.lastOnline),
strftime('%s', u.previousVisitAt), u.status,
u.isDeleted
from post p, person u
@ -1014,7 +992,7 @@ routes:
""" % postsFrom)
let userQuery = sql("""
select id, name, email, strftime('%s', lastOnline),
select name, email, strftime('%s', lastOnline),
strftime('%s', previousVisitAt), status, isDeleted,
strftime('%s', creation), id
from person
@ -1040,7 +1018,7 @@ routes:
getValue(db, sql("select count(*) " & threadsFrom), userID).parseInt()
if c.rank >= Admin or c.username == username:
profile.email = some(userRow[2])
profile.email = some(userRow[1])
for row in db.getAllRows(postsQuery, username):
profile.posts.add(
@ -1357,33 +1335,6 @@ routes:
except ForumError as exc:
resp Http400, $(%exc.data), "application/json"
post re"/(pin|unpin)":
createTFD()
if not c.loggedIn():
let err = PostError(
errorFields: @[],
message: "Not logged in."
)
resp Http401, $(%err), "application/json"
let formData = request.formData
cond "id" in formData
let threadId = getInt(formData["id"].body, -1)
cond threadId != -1
try:
case request.path
of "/pin":
executePinState(c, threadId, true)
of "/unpin":
executePinState(c, threadId, false)
else:
assert false
resp Http200, "{}", "application/json"
except ForumError as exc:
resp Http400, $(%exc.data), "application/json"
post re"/delete(Post|Thread)":
createTFD()
if not c.loggedIn():
@ -1616,7 +1567,7 @@ routes:
postId: rowFT[2].parseInt(),
postContent: content,
creation: rowFT[4].parseInt(),
author: selectUser(rowFT[5 .. 11]),
author: selectUser(rowFT[5 .. 10]),
)
)

View file

@ -96,8 +96,8 @@ when defined(js):
section(class="navbar-section"):
tdiv(class="input-group input-inline"):
input(class="search-input input-sm",
`type`="search", placeholder="Search",
id="search-box", required="required",
`type`="text", placeholder="search",
id="search-box",
onKeyDown=onKeyDown)
if state.loading:
tdiv(class="loading")

View file

@ -64,4 +64,4 @@ when defined(js):
renderPostUrl(thread.id, post.id)
proc renderPostUrl*(link: PostLink): string =
renderPostUrl(link.threadId, link.postId)
renderPostUrl(link.threadId, link.postId)

View file

@ -190,7 +190,7 @@ when defined(js):
else: ""
result = buildHtml():
button(class="btn btn-secondary", id="lock-btn",
button(class="btn btn-secondary",
onClick=(e: Event, n: VNode) =>
onLockClick(e, n, state, thread),
"data-tooltip"=tooltip,
@ -201,61 +201,4 @@ when defined(js):
text " Unlock Thread"
else:
italic(class="fas fa-lock")
text " Lock Thread"
type
PinButton* = ref object
error: Option[PostError]
loading: bool
proc newPinButton*(): PinButton =
PinButton()
proc onPost(httpStatus: int, response: kstring, state: PinButton,
thread: var Thread) =
postFinished:
thread.isPinned = not thread.isPinned
proc onPinClick(ev: Event, n: VNode, state: PinButton, thread: var Thread) =
if state.loading: return
state.loading = true
state.error = none[PostError]()
# Same as LockButton so the following is still a hack and karax should support this.
var formData = newFormData()
formData.append("id", $thread.id)
let uri =
if thread.isPinned:
makeUri("/unpin")
else:
makeUri("/pin")
ajaxPost(uri, @[], formData.to(cstring),
(s: int, r: kstring) => onPost(s, r, state, thread))
ev.preventDefault()
proc render*(state: PinButton, thread: var Thread,
currentUser: Option[User]): VNode =
if currentUser.isNone() or
currentUser.get().rank < Moderator:
return buildHtml(tdiv())
let tooltip =
if state.error.isSome(): state.error.get().message
else: ""
result = buildHtml():
button(class="btn btn-secondary", id="pin-btn",
onClick=(e: Event, n: VNode) =>
onPinClick(e, n, state, thread),
"data-tooltip"=tooltip,
onmouseleave=(e: Event, n: VNode) =>
(state.error = none[PostError]())):
if thread.isPinned:
italic(class="fas fa-thumbtack")
text " Unpin Thread"
else:
italic(class="fas fa-thumbtack")
text " Pin Thread"
text " Lock Thread"

View file

@ -36,7 +36,6 @@ when defined(js):
likeButton: LikeButton
deleteModal: DeleteModal
lockButton: LockButton
pinButton: PinButton
categoryPicker: CategoryPicker
proc onReplyPosted(id: int)
@ -57,7 +56,6 @@ when defined(js):
likeButton: newLikeButton(),
deleteModal: newDeleteModal(onDeletePost, onDeleteThread, nil),
lockButton: newLockButton(),
pinButton: newPinButton(),
categoryPicker: newCategoryPicker(onCategoryChanged)
)
@ -211,12 +209,12 @@ when defined(js):
let loggedIn = currentUser.isSome()
let authoredByUser =
loggedIn and currentUser.get().name == thread.author.name
let canChangeCategory =
loggedIn and currentUser.get().rank in {Admin, Moderator}
let currentAdmin =
currentUser.isSome() and currentUser.get().rank == Admin
result = buildHtml():
tdiv():
if authoredByUser or canChangeCategory:
if authoredByUser or currentAdmin:
render(state.categoryPicker, currentUser, compact=false)
else:
render(thread.category)
@ -413,7 +411,6 @@ when defined(js):
text " Reply"
render(state.lockButton, list.thread, currentUser)
render(state.pinButton, list.thread, currentUser)
render(state.replyBox, list.thread, state.replyingTo, false)

View file

@ -15,7 +15,6 @@ type
creation*: int64 ## Unix timestamp
isLocked*: bool
isSolved*: bool
isPinned*: bool
ThreadList* = ref object
threads*: seq[Thread]
@ -97,18 +96,15 @@ when defined(js):
else:
return $duration.inSeconds & "s"
proc genThread(pos: int, thread: Thread, isNew: bool, noBorder: bool, displayCategory=true): VNode =
proc genThread(thread: Thread, isNew: bool, noBorder: bool, displayCategory=true): VNode =
let isOld = (getTime() - thread.creation.fromUnix).inWeeks > 2
let isBanned = thread.author.rank.isBanned()
result = buildHtml():
tr(class=class({"no-border": noBorder, "banned": isBanned, "pinned": thread.isPinned, "thread-" & $pos: true})):
tr(class=class({"no-border": noBorder, "banned": isBanned})):
td(class="thread-title"):
if thread.isLocked:
italic(class="fas fa-lock fa-xs",
title="Thread cannot be replied to")
if thread.isPinned:
italic(class="fas fa-thumbtack fa-xs",
title="Pinned post")
if isBanned:
italic(class="fas fa-ban fa-xs",
title="Thread author is banned")
@ -120,11 +116,8 @@ when defined(js):
title="Thread has a solution")
a(href=makeUri("/t/" & $thread.id), onClick=anchorCB):
text thread.topic
tdiv(class="show-sm" & class({"d-none": not displayCategory})):
tdiv(class=class({"d-none": not displayCategory})):
render(thread.category)
td(class="hide-sm" & class({"d-none": not displayCategory})):
render(thread.category)
genUserAvatars(thread.users)
td(class="thread-replies"): text $thread.replies
td(class="hide-sm" & class({
@ -206,7 +199,7 @@ when defined(js):
return buildHtml(tdiv(class="loading loading-lg"))
let displayCategory = categoryId.isNone
let displayCategory = true
let list = state.list.get()
result = buildHtml():
@ -215,8 +208,7 @@ when defined(js):
thead():
tr:
th(text "Topic")
th(class="hide-sm" & class({"d-none": not displayCategory})): text "Category"
th(class="thread-users"): text "Users"
th(class="centered-header"): text "Users"
th(class="centered-header"): text "Replies"
th(class="hide-sm centered-header"): text "Views"
th(class="centered-header"): text "Activity"
@ -227,7 +219,7 @@ when defined(js):
let isLastThread = i+1 == list.threads.len
let (isLastUnseen, isNew) = getInfo(list.threads, i, currentUser)
genThread(i+1, thread, isNew,
genThread(thread, isNew,
noBorder=isLastUnseen or isLastThread,
displayCategory=displayCategory)
if isLastUnseen and (not isLastThread):

View file

@ -17,7 +17,6 @@ type
Admin ## Admin: can do everything
User* = object
id*: string
name*: string
avatarUrl*: string
lastOnline*: int64
@ -76,4 +75,4 @@ when defined(js):
title="User is a moderator")
of Admin:
italic(class="fas fa-chess-knight",
title="User is an admin")
title="User is an admin")

View file

@ -7,7 +7,6 @@ SELECT
post_id,
post_content,
cdate,
person.id,
person.name AS author,
person.email AS email,
strftime('%s', person.lastOnline) AS lastOnline,
@ -47,7 +46,6 @@ SELECT
THEN snippet(post_fts, '**', '**', '...', what, -45)
ELSE SUBSTR(post_fts.content, 1, 200) END AS content,
cdate,
person.id,
person.name AS author,
person.email AS email,
strftime('%s', person.lastOnline) AS lastOnline,

View file

@ -66,7 +66,7 @@ proc initialiseDb(admin: tuple[username, password, email: string],
db.exec(sql"""
insert into category (id, name, description, color)
values (0, 'Unsorted', 'No category has been chosen yet.', '');
values (0, 'Default', 'The default category', '');
""")
# -- Thread
@ -81,7 +81,6 @@ proc initialiseDb(admin: tuple[username, password, email: string],
isLocked boolean not null default 0,
solution integer,
isDeleted boolean not null default 0,
isPinned boolean not null default 0,
foreign key (category) references category(id),
foreign key (solution) references post(id)
@ -235,7 +234,7 @@ proc initialiseDb(admin: tuple[username, password, email: string],
proc initialiseConfig(
name, title, hostname: string,
recaptcha: tuple[siteKey, secretKey: string],
smtp: tuple[address, user, password, fromAddr: string, tls: bool],
smtp: tuple[address, user, password, fromAddr: string],
isDev: bool,
dbPath: string,
ga: string=""
@ -252,7 +251,6 @@ proc initialiseConfig(
"smtpUser": %smtp.user,
"smtpPassword": %smtp.password,
"smtpFromAddr": %smtp.fromAddr,
"smtpTls": %smtp.tls,
"isDev": %isDev,
"dbPath": %dbPath
}
@ -284,7 +282,7 @@ These can be changed later in the generated forum.json file.
echo("")
echo("The following question are related to recaptcha. \nYou must set up a " &
"recaptcha v2 for your forum before answering them. \nPlease do so now " &
"recaptcha for your forum before answering them. \nPlease do so now " &
"and then answer these questions: https://www.google.com/recaptcha/admin")
let recaptchaSiteKey = question("Recaptcha site key: ")
let recaptchaSecretKey = question("Recaptcha secret key: ")
@ -296,7 +294,6 @@ These can be changed later in the generated forum.json file.
let smtpUser = question("SMTP user: ")
let smtpPassword = readPasswordFromStdin("SMTP pass: ")
let smtpFromAddr = question("SMTP sending email address (eg: mail@mail.hostname.com): ")
let smtpTls = parseBool(question("Enable TLS for SMTP: "))
echo("The following is optional. You can specify your Google Analytics ID " &
"if you wish. Otherwise just leave it blank.")
@ -306,7 +303,7 @@ These can be changed later in the generated forum.json file.
let dbPath = "nimforum.db"
initialiseConfig(
name, title, hostname, (recaptchaSiteKey, recaptchaSecretKey),
(smtpAddress, smtpUser, smtpPassword, smtpFromAddr, smtpTls), isDev=false,
(smtpAddress, smtpUser, smtpPassword, smtpFromAddr), isDev=false,
dbPath, ga
)
@ -341,7 +338,7 @@ when isMainModule:
"Development Forum",
"localhost",
recaptcha=("", ""),
smtp=("", "", "", "", false),
smtp=("", "", "", ""),
isDev=true,
dbPath
)
@ -358,7 +355,7 @@ when isMainModule:
"Test Forum",
"localhost",
recaptcha=("", ""),
smtp=("", "", "", "", false),
smtp=("", "", "", ""),
isDev=true,
dbPath
)

View file

@ -1,11 +1,11 @@
import asyncdispatch, smtp, strutils, json, os, rst, rstgen, xmltree, strtabs,
htmlparser, streams, parseutils, options, logging
from times import getTime, utc, format
from times import getTime, getGMTime, format
# Used to be:
# {'A'..'Z', 'a'..'z', '0'..'9', '_', '\128'..'\255'}
let
UsernameIdent* = IdentChars + {'-'} # TODO: Double check that everyone follows this.
UsernameIdent* = IdentChars # TODO: Double check that everyone follows this.
import frontend/[karaxutils, error]
export parseInt
@ -17,8 +17,6 @@ type
smtpUser*: string
smtpPassword*: string
smtpFromAddr*: string
smtpTls*: bool
smtpSsl*: bool
mlistAddress*: string
recaptchaSecretKey*: string
recaptchaSiteKey*: string
@ -57,8 +55,6 @@ proc loadConfig*(filename = getCurrentDir() / "forum.json"): Config =
result.smtpUser = root{"smtpUser"}.getStr("")
result.smtpPassword = root{"smtpPassword"}.getStr("")
result.smtpFromAddr = root{"smtpFromAddr"}.getStr("")
result.smtpTls = root{"smtpTls"}.getBool(false)
result.smtpSsl = root{"smtpSsl"}.getBool(false)
result.mlistAddress = root{"mlistAddress"}.getStr("")
result.recaptchaSecretKey = root{"recaptchaSecretKey"}.getStr("")
result.recaptchaSiteKey = root{"recaptchaSiteKey"}.getStr("")

View file

@ -69,29 +69,6 @@ proc categoriesUserTests(session: Session, baseUrl: string) =
ensureExists title, LinkTextSelector
test "can create category thread and change category":
with session:
let newTitle = title & " Selection"
click "#new-thread-btn"
sendKeys "#thread-title", newTitle
selectCategory "fun"
sendKeys "#reply-textarea", content
click "#create-thread-btn"
checkText "#thread-title .category", "Fun"
selectCategory "announcements"
checkText "#thread-title .category", "Announcements"
# Make sure there is no error
checkIsNone "#thread-title .text-error"
navigate baseUrl
ensureExists newTitle, LinkTextSelector
test "can navigate to categories page":
with session:
click "#categories-btn"
@ -125,7 +102,7 @@ proc categoriesUserTests(session: Session, baseUrl: string) =
click "#new-thread-btn"
sendKeys "#thread-title", "Post 3"
selectCategory "unsorted"
selectCategory "default"
sendKeys "#reply-textarea", "Post 3"
click "#create-thread-btn"
@ -135,11 +112,11 @@ proc categoriesUserTests(session: Session, baseUrl: string) =
click "#categories-btn"
ensureExists "#categories-list"
click "#category-unsorted"
click "#category-default"
checkText "#threads-list .thread-title a", "Post 3"
for element in session.waitForElements("#threads-list .category-name"):
# Have to user "innerText" because elements are hidden on this page
assert element.getProperty("innerText") == "Unsorted"
assert element.getProperty("innerText") == "Default"
selectCategory "announcements"
checkText "#threads-list .thread-title a", "Post 2"

View file

@ -30,8 +30,7 @@ proc elementIsSome(element: Option[Element]): bool =
proc elementIsNone(element: Option[Element]): bool =
return element.isNone
proc waitForElement*(session: Session, selector: string, strategy=CssSelector, timeout=20000, pollTime=50,
waitCondition: proc(element: Option[Element]): bool = elementIsSome): Option[Element]
proc waitForElement*(session: Session, selector: string, strategy=CssSelector, timeout=20000, pollTime=50, waitCondition=elementIsSome): Option[Element]
proc click*(session: Session, element: string, strategy=CssSelector) =
let el = session.waitForElement(element, strategy)
@ -72,14 +71,14 @@ proc setColor*(session: Session, element, color: string, strategy=CssSelector) =
proc checkIsNone*(session: Session, element: string, strategy=CssSelector) =
discard session.waitForElement(element, strategy, waitCondition=elementIsNone)
template checkText*(session: Session, element, expectedValue: string) =
proc checkText*(session: Session, element, expectedValue: string) =
let el = session.waitForElement(element)
check el.get().getText() == expectedValue
proc waitForElement*(
session: Session, selector: string, strategy=CssSelector,
timeout=20000, pollTime=50,
waitCondition: proc(element: Option[Element]): bool = elementIsSome
waitCondition=elementIsSome
): Option[Element] =
var waitTime = 0

View file

@ -40,4 +40,4 @@ proc test*(session: Session, baseUrl: string) =
register "TEst1", "test1", verify = false
ensureExists "#signup-form .has-error"
navigate baseUrl
navigate baseUrl

View file

@ -1,4 +1,5 @@
import unittest, common
import webdriver
let
@ -39,53 +40,10 @@ proc userTests(session: Session, baseUrl: string) =
checkText "#thread-title .title-text", userTitleStr
checkText ".original-post div.post-content", userContentStr
test "can delete thread":
with session:
# create thread to be deleted
click "#new-thread-btn"
sendKeys "#thread-title", "To be deleted"
sendKeys "#reply-textarea", "This thread is to be deleted"
click "#create-thread-btn"
click ".post-buttons .delete-button"
# click delete confirmation
click "#delete-modal .delete-btn"
# Make sure the forum post is gone
checkIsNone "To be deleted", LinkTextSelector
test "cannot (un)pin thread":
with session:
navigate(baseUrl)
click "#new-thread-btn"
sendKeys "#thread-title", "Unpinnable"
sendKeys "#reply-textarea", "Cannot (un)pin as an user"
click "#create-thread-btn"
checkIsNone "#pin-btn"
test "cannot lock threads":
with session:
navigate(baseUrl)
click "#new-thread-btn"
sendKeys "#thread-title", "Locking"
sendkeys "#reply-textarea", "Cannot lock as an user"
click "#create-thread-btn"
checkIsNone "#lock-btn"
session.logout()
proc anonymousTests(session: Session, baseUrl: string) =
suite "anonymous user tests":
with session:
navigate baseUrl
@ -185,70 +143,6 @@ proc adminTests(session: Session, baseUrl: string) =
# Make sure the forum post is gone
checkIsNone adminTitleStr, LinkTextSelector
test "can pin a thread":
with session:
click "#new-thread-btn"
sendKeys "#thread-title", "Pinned post"
sendKeys "#reply-textarea", "A pinned post"
click "#create-thread-btn"
navigate(baseUrl)
click "#new-thread-btn"
sendKeys "#thread-title", "Normal post"
sendKeys "#reply-textarea", "A normal post"
click "#create-thread-btn"
navigate(baseUrl)
click "Pinned post", LinkTextSelector
click "#pin-btn"
checkText "#pin-btn", "Unpin Thread"
navigate(baseUrl)
# Make sure pin exists
ensureExists "#threads-list .thread-1 .thread-title i"
checkText "#threads-list .thread-1 .thread-title a", "Pinned post"
checkText "#threads-list .thread-2 .thread-title a", "Normal post"
test "can unpin a thread":
with session:
click "Pinned post", LinkTextSelector
click "#pin-btn"
checkText "#pin-btn", "Pin Thread"
navigate(baseUrl)
checkIsNone "#threads-list .thread-2 .thread-title i"
checkText "#threads-list .thread-1 .thread-title a", "Normal post"
checkText "#threads-list .thread-2 .thread-title a", "Pinned post"
test "can lock a thread":
with session:
click "Locking", LinkTextSelector
click "#lock-btn"
ensureExists "#thread-title i.fas.fa-lock.fa-xs"
test "locked thread appears on frontpage":
with session:
click "#new-thread-btn"
sendKeys "#thread-title", "A new locked thread"
sendKeys "#reply-textarea", "This thread should appear locked on the frontpage"
click "#create-thread-btn"
click "#lock-btn"
navigate(baseUrl)
ensureExists "#threads-list .thread-1 .thread-title i.fas.fa-lock.fa-xs"
test "can unlock a thread":
with session:
click "Locking", LinkTextSelector
click "#lock-btn"
checkIsNone "#thread-title i.fas.fa-lock.fa-xs"
session.logout()
proc test*(session: Session, baseUrl: string) =
@ -264,4 +158,4 @@ proc test*(session: Session, baseUrl: string) =
unBanUser(session, baseUrl)
session.navigate(baseUrl)
session.navigate(baseUrl)