Compare commits

..

1 commit

Author SHA1 Message Date
Joey Yakimowich-Payne
8b39358dd6 Allow passing of url between jester and frontend 2020-02-22 15:23:51 -07:00
38 changed files with 377 additions and 1157 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

7
.gitignore vendored
View file

@ -14,10 +14,3 @@ createdb
editdb
.vscode
forum.json*
browsertester
setup_nimforum
buildcss
nimforum.css
/src/frontend/forum.js

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,15 +13,15 @@ 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"
requires "webdriver#c2fee57"
# Tasks
@ -32,9 +32,6 @@ task backend, "Compiles and runs the forum backend":
task runbackend, "Runs the forum backend":
exec "./src/forum"
task testbackend, "Runs the forum backend in test mode":
exec "nimble c -r -d:skipRateLimitCheck src/forum.nim"
task frontend, "Builds the necessary JS frontend (with CSS)":
exec "nimble c -r src/buildcss"
exec "nimble js -d:release src/frontend/forum.nim"

View file

@ -22,7 +22,6 @@ table th {
// Custom styles.
// - Navigation bar.
$navbar-height: 60px;
$default-category-color: #a3a3a3;
$logo-height: $navbar-height - 20px;
.navbar-button {
@ -51,7 +50,6 @@ $logo-height: $navbar-height - 20px;
// Unfortunately we must colour the controls in the navbar manually.
.search-input {
@extend .form-input;
min-width: 120px;
border-color: $navbar-border-color-dark;
}
@ -123,22 +121,6 @@ $logo-height: $navbar-height - 20px;
}
}
.category-description {
opacity: 0.6;
font-size: small;
}
.category-status {
font-size: small;
font-weight: bold;
.topic-count {
margin-left: 5px;
opacity: 0.7;
font-size: small;
}
}
.category {
white-space: nowrap;
}
@ -184,33 +166,6 @@ $logo-height: $navbar-height - 20px;
}
}
.thread-list {
@extend .container;
@extend .grid-xl;
}
.category-list {
@extend .thread-list;
.category-title {
@extend .thread-title;
a, a:hover {
color: lighten($body-font-color, 10%);
text-decoration: none;
}
}
.category-description {
opacity: 0.6;
}
}
#categories-list .category {
border-left: 6px solid;
border-left-color: $default-category-color;
}
$super-popular-color: #f86713;
$popular-color: darken($super-popular-color, 25%);
$threads-meta-color: #545d70;
@ -262,7 +217,7 @@ $threads-meta-color: #545d70;
.category-color {
width: 0;
height: 0;
border: 0.25rem solid $default-category-color;
border: 0.3rem solid #98c766;
display: inline-block;
margin-right: 5px;
}
@ -301,14 +256,6 @@ $threads-meta-color: #545d70;
}
}
.thread-replies, .thread-time, .views-text, .popular-text, .centered-header {
text-align: center;
}
.thread-users {
text-align: left;
}
.thread-time {
color: $threads-meta-color;
@ -779,3 +726,8 @@ hr {
margin-top: $control-padding-y*2;
}
}
// - Hide features that have not been implemented yet.
#main-buttons > section.navbar-section:nth-child(1) {
display: none;
}

View file

@ -15,6 +15,7 @@
<!-- Global site tag (gtag.js) - Google Analytics -->
<script async src="https://www.googletagmanager.com/gtag/js?id=$ga"></script>
<script>
window.SERVER_URL = "$server_url";
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());

View file

@ -1,4 +1,4 @@
import os
import os, strutils
import sass

View file

@ -20,7 +20,7 @@ proc newMailer*(config: Config): Mailer =
proc rateCheck(mailer: Mailer, address: string): bool =
## Returns true if we've emailed the address too much.
let diff = getTime() - mailer.lastReset
if diff.inHours >= 1:
if diff.hours >= 1:
mailer.lastReset = getTime()
mailer.emailsSent.clear()
@ -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

