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 nimble backend
``` ```
Development typically involves running `nimble devdb` which sets up the Development typically involves running `nimble devdb` which sets up the
database for development and testing, then `nimble backend` database for development and testing, then `nimble backend`
which compiles and runs the forum's backend, and `nimble frontend` 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 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. 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 # 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 # Package
version = "2.1.0" version = "2.0.2"
author = "Dominik Picheta" author = "Dominik Picheta"
description = "The Nim forum" description = "The Nim forum"
license = "MIT" license = "MIT"
@ -13,13 +13,13 @@ skipExt = @["nim"]
# Dependencies # Dependencies
requires "nim >= 1.0.6" requires "nim >= 1.0.6"
requires "jester#405be2e" requires "jester#d8a03aa"
requires "bcrypt#440c5676ff6" requires "bcrypt#head"
requires "hmac#9c61ebe2fd134cf97" requires "hmac#9c61ebe2fd134cf97"
requires "recaptcha#d06488e" requires "recaptcha#d06488e"
requires "sass#649e0701fa5c" requires "sass#649e0701fa5c"
requires "karax#5f21dcd" requires "karax#f6bda9a"
requires "webdriver#429933a" requires "webdriver#429933a"

View file

@ -22,7 +22,7 @@ table th {
// Custom styles. // Custom styles.
// - Navigation bar. // - Navigation bar.
$navbar-height: 60px; $navbar-height: 60px;
$default-category-color: #a3a3a3; $default-category-color: #98c766;
$logo-height: $navbar-height - 20px; $logo-height: $navbar-height - 20px;
.navbar-button { .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; text-align: center;
} }
.thread-users {
text-align: left;
}
.thread-time { .thread-time {
color: $threads-meta-color; color: $threads-meta-color;

View file

@ -44,18 +44,8 @@ proc sendMail(
warn("Cannot send mail: no smtp from address configured (smtpFromAddr).") warn("Cannot send mail: no smtp from address configured (smtpFromAddr).")
return return
var client: AsyncSmtp var client = newAsyncSmtp()
if mailer.config.smtpTls: await client.connect(mailer.config.smtpAddress, Port(mailer.config.smtpPort))
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))
if mailer.config.smtpUser.len > 0: if mailer.config.smtpUser.len > 0:
await client.auth(mailer.config.smtpUser, mailer.config.smtpPassword) await client.auth(mailer.config.smtpUser, mailer.config.smtpPassword)
@ -64,9 +54,6 @@ proc sendMail(
var headers = otherHeaders var headers = otherHeaders
headers.add(("From", mailer.config.smtpFromAddr)) 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, let encoded = createMessage(subject, message,
toList, @[], headers) toList, @[], headers)

View file

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

View file

@ -64,4 +64,4 @@ when defined(js):
renderPostUrl(thread.id, post.id) renderPostUrl(thread.id, post.id)
proc renderPostUrl*(link: PostLink): string = 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: "" else: ""
result = buildHtml(): result = buildHtml():
button(class="btn btn-secondary", id="lock-btn", button(class="btn btn-secondary",
onClick=(e: Event, n: VNode) => onClick=(e: Event, n: VNode) =>
onLockClick(e, n, state, thread), onLockClick(e, n, state, thread),
"data-tooltip"=tooltip, "data-tooltip"=tooltip,
@ -201,61 +201,4 @@ when defined(js):
text " Unlock Thread" text " Unlock Thread"
else: else:
italic(class="fas fa-lock") italic(class="fas fa-lock")
text " Lock Thread" 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"

View file

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

View file

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

View file

@ -17,7 +17,6 @@ type
Admin ## Admin: can do everything Admin ## Admin: can do everything
User* = object User* = object
id*: string
name*: string name*: string
avatarUrl*: string avatarUrl*: string
lastOnline*: int64 lastOnline*: int64
@ -76,4 +75,4 @@ when defined(js):
title="User is a moderator") title="User is a moderator")
of Admin: of Admin:
italic(class="fas fa-chess-knight", 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_id,
post_content, post_content,
cdate, cdate,
person.id,
person.name AS author, person.name AS author,
person.email AS email, person.email AS email,
strftime('%s', person.lastOnline) AS lastOnline, strftime('%s', person.lastOnline) AS lastOnline,
@ -47,7 +46,6 @@ SELECT
THEN snippet(post_fts, '**', '**', '...', what, -45) THEN snippet(post_fts, '**', '**', '...', what, -45)
ELSE SUBSTR(post_fts.content, 1, 200) END AS content, ELSE SUBSTR(post_fts.content, 1, 200) END AS content,
cdate, cdate,
person.id,
person.name AS author, person.name AS author,
person.email AS email, person.email AS email,
strftime('%s', person.lastOnline) AS lastOnline, strftime('%s', person.lastOnline) AS lastOnline,

View file

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

View file

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

View file

@ -69,29 +69,6 @@ proc categoriesUserTests(session: Session, baseUrl: string) =
ensureExists title, LinkTextSelector 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": test "can navigate to categories page":
with session: with session:
click "#categories-btn" click "#categories-btn"
@ -125,7 +102,7 @@ proc categoriesUserTests(session: Session, baseUrl: string) =
click "#new-thread-btn" click "#new-thread-btn"
sendKeys "#thread-title", "Post 3" sendKeys "#thread-title", "Post 3"
selectCategory "unsorted" selectCategory "default"
sendKeys "#reply-textarea", "Post 3" sendKeys "#reply-textarea", "Post 3"
click "#create-thread-btn" click "#create-thread-btn"
@ -135,11 +112,11 @@ proc categoriesUserTests(session: Session, baseUrl: string) =
click "#categories-btn" click "#categories-btn"
ensureExists "#categories-list" ensureExists "#categories-list"
click "#category-unsorted" click "#category-default"
checkText "#threads-list .thread-title a", "Post 3" checkText "#threads-list .thread-title a", "Post 3"
for element in session.waitForElements("#threads-list .category-name"): for element in session.waitForElements("#threads-list .category-name"):
# Have to user "innerText" because elements are hidden on this page # Have to user "innerText" because elements are hidden on this page
assert element.getProperty("innerText") == "Unsorted" assert element.getProperty("innerText") == "Default"
selectCategory "announcements" selectCategory "announcements"
checkText "#threads-list .thread-title a", "Post 2" 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 = proc elementIsNone(element: Option[Element]): bool =
return element.isNone return element.isNone
proc waitForElement*(session: Session, selector: string, strategy=CssSelector, timeout=20000, pollTime=50, proc waitForElement*(session: Session, selector: string, strategy=CssSelector, timeout=20000, pollTime=50, waitCondition=elementIsSome): Option[Element]
waitCondition: proc(element: Option[Element]): bool = elementIsSome): Option[Element]
proc click*(session: Session, element: string, strategy=CssSelector) = proc click*(session: Session, element: string, strategy=CssSelector) =
let el = session.waitForElement(element, strategy) 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) = proc checkIsNone*(session: Session, element: string, strategy=CssSelector) =
discard session.waitForElement(element, strategy, waitCondition=elementIsNone) 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) let el = session.waitForElement(element)
check el.get().getText() == expectedValue check el.get().getText() == expectedValue
proc waitForElement*( proc waitForElement*(
session: Session, selector: string, strategy=CssSelector, session: Session, selector: string, strategy=CssSelector,
timeout=20000, pollTime=50, timeout=20000, pollTime=50,
waitCondition: proc(element: Option[Element]): bool = elementIsSome waitCondition=elementIsSome
): Option[Element] = ): Option[Element] =
var waitTime = 0 var waitTime = 0

View file

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

View file

@ -1,4 +1,5 @@
import unittest, common import unittest, common
import webdriver import webdriver
let let
@ -39,53 +40,10 @@ proc userTests(session: Session, baseUrl: string) =
checkText "#thread-title .title-text", userTitleStr checkText "#thread-title .title-text", userTitleStr
checkText ".original-post div.post-content", userContentStr 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() session.logout()
proc anonymousTests(session: Session, baseUrl: string) = proc anonymousTests(session: Session, baseUrl: string) =
suite "anonymous user tests": suite "anonymous user tests":
with session: with session:
navigate baseUrl navigate baseUrl
@ -185,70 +143,6 @@ proc adminTests(session: Session, baseUrl: string) =
# Make sure the forum post is gone # Make sure the forum post is gone
checkIsNone adminTitleStr, LinkTextSelector 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() session.logout()
proc test*(session: Session, baseUrl: string) = proc test*(session: Session, baseUrl: string) =
@ -264,4 +158,4 @@ proc test*(session: Session, baseUrl: string) =
unBanUser(session, baseUrl) unBanUser(session, baseUrl)
session.navigate(baseUrl) session.navigate(baseUrl)