@ -151,7 +151,7 @@ proc checkLoggedIn(c: TForumData) =
)
c.previousVisitAt = personRow[1].parseInt
let diff = getTime() - fromUnix(personRow[0].parseInt)
if diff.inMinutes > 30:
if diff.minutes > 30:
c.previousVisitAt = personRow[0].parseInt
db.exec(
sql"""
@ -238,7 +238,7 @@ proc verifyIdentHash(
let newIdent = makeIdentHash(name, row[0], epoch, row[1])
# Check that it hasn't expired.
let diff = getTime() - epoch.fromUnix()
if diff.inHours > 2:
if diff.hours > 2:
raise newForumError("Link expired")
if newIdent != ident:
raise newForumError("Invalid ident hash")
@ -267,7 +267,9 @@ proc initialise() =
{
"title": config.title,
"timestamp": encodeUrl(CompileDate & CompileTime),
"ga": config.ga
"ga": config.ga,
# An ugly hack, maybe there's a better way?
"server_url": "$server_url"
}.newStringTable()
@ -276,26 +278,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 +304,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 +320,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 +336,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 +355,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 +370,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 +388,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 +401,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 +413,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.
@ -436,9 +436,8 @@ proc executeReply(c: TForumData, threadId: int, content: string,
else:
raise newForumError("You are not allowed to post")
when not defined(skipRateLimitCheck):
if rateLimitCheck(c):
raise newForumError("You're posting too fast!")
if rateLimitCheck(c):
raise newForumError("You're posting too fast!")
if content.strip().len == 0:
raise newForumError("Message cannot be empty")
@ -500,10 +499,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).weeks > 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 +533,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")
@ -570,9 +569,8 @@ proc executeNewThread(c: TForumData, subject, msg, categoryID: string): (int64,
if not validateRst(c, msg):
raise newForumError("Message needs to be valid RST", @["msg"])
when not defined(skipRateLimitCheck):
if rateLimitCheck(c):
raise newForumError("You're posting too fast!")
if rateLimitCheck(c):
raise newForumError("You're posting too fast!")
result[0] = tryInsertID(db, query, subject, categoryID).int
if result[0] < 0:
@ -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)
@ -805,18 +793,12 @@ routes:
get "/categories.json":
# TODO: Limit this query in the case of many many categories
const categoriesQuery =
sql"""
select c.*, count(thread.category)
from category c
left join thread on c.id == thread.category
group by c.id;
"""
const categoriesQuery = sql"""select * from category;"""
var list = CategoryList(categories: @[])
for data in getAllRows(db, categoriesQuery):
let category = Category(
id: data[0].getInt, name: data[1], description: data[2], color: data[3], numTopics: data[4].parseInt
id: data[0].getInt, name: data[1], description: data[2], color: data[3]
)
list.categories.add(category)
@ -826,42 +808,29 @@ routes:
var
start = getInt(@"start", 0)
count = getInt(@"count", 30)
categoryId = getInt(@"categoryId", -1)
var
categorySection = ""
categoryArgs: seq[string] = @[$start, $count]
countQuery = sql"select count(*) from thread;"
countArgs: seq[string] = @[]
if categoryId != -1:
categorySection = "c.id == ? and "
countQuery = sql"select count(*) from thread t, category c where category == c.id and c.id == ?;"
countArgs.add($categoryId)
categoryArgs.insert($categoryId, 0)
const threadsQuery =
"""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,
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 $#
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 thrCount = getValue(db, sql"select count(*) from thread;").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]))
for data in getAllRows(db, threadsQuery, start, count):
let thread = selectThread(data[0 .. 8], selectUser(data[9 .. ^1]))
list.threads.add(thread)
resp $(%list), "application/json"
@ -876,24 +845,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 +896,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 +973,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 +999,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 +1316,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 +1548,7 @@ routes:
postId: rowFT[2].parseInt(),
postContent: content,
creation: rowFT[4].parseInt(),
author: selectUser(rowFT[5 .. 11]),
author: selectUser(rowFT[5 .. 10]),
)
)
@ -1624,4 +1556,4 @@ routes:
get re"/(.*)":
cond request.matches[0].splitFile.ext == ""
resp karaxHtml
resp karaxHtml % {"server_url": request.url.jsEscape}.newStringTable

View file

@ -1,87 +0,0 @@
when defined(js):
import sugar, httpcore, options, json, strutils
import dom except Event
import jsffi except `&`
include karax/prelude
import karax / [kajax, kdom, vdom]
import error, category
import category, karaxutils
type
AddCategoryModal* = ref object of VComponent
modalShown: bool
loading: bool
error: Option[PostError]
onAddCategory: CategoryEvent
let nullCategory: CategoryEvent = proc (category: Category) = discard
proc newAddCategoryModal*(onAddCategory=nullCategory): AddCategoryModal =
result = AddCategoryModal(
modalShown: false,
loading: false,
onAddCategory: onAddCategory
)
proc onAddCategoryPost(httpStatus: int, response: kstring, state: AddCategoryModal) =
postFinished:
state.modalShown = false
let j = parseJson($response)
let category = j.to(Category)
state.onAddCategory(category)
proc onAddCategoryClick(state: AddCategoryModal) =
state.loading = true
state.error = none[PostError]()
let uri = makeUri("createCategory")
let form = dom.document.getElementById("add-category-form")
let formData = newFormData(form)
ajaxPost(uri, @[], formData.to(cstring),
(s: int, r: kstring) => onAddCategoryPost(s, r, state))
proc setModalShown*(state: AddCategoryModal, visible: bool) =
state.modalShown = visible
state.markDirty()
proc onModalClose(state: AddCategoryModal, ev: Event, n: VNode) =
state.setModalShown(false)
ev.preventDefault()
proc render*(state: AddCategoryModal): VNode =
result = buildHtml():
tdiv(class=class({"active": state.modalShown}, "modal modal-sm")):
a(href="", class="modal-overlay", "aria-label"="close",
onClick=(ev: Event, n: VNode) => onModalClose(state, ev, n))
tdiv(class="modal-container"):
tdiv(class="modal-header"):
tdiv(class="card-title h5"):
text "Add New Category"
tdiv(class="modal-body"):
form(id="add-category-form"):
genFormField(
state.error, "name", "Name", "text", false,
placeholder="Category Name")
genFormField(
state.error, "color", "Color", "color", false,
placeholder="#XXYYZZ"
)
genFormField(
state.error,
"description",
"Description",
"text",
true,
placeholder="Description"
)
tdiv(class="modal-footer"):
button(
id="add-category-btn",
class="btn btn-primary",
onClick=(ev: Event, n: VNode) =>
state.onAddCategoryClick()):
text "Add"

View file

@ -5,44 +5,28 @@ type
name*: string
description*: string
color*: string
numTopics*: int
CategoryList* = ref object
categories*: seq[Category]
CategoryEvent* = proc (category: Category) {.closure.}
CategoryChangeEvent* = proc (oldCategory: Category, newCategory: Category) {.closure.}
const categoryDescriptionCharLimit = 250
proc cmpNames*(cat1: Category, cat2: Category): int =
cat1.name.cmp(cat2.name)
when defined(js):
include karax/prelude
import karax / [vstyles]
import karaxutils
proc render*(category: Category, compact=true): VNode =
if category.name.len == 0:
return buildHtml():
span()
result = buildhtml(tdiv):
tdiv(class="category-status"):
proc render*(category: Category): VNode =
result = buildHtml():
if category.name.len >= 0:
tdiv(class="category",
title=category.description,
"data-color"="#" & category.color):
tdiv(class="category-color",
style=style(
(StyleAttr.border,
kstring"0.25rem solid #" & category.color)
kstring"0.3rem solid #" & category.color)
))
span(class="category-name"):
text category.name
if not compact:
span(class="topic-count"):
text "× " & $category.numTopics
if not compact:
tdiv(class="category-description"):
text category.description.limit(categoryDescriptionCharLimit)
text category.name
else:
span()

View file

@ -1,105 +0,0 @@
import options, json, httpcore
import category
when defined(js):
import sugar
include karax/prelude
import karax / [vstyles, kajax]
import karaxutils, error, user, mainbuttons, addcategorymodal
type
State = ref object
list: Option[CategoryList]
loading: bool
mainButtons: MainButtons
status: HttpCode
addCategoryModal: AddCategoryModal
var state: State
proc newState(): State =
State(
list: none[CategoryList](),
loading: false,
mainButtons: newMainButtons(),
status: Http200,
addCategoryModal: newAddCategoryModal(
onAddCategory=
(category: Category) => state.list.get().categories.add(category)
)
)
state = newState()
proc genCategory(category: Category, noBorder = false): VNode =
result = buildHtml():
tr(class=class({"no-border": noBorder})):
td(style=style((StyleAttr.borderLeftColor, kstring("#" & category.color))), class="category"):
h4(class="category-title", id="category-" & category.name.slug):
a(href=makeUri("/c/" & $category.id)):
tdiv():
tdiv(class="category-name"):
text category.name
tdiv(class="category-description"):
text category.description
td(class="topics"):
text $category.numTopics
proc onCategoriesRetrieved(httpStatus: int, response: kstring) =
state.loading = false
state.status = httpStatus.HttpCode
if state.status != Http200: return
let parsed = parseJson($response)
let list = to(parsed, CategoryList)
if state.list.isSome:
state.list.get().categories.add(list.categories)
else:
state.list = some(list)
proc renderCategoryHeader*(currentUser: Option[User]): VNode =
result = buildHtml(tdiv(id="add-category")):
text "Category"
if currentUser.isAdmin():
button(class="plus-btn btn btn-link",
onClick=(ev: Event, n: VNode) => (
state.addCategoryModal.setModalShown(true)
)):
italic(class="fas fa-plus")
render(state.addCategoryModal)
proc renderCategories(currentUser: Option[User]): VNode =
if state.status != Http200:
return renderError("Couldn't retrieve threads.", state.status)
if state.list.isNone:
if not state.loading:
state.loading = true
ajaxGet(makeUri("categories.json"), @[], onCategoriesRetrieved)
return buildHtml(tdiv(class="loading loading-lg"))
let list = state.list.get()
return buildHtml():
section(class="category-list"):
table(id="categories-list", class="table"):
thead():
tr:
th:
renderCategoryHeader(currentUser)
th(text "Topics")
tbody():
for i in 0 ..< list.categories.len:
let category = list.categories[i]
let isLastCategory = i+1 == list.categories.len
genCategory(category, noBorder=isLastCategory)
proc renderCategoryList*(currentUser: Option[User]): VNode =
result = buildHtml(tdiv):
state.mainButtons.render(currentUser)
renderCategories(currentUser)

View file

@ -1,24 +1,28 @@
when defined(js):
import sugar, httpcore, options, json, strutils, algorithm
import dom except Event
import jsffi except `&`
include karax/prelude
import karax / [kajax, kdom, vdom]
import error, category, user
import category, karaxutils, addcategorymodal
import category, karaxutils
type
CategoryPicker* = ref object of VComponent
list: Option[CategoryList]
selectedCategoryID*: int
loading: bool
modalShown: bool
addEnabled: bool
status: HttpCode
error: Option[PostError]
addCategoryModal: AddCategoryModal
onCategoryChange: CategoryChangeEvent
onAddCategory: CategoryEvent
onCategoryChange: proc (oldCategory: Category, newCategory: Category)
onAddCategory: proc (category: Category)
proc slug(name: string): string =
name.strip().replace(" ", "-").toLowerAscii
proc onCategoryLoad(state: CategoryPicker): proc (httpStatus: int, response: kstring) =
return
@ -50,26 +54,18 @@ when defined(js):
return cat
raise newException(IndexError, "Category at " & $id & " not found!")
let nullAddCategory: CategoryEvent = proc (category: Category) = discard
let nullCategoryChange: CategoryChangeEvent = proc (oldCategory: Category, newCategory: Category) = discard
proc nullAddCategory(category: Category) = discard
proc nullCategoryChange(oldCategory: Category, newCategory: Category) = discard
proc select*(state: CategoryPicker, id: int) =
state.selectedCategoryID = id
state.markDirty()
proc onCategory(state: CategoryPicker): CategoryEvent =
result =
proc (category: Category) =
state.list.get().categories.add(category)
state.list.get().categories.sort(cmpNames)
state.select(category.id)
state.onAddCategory(category)
proc newCategoryPicker*(onCategoryChange=nullCategoryChange, onAddCategory=nullAddCategory): CategoryPicker =
proc newCategoryPicker*(
onCategoryChange: proc(oldCategory: Category, newCategory: Category) = nullCategoryChange,
onAddCategory: proc(category: Category) = nullAddCategory
): CategoryPicker =
result = CategoryPicker(
list: none[CategoryList](),
selectedCategoryID: 0,
loading: false,
modalShown: false,
addEnabled: false,
status: Http200,
error: none[PostError](),
@ -77,14 +73,13 @@ when defined(js):
onAddCategory: onAddCategory
)
let state = result
result.addCategoryModal = newAddCategoryModal(
onAddCategory=onCategory(state)
)
proc setAddEnabled*(state: CategoryPicker, enabled: bool) =
state.addEnabled = enabled
proc select*(state: CategoryPicker, id: int) =
state.selectedCategoryID = id
state.markDirty()
proc onCategoryClick(state: CategoryPicker, category: Category): proc (ev: Event, n: VNode) =
# this is necessary to capture the right value
let cat = category
@ -94,18 +89,82 @@ when defined(js):
state.select(cat.id)
state.onCategoryChange(oldCategory, cat)
proc onAddCategoryPost(httpStatus: int, response: kstring, state: CategoryPicker) =
postFinished:
state.modalShown = false
let j = parseJson($response)
let category = j.to(Category)
state.list.get().categories.add(category)
state.list.get().categories.sort(cmpNames)
state.select(category.id)
state.onAddCategory(category)
proc onAddCategoryClick(state: CategoryPicker) =
state.loading = true
state.error = none[PostError]()
let uri = makeUri("createCategory")
let form = dom.document.getElementById("add-category-form")
let formData = newFormData(form)
ajaxPost(uri, @[], formData.to(cstring),
(s: int, r: kstring) => onAddCategoryPost(s, r, state))
proc onClose(ev: Event, n: VNode, state: CategoryPicker) =
state.modalShown = false
state.markDirty()
ev.preventDefault()
proc genAddCategory(state: CategoryPicker): VNode =
result = buildHtml():
tdiv(id="add-category"):
button(class="plus-btn btn btn-link",
onClick=(ev: Event, n: VNode) => (
state.addCategoryModal.setModalShown(true)
state.modalShown = true;
state.markDirty()
)):
italic(class="fas fa-plus")
render(state.addCategoryModal)
tdiv(class=class({"active": state.modalShown}, "modal modal-sm")):
a(href="", class="modal-overlay", "aria-label"="close",
onClick=(ev: Event, n: VNode) => onClose(ev, n, state))
tdiv(class="modal-container"):
tdiv(class="modal-header"):
tdiv(class="card-title h5"):
text "Add New Category"
tdiv(class="modal-body"):
form(id="add-category-form"):
genFormField(
state.error, "name", "Name", "text", false,
placeholder="Category Name")
genFormField(
state.error, "color", "Color", "color", false,
placeholder="#XXYYZZ"
)
genFormField(
state.error,
"description",
"Description",
"text",
true,
placeholder="Description"
)
tdiv(class="modal-footer"):
button(
id="add-category-btn",
class="btn btn-primary",
onClick=(ev: Event, n: VNode) =>
state.onAddCategoryClick()):
text "Add"
proc render*(state: CategoryPicker, currentUser: Option[User], compact=true): VNode =
state.setAddEnabled(currentUser.isAdmin())
proc render*(state: CategoryPicker, currentUser: Option[User]): VNode =
let loggedIn = currentUser.isSome()
let currentAdmin =
loggedIn and currentUser.get().rank == Admin
if currentAdmin:
state.setAddEnabled(true)
if state.status != Http200:
return renderError("Couldn't retrieve categories.", state.status)
@ -130,6 +189,6 @@ when defined(js):
li(class="menu-item"):
a(class="category-" & $category.id & " " & category.name.slug,
onClick=onCategoryClick(state, category)):
render(category, compact)
render(category)
if state.addEnabled:
genAddCategory(state)

View file

@ -1,11 +1,11 @@
import httpcore
import options, httpcore
type
PostError* = object
errorFields*: seq[string] ## IDs of the fields with an error.
message*: string
when defined(js):
import json, options
import json
include karax/prelude
import karaxutils

View file

@ -1,4 +1,4 @@
import options, tables, sugar, httpcore
import options, tables, sugar, httpcore, uri
from dom import window, Location, document, decodeURI
include karax/prelude
@ -6,7 +6,6 @@ import karax/[kdom]
import jester/[patterns]
import threadlist, postlist, header, profile, newthread, error, about
import categorylist
import resetpassword, activateemail, search
import karaxutils
@ -14,6 +13,7 @@ type
State = ref object
originalTitle: cstring
url: Location
serverUri: Uri
profile: ProfileState
newThread: NewThread
about: About
@ -38,6 +38,7 @@ proc newState(): State =
State(
originalTitle: document.title,
url: copyLocation(window.location),
serverUri: SERVER_URI,
profile: newProfileState(),
newThread: newNewThread(),
about: newAbout(),
@ -58,6 +59,7 @@ proc onPopState(event: dom.Event) =
state.url = copyLocation(window.location)
redraw()
window.location.reload()
type Params = Table[string, string]
type
@ -68,12 +70,12 @@ type
proc r(n: string, p: proc (params: Params): VNode): Route = Route(n: n, p: p)
proc route(routes: openarray[Route]): VNode =
let path =
if state.url.pathname.len == 0: "/" else: $state.url.pathname
if state.serverUri.path.len == 0: "/" else: state.serverUri.path
let prefix = if appName == "/": "" else: appName
for route in routes:
let pattern = (prefix & route.n).parsePattern()
var (matched, params) = pattern.match(path)
parseUrlQuery($state.url.search, params)
parseUrlQuery($state.serverUri.query, params)
if matched:
return route.p(params)
@ -83,14 +85,6 @@ proc render(): VNode =
result = buildHtml(tdiv()):
renderHeader()
route([
r("/categories",
(params: Params) =>
(renderCategoryList(getLoggedInUser()))
),
r("/c/@id",
(params: Params) =>
(renderThreadList(getLoggedInUser(), some(params["id"].parseInt)))
),
r("/newthread",
(params: Params) =>
(render(state.newThread, getLoggedInUser()))

View file

@ -1,4 +1,4 @@
import options, httpcore
import options, times, httpcore, json, sugar
import user
type
@ -7,7 +7,6 @@ type
recaptchaSiteKey*: Option[string]
when defined(js):
import times, json, sugar
include karax/prelude
import karax / [kajax, kdom]
@ -32,7 +31,7 @@ when defined(js):
var
state = newState()
proc getStatus(logout=false)
proc getStatus(logout: bool=false)
proc newState(): State =
State(
data: none[UserStatus](),
@ -61,7 +60,7 @@ when defined(js):
state.lastUpdate = getTime()
proc getStatus(logout=false) =
proc getStatus(logout: bool=false) =
if state.loading: return
let diff = getTime() - state.lastUpdate
if diff.inMinutes < 5:
@ -96,8 +95,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

@ -1,15 +1,4 @@
import strutils, strformat, parseutils, tables
proc limit*(str: string, n: int): string =
## Limit the number of characters in a string. Ends with a elipsis
if str.len > n:
return str[0..<n-3] & "..."
else:
return str
proc slug*(name: string): string =
## Transforms text into a url slug
name.strip().replace(" ", "-").toLowerAscii
import strutils, strformat, parseutils, tables, uri
proc parseIntSafe*(s: string, value: var int) {.noSideEffect.} =
## parses `s` into an integer in the range `validRange`. If successful,
@ -42,6 +31,10 @@ when defined(js):
const appName* = "/"
# Get the server URL that we set in the backend
var SERVER_URL* {.importc: "SERVER_URL".}: cstring
let SERVER_URI* = parseUri($SERVER_URL)
proc class*(classes: varargs[tuple[name: string, present: bool]],
defaultClasses: string = ""): string =
result = defaultClasses & " "
@ -53,11 +46,13 @@ when defined(js):
## Concatenates ``relative`` to the current URL in a way that is
## (possibly) sane.
var relative = relative
assert appName in $window.location.pathname
assert appName in SERVER_URI.path
if relative[0] == '/': relative = relative[1..^1]
return $window.location.protocol & "//" &
$window.location.host &
let port = if SERVER_URI.port.len > 0: ":" & SERVER_URI.port else: ""
return SERVER_URI.scheme & "://" &
SERVER_URI.hostname & port &
appName &
relative &
search &
@ -73,7 +68,7 @@ when defined(js):
query.add(param[0] & "=" & param[1])
if query.len > 0:
var search = if reuseSearch: $window.location.search else: ""
var search = if reuseSearch: SERVER_URI.query else: ""
if search.len != 0: search.add("&")
search.add(query)
if search[0] != '?': search = "?" & search
@ -97,6 +92,7 @@ when defined(js):
let url = n.getAttr("href")
navigateTo(url)
window.location.href = url
proc newFormData*(form: dom.Element): FormData
{.importcpp: "new FormData(@)", constructor.}

View file

@ -1,58 +0,0 @@
import options
import user
when defined(js):
include karax/prelude
import karax / [kdom]
import karaxutils, user, categorypicker, category
let buttons = [
(name: "Latest", url: makeUri("/"), id: "latest-btn"),
(name: "Categories", url: makeUri("/categories"), id: "categories-btn"),
]
proc onSelectedCategoryChanged(oldCategory: Category, newCategory: Category) =
let uri = makeUri("/c/" & $newCategory.id)
navigateTo(uri)
type
MainButtons* = ref object
categoryPicker: CategoryPicker
onCategoryChange*: CategoryChangeEvent
proc newMainButtons*(onCategoryChange: CategoryChangeEvent = onSelectedCategoryChanged): MainButtons =
new result
result.onCategoryChange = onCategoryChange
result.categoryPicker = newCategoryPicker(
onCategoryChange = proc (oldCategory, newCategory: Category) =
onSelectedCategoryChanged(oldCategory, newCategory)
result.onCategoryChange(oldCategory, newCategory)
)
proc render*(state: MainButtons, currentUser: Option[User], categoryId = none(int)): VNode =
result = buildHtml():
section(class="navbar container grid-xl", id="main-buttons"):
section(class="navbar-section"):
#[tdiv(class="dropdown"):
a(href="#", class="btn dropdown-toggle"):
text "Filter "
italic(class="fas fa-caret-down")
ul(class="menu"):
li: text "community"
li: text "dev" ]#
if categoryId.isSome:
state.categoryPicker.selectedCategoryID = categoryId.get()
render(state.categoryPicker, currentUser, compact=false)
for btn in buttons:
let active = btn.url == window.location.href
a(id=btn.id, href=btn.url):
button(class=class({"btn-primary": active, "btn-link": not active}, "btn")):
text btn.name
section(class="navbar-section"):
if currentUser.isSome():
a(id="new-thread-btn", href=makeUri("/newthread"), onClick=anchorCB):
button(class="btn btn-secondary"):
italic(class="fas fa-plus")
text " New Thread"

View file

@ -65,7 +65,7 @@ when defined(js):
tdiv():
label(class="d-inline-block form-label"):
text "Category"
render(state.categoryPicker, currentUser, compact=false)
render(state.categoryPicker, currentUser)
renderContent(state.replyBox, none[Thread](), none[Post]())
tdiv(class="footer"):

View file

@ -1,6 +1,6 @@
import options
import user
import user, threadlist
type
PostInfo* = object
@ -58,10 +58,10 @@ type
email*: Option[string]
when defined(js):
import karaxutils, threadlist
import karaxutils
proc renderPostUrl*(post: Post, thread: Thread): string =
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,13 +209,13 @@ 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:
render(state.categoryPicker, currentUser, compact=false)
if authoredByUser or currentAdmin:
render(state.categoryPicker, currentUser)
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

@ -1,4 +1,4 @@
import strformat, times, options, json, httpcore, sugar
import strformat, times, options, json, httpcore
import category, user
@ -15,7 +15,6 @@ type
creation*: int64 ## Unix timestamp
isLocked*: bool
isSolved*: bool
isPinned*: bool
ThreadList* = ref object
threads*: seq[Thread]
@ -27,34 +26,26 @@ proc isModerated*(thread: Thread): bool =
thread.author.rank <= Moderated
when defined(js):
import sugar
include karax/prelude
import karax / [vstyles, kajax, kdom]
import karaxutils, error, user, mainbuttons
import karaxutils, error, user
type
State = ref object
list: Option[ThreadList]
refreshList: bool
loading: bool
status: HttpCode
mainButtons: MainButtons
var state: State
proc newState(): State =
State(
list: none[ThreadList](),
loading: false,
status: Http200,
mainButtons: newMainButtons(
onCategoryChange =
(oldCategory: Category, newCategory: Category) => (state.list = none[ThreadList]())
)
status: Http200
)
state = newState()
var
state = newState()
proc visibleTo*[T](thread: T, user: Option[User]): bool =
## Determines whether the specified thread (or post) should be
@ -74,8 +65,29 @@ when defined(js):
return true
proc genTopButtons(currentUser: Option[User]): VNode =
result = buildHtml():
section(class="navbar container grid-xl", id="main-buttons"):
section(class="navbar-section"):
tdiv(class="dropdown"):
a(href="#", class="btn dropdown-toggle"):
text "Filter "
italic(class="fas fa-caret-down")
ul(class="menu"):
li: text "community"
li: text "dev"
button(class="btn btn-primary"): text "Latest"
button(class="btn btn-link"): text "Most Active"
button(class="btn btn-link"): text "Categories"
section(class="navbar-section"):
if currentUser.isSome():
a(id="new-thread-btn", href=makeUri("/newthread"), onClick=anchorCB):
button(class="btn btn-secondary"):
italic(class="fas fa-plus")
text " New Thread"
proc genUserAvatars(users: seq[User]): VNode =
result = buildHtml(td(class="thread-users")):
result = buildHtml(td):
for user in users:
render(user, "avatar avatar-sm", showStatus=true)
text " "
@ -97,18 +109,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): 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")
@ -118,16 +127,14 @@ when defined(js):
if thread.isSolved:
italic(class="fas fa-check-square fa-xs",
title="Thread has a solution")
a(href=makeUri("/t/" & $thread.id), onClick=anchorCB):
a(href=makeUri("/t/" & $thread.id),
onClick=anchorCB):
text thread.topic
tdiv(class="show-sm" & class({"d-none": not displayCategory})):
render(thread.category)
td(class="hide-sm" & class({"d-none": not displayCategory})):
td():
render(thread.category)
genUserAvatars(thread.users)
td(class="thread-replies"): text $thread.replies
td(class="hide-sm" & class({
td(): text $thread.replies
td(class=class({
"views-text": thread.views < 999,
"popular-text": thread.views > 999 and thread.views < 5000,
"super-popular-text": thread.views > 5000
@ -161,13 +168,10 @@ when defined(js):
else:
state.list = some(list)
proc onLoadMore(ev: Event, n: VNode, categoryId: Option[int]) =
proc onLoadMore(ev: Event, n: VNode) =
state.loading = true
let start = state.list.get().threads.len
if categoryId.isSome:
ajaxGet(makeUri("threads.json?start=" & $start & "&categoryId=" & $categoryId.get()), @[], onThreadList)
else:
ajaxGet(makeUri("threads.json?start=" & $start), @[], onThreadList)
ajaxGet(makeUri("threads.json?start=" & $start), @[], onThreadList)
proc getInfo(
list: seq[Thread], i: int, currentUser: Option[User]
@ -192,34 +196,29 @@ when defined(js):
isNew: thread.creation > previousVisitAt
)
proc genThreadList(currentUser: Option[User], categoryId: Option[int]): VNode =
proc genThreadList(currentUser: Option[User]): VNode =
if state.status != Http200:
return renderError("Couldn't retrieve threads.", state.status)
if state.list.isNone:
if not state.loading:
state.loading = true
if categoryId.isSome:
ajaxGet(makeUri("threads.json?categoryId=" & $categoryId.get()), @[], onThreadList)
else:
ajaxGet(makeUri("threads.json"), @[], onThreadList)
ajaxGet(makeUri("threads.json"), @[], onThreadList)
return buildHtml(tdiv(class="loading loading-lg"))
let displayCategory = categoryId.isNone
let list = state.list.get()
result = buildHtml():
section(class="thread-list"):
section(class="container grid-xl"): # TODO: Rename to `.thread-list`.
table(class="table", id="threads-list"):
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 "Replies"
th(class="hide-sm centered-header"): text "Views"
th(class="centered-header"): text "Activity"
th(text "Category")
th(style=style((StyleAttr.width, kstring"8rem"))): text "Users"
th(text "Replies")
th(text "Views")
th(text "Activity")
tbody():
for i in 0 ..< list.threads.len:
let thread = list.threads[i]
@ -227,9 +226,8 @@ when defined(js):
let isLastThread = i+1 == list.threads.len
let (isLastUnseen, isNew) = getInfo(list.threads, i, currentUser)
genThread(i+1, thread, isNew,
noBorder=isLastUnseen or isLastThread,
displayCategory=displayCategory)
genThread(thread, isNew,
noBorder=isLastUnseen or isLastThread)
if isLastUnseen and (not isLastThread):
tr(class="last-visit-separator"):
td(colspan="6"):
@ -241,11 +239,10 @@ when defined(js):
td(colspan="6"):
tdiv(class="loading loading-lg")
else:
td(colspan="6",
onClick = (ev: Event, n: VNode) => (onLoadMore(ev, n, categoryId))):
td(colspan="6", onClick=onLoadMore):
span(text "load more threads")
proc renderThreadList*(currentUser: Option[User], categoryId = none(int)): VNode =
proc renderThreadList*(currentUser: Option[User]): VNode =
result = buildHtml(tdiv):
state.mainButtons.render(currentUser, categoryId=categoryId)
genThreadList(currentUser, categoryId)
genTopButtons(currentUser)
genThreadList(currentUser)

View file

@ -1,4 +1,4 @@
import times, options
import times
type
# If you add more "Banned" states, be sure to modify forum's threadsQuery too.
@ -17,7 +17,6 @@ type
Admin ## Admin: can do everything
User* = object
id*: string
name*: string
avatarUrl*: string
lastOnline*: int64
@ -28,9 +27,6 @@ type
proc isOnline*(user: User): bool =
return getTime().toUnix() - user.lastOnline < (60*5)
proc isAdmin*(user: Option[User]): bool =
return user.isSome and user.get().rank == Admin
proc `==`*(u1, u2: User): bool =
u1.name == u2.name
@ -76,4 +72,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,13 +1,21 @@
import asyncdispatch, smtp, strutils, json, os, rst, rstgen, xmltree, strtabs,
htmlparser, streams, parseutils, options, logging
from times import getTime, utc, format
import asyncdispatch, strutils, json, os, rst, rstgen, xmltree, strtabs,
htmlparser, streams, parseutils, logging, uri, options, nre, strformat
from times import getTime, getGMTime, format
import jester
import jester/private/utils as jesterutils
when useHttpBeast:
import httpbeast except Settings, Request
else:
import asynchttpserver
# 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]
import frontend/[error]
export parseInt
type
@ -17,8 +25,6 @@ type
smtpUser*: string
smtpPassword*: string
smtpFromAddr*: string
smtpTls*: bool
smtpSsl*: bool
mlistAddress*: string
recaptchaSecretKey*: string
recaptchaSiteKey*: string
@ -57,8 +63,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("")
@ -185,3 +189,24 @@ proc rstToHtml*(content: string): string =
add(result, node, indWidth=0, addNewLines=false)
except:
warn("Could not parse rst html.")
proc jsEscape*(str: string): string =
## Escapes a string to be stored in a JS variable. This prevents
## XSS attacks. Taken from here: https://portswigger.net/web-security/cross-site-scripting/preventing
str.replace(
re"[^\w. ]",
proc(match: string): string =
let code = match[0].ord
fmt"\u{code:04x}"
)
proc url*(request: Request): string =
## Get the full URL of the request, including the query params
let nativeRequest = request.getNativeReq
when useHttpBeast:
let query = nativeRequest.path.get("").parseUri().query
else:
let query = nativeRequest.url.query
let proto = if request.secure: "https" else: "http"
result = proto & "://" & request.host & request.path & query

View file

@ -23,7 +23,7 @@ const baseUrl = "http://localhost:" & $port & "/"
template withBackend(body: untyped): untyped =
## Starts a new backend instance.
spawn runProcess("nimble -y testbackend")
spawn runProcess("nimble -y runbackend")
defer:
discard execCmd("killall " & backend)
@ -46,8 +46,6 @@ template withBackend(body: untyped): untyped =
import browsertests/[scenario1, threads, issue181, categories]
proc main() =
# Kill any already running instances
discard execCmd("killall geckodriver")
spawn runProcess("geckodriver -p 4444 --log config")
defer:
discard execCmd("killall geckodriver")

View file

@ -1,2 +1 @@
--threads:on
--path:"../src/frontend"
--threads:on

View file

@ -1,32 +1,13 @@
import unittest, common
import webdriver
import unittest, options, os, common
import karaxutils
import webdriver
proc selectCategory(session: Session, name: string) =
with session:
click "#category-selection .dropdown-toggle"
click "#category-selection ." & name
proc createCategory(session: Session, baseUrl, name, color, description: string) =
with session:
navigate baseUrl
click "#categories-btn"
ensureExists "#add-category"
click "#add-category .plus-btn"
clear "#add-category input[name='name']"
clear "#add-category input[name='description']"
sendKeys "#add-category input[name='name']", name
setColor "#add-category input[name='color']", color
sendKeys "#add-category input[name='description']", description
click "#add-category #add-category-btn"
checkText "#category-" & name.slug(), name
proc categoriesUserTests(session: Session, baseUrl: string) =
let
@ -49,122 +30,38 @@ proc categoriesUserTests(session: Session, baseUrl: string) =
checkIsNone "#add-category"
test "no category add available category page":
with session:
click "#categories-btn"
checkIsNone "#add-category"
test "can create category thread":
with session:
click "#new-thread-btn"
sendKeys "#thread-title", title
selectCategory "fun"
sendKeys "#reply-textarea", content
click "#create-thread-btn"
checkText "#thread-title .category", "Fun"
navigate baseUrl
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"
ensureExists "#categories-list"
test "can view post under category":
with session:
# create a few threads
click "#new-thread-btn"
sendKeys "#thread-title", "Post 1"
selectCategory "fun"
sendKeys "#reply-textarea", "Post 1"
click "#create-thread-btn"
navigate baseUrl
click "#new-thread-btn"
sendKeys "#thread-title", "Post 2"
selectCategory "announcements"
sendKeys "#reply-textarea", "Post 2"
click "#create-thread-btn"
navigate baseUrl
click "#new-thread-btn"
sendKeys "#thread-title", "Post 3"
selectCategory "unsorted"
sendKeys "#reply-textarea", "Post 3"
click "#create-thread-btn"
navigate baseUrl
click "#categories-btn"
ensureExists "#categories-list"
click "#category-unsorted"
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"
selectCategory "announcements"
checkText "#threads-list .thread-title a", "Post 2"
for element in session.waitForElements("#threads-list .category-name"):
assert element.getProperty("innerText") == "Announcements"
selectCategory "fun"
checkText "#threads-list .thread-title a", "Post 1"
for element in session.waitForElements("#threads-list .category-name"):
assert element.getProperty("innerText") == "Fun"
session.logout()
proc categoriesAdminTests(session: Session, baseUrl: string) =
let
name = "Category Test"
color = "Creating category test"
description = "This is a description"
suite "admin tests":
with session:
navigate baseUrl
login "admin", "admin"
test "can create category via dropdown":
let
name = "Category Test"
color = "#720904"
description = "This is a description"
test "can create category":
with session:
click "#new-thread-btn"
@ -173,36 +70,18 @@ proc categoriesAdminTests(session: Session, baseUrl: string) =
click "#add-category .plus-btn"
clear "#add-category input[name='name']"
clear "#add-category input[name='color']"
clear "#add-category input[name='description']"
sendKeys "#add-category input[name='name']", name
setColor "#add-category input[name='color']", color
sendKeys "#add-category input[name='color']", color
sendKeys "#add-category input[name='description']", description
click "#add-category #add-category-btn"
checkText "#category-selection .selected-category", name
test "can create category on category page":
let
name = "Category Test Page"
color = "#70B4D4"
description = "This is a description on category page"
with session:
createCategory baseUrl, name, color, description
test "category adding disabled on admin logout":
with session:
navigate(baseUrl & "c/0")
ensureExists "#add-category"
logout()
checkIsNone "#add-category"
navigate baseUrl
login "admin", "admin"
session.logout()
proc test*(session: Session, baseUrl: string) =
@ -211,4 +90,4 @@ proc test*(session: Session, baseUrl: string) =
categoriesUserTests(session, baseUrl)
categoriesAdminTests(session, baseUrl)
session.navigate(baseUrl)
session.navigate(baseUrl)

View file

@ -8,44 +8,34 @@ const actionDelayMs {.intdefine.} = 0
macro with*(obj: typed, code: untyped): untyped =
## Execute a set of statements with an object
expectKind code, nnkStmtList
template checkCompiles(res, default) =
when compiles(res):
res
else:
default
result = code.copy
result = code
# Simply inject obj into call
for i in 0 ..< result.len:
if result[i].kind in {nnkCommand, nnkCall}:
result[i].insert(1, obj)
result = getAst(checkCompiles(result, code))
proc elementIsSome(element: Option[Element]): bool =
return element.isSome
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) =
template click*(session: Session, element: string, strategy=CssSelector) =
let el = session.waitForElement(element, strategy)
el.get().click()
proc sendKeys*(session: Session, element, keys: string) =
template sendKeys*(session: Session, element, keys: string) =
let el = session.waitForElement(element)
el.get().sendKeys(keys)
proc clear*(session: Session, element: string) =
template clear*(session: Session, element: string) =
let el = session.waitForElement(element)
el.get().clear()
proc sendKeys*(session: Session, element: string, keys: varargs[Key]) =
template sendKeys*(session: Session, element: string, keys: varargs[Key]) =
let el = session.waitForElement(element)
# focus
@ -53,7 +43,7 @@ proc sendKeys*(session: Session, element: string, keys: varargs[Key]) =
for key in keys:
session.press(key)
proc ensureExists*(session: Session, element: string, strategy=CssSelector) =
template ensureExists*(session: Session, element: string, strategy=CssSelector) =
discard session.waitForElement(element, strategy)
template check*(session: Session, element: string, function: untyped) =
@ -65,11 +55,7 @@ template check*(session: Session, element: string,
let el = session.waitForElement(element, strategy)
check function(el)
proc setColor*(session: Session, element, color: string, strategy=CssSelector) =
let el = session.waitForElement(element, strategy)
discard session.execute("arguments[0].setAttribute('value', '" & color & "')", el.get())
proc checkIsNone*(session: Session, element: string, strategy=CssSelector) =
template checkIsNone*(session: Session, element: string, strategy=CssSelector) =
discard session.waitForElement(element, strategy, waitCondition=elementIsNone)
template checkText*(session: Session, element, expectedValue: string) =
@ -79,7 +65,7 @@ template checkText*(session: Session, element, expectedValue: string) =
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
@ -87,30 +73,8 @@ proc waitForElement*(
sleep(actionDelayMs)
while true:
try:
let loading = session.findElement(selector, strategy)
if waitCondition(loading):
return loading
finally:
discard
sleep(pollTime)
waitTime += pollTime
if waitTime > timeout:
doAssert false, "Wait for load time exceeded"
proc waitForElements*(
session: Session, selector: string, strategy=CssSelector,
timeout=20000, pollTime=50
): seq[Element] =
var waitTime = 0
when actionDelayMs > 0:
sleep(actionDelayMs)
while true:
let loading = session.findElements(selector, strategy)
if loading.len > 0:
let loading = session.findElement(selector, strategy)
if waitCondition(loading):
return loading
sleep(pollTime)
waitTime += pollTime

View file

@ -1,4 +1,4 @@
import unittest, common
import unittest, options, os, common
import webdriver

View file

@ -1,4 +1,4 @@
import unittest, common
import unittest, options, os, common
import webdriver
@ -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 unittest, options, 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)