Compare commits
2 commits
master
...
mobile_fix
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9bab05a6af | ||
|
|
eee155f343 |
23 changed files with 129 additions and 514 deletions
80
.github/workflows/main.yml
vendored
80
.github/workflows/main.yml
vendored
|
|
@ -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
45
.travis.yml
Normal 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
|
||||
33
README.md
33
README.md
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
version: "3.7"
|
||||
|
||||
services:
|
||||
forum:
|
||||
build:
|
||||
context: ../
|
||||
dockerfile: ./docker/Dockerfile
|
||||
volumes:
|
||||
- "../:/app"
|
||||
ports:
|
||||
- "5000:5000"
|
||||
entrypoint: "/app/docker/entrypoint.sh"
|
||||
|
|
@ -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
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
# Package
|
||||
version = "2.1.0"
|
||||
version = "2.0.2"
|
||||
author = "Dominik Picheta"
|
||||
description = "The Nim forum"
|
||||
license = "MIT"
|
||||
|
|
@ -13,13 +13,13 @@ skipExt = @["nim"]
|
|||
# Dependencies
|
||||
|
||||
requires "nim >= 1.0.6"
|
||||
requires "jester#405be2e"
|
||||
requires "bcrypt#440c5676ff6"
|
||||
requires "jester#d8a03aa"
|
||||
requires "bcrypt#head"
|
||||
requires "hmac#9c61ebe2fd134cf97"
|
||||
requires "recaptcha#d06488e"
|
||||
requires "sass#649e0701fa5c"
|
||||
|
||||
requires "karax#5f21dcd"
|
||||
requires "karax#f6bda9a"
|
||||
|
||||
requires "webdriver#429933a"
|
||||
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ table th {
|
|||
// Custom styles.
|
||||
// - Navigation bar.
|
||||
$navbar-height: 60px;
|
||||
$default-category-color: #a3a3a3;
|
||||
$default-category-color: #98c766;
|
||||
$logo-height: $navbar-height - 20px;
|
||||
|
||||
.navbar-button {
|
||||
|
|
@ -301,14 +301,10 @@ $threads-meta-color: #545d70;
|
|||
}
|
||||
}
|
||||
|
||||
.thread-replies, .thread-time, .views-text, .popular-text, .centered-header {
|
||||
.thread-replies, .thread-time, .thread-users, .views-text, .centered-header {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.thread-users {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.thread-time {
|
||||
color: $threads-meta-color;
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
133
src/forum.nim
133
src/forum.nim
|
|
@ -276,26 +276,25 @@ template createTFD() =
|
|||
new(c)
|
||||
init(c)
|
||||
c.req = request
|
||||
if cookies(request).len > 0:
|
||||
if request.cookies.len > 0:
|
||||
checkLoggedIn(c)
|
||||
|
||||
#[ DB functions. TODO: Move to another module? ]#
|
||||
|
||||
proc selectUser(userRow: seq[string], avatarSize: int=80): User =
|
||||
result = User(
|
||||
id: userRow[0],
|
||||
name: userRow[1],
|
||||
avatarUrl: userRow[2].getGravatarUrl(avatarSize),
|
||||
lastOnline: userRow[3].parseInt,
|
||||
previousVisitAt: userRow[4].parseInt,
|
||||
rank: parseEnum[Rank](userRow[5]),
|
||||
isDeleted: userRow[6] == "1"
|
||||
name: userRow[0],
|
||||
avatarUrl: userRow[1].getGravatarUrl(avatarSize),
|
||||
lastOnline: userRow[2].parseInt,
|
||||
previousVisitAt: userRow[3].parseInt,
|
||||
rank: parseEnum[Rank](userRow[4]),
|
||||
isDeleted: userRow[5] == "1"
|
||||
)
|
||||
|
||||
# Don't give data about a deleted user.
|
||||
if result.isDeleted:
|
||||
result.name = "DeletedUser"
|
||||
result.avatarUrl = getGravatarUrl(result.name & userRow[2], avatarSize)
|
||||
result.avatarUrl = getGravatarUrl(result.name & userRow[1], avatarSize)
|
||||
|
||||
proc selectPost(postRow: seq[string], skippedPosts: seq[int],
|
||||
replyingTo: Option[PostLink], history: seq[PostInfo],
|
||||
|
|
@ -303,7 +302,7 @@ proc selectPost(postRow: seq[string], skippedPosts: seq[int],
|
|||
return Post(
|
||||
id: postRow[0].parseInt,
|
||||
replyingTo: replyingTo,
|
||||
author: selectUser(postRow[5..11]),
|
||||
author: selectUser(postRow[5..10]),
|
||||
likes: likes,
|
||||
seen: false, # TODO:
|
||||
history: history,
|
||||
|
|
@ -319,7 +318,7 @@ proc selectReplyingTo(replyingTo: string): Option[PostLink] =
|
|||
|
||||
const replyingToQuery = sql"""
|
||||
select p.id, strftime('%s', p.creation), p.thread,
|
||||
u.id, u.name, u.email, strftime('%s', u.lastOnline),
|
||||
u.name, u.email, strftime('%s', u.lastOnline),
|
||||
strftime('%s', u.previousVisitAt), u.status,
|
||||
u.isDeleted,
|
||||
t.name
|
||||
|
|
@ -335,7 +334,7 @@ proc selectReplyingTo(replyingTo: string): Option[PostLink] =
|
|||
topic: row[^1],
|
||||
threadId: row[2].parseInt(),
|
||||
postId: row[0].parseInt(),
|
||||
author: some(selectUser(row[3..9]))
|
||||
author: some(selectUser(row[3..8]))
|
||||
))
|
||||
|
||||
proc selectHistory(postId: int): seq[PostInfo] =
|
||||
|
|
@ -354,7 +353,7 @@ proc selectHistory(postId: int): seq[PostInfo] =
|
|||
|
||||
proc selectLikes(postId: int): seq[User] =
|
||||
const likeQuery = sql"""
|
||||
select u.id, u.name, u.email, strftime('%s', u.lastOnline),
|
||||
select u.name, u.email, strftime('%s', u.lastOnline),
|
||||
strftime('%s', u.previousVisitAt), u.status,
|
||||
u.isDeleted
|
||||
from like h, person u
|
||||
|
|
@ -369,7 +368,7 @@ proc selectLikes(postId: int): seq[User] =
|
|||
proc selectThreadAuthor(threadId: int): User =
|
||||
const authorQuery =
|
||||
sql"""
|
||||
select id, name, email, strftime('%s', lastOnline),
|
||||
select name, email, strftime('%s', lastOnline),
|
||||
strftime('%s', previousVisitAt), status, isDeleted
|
||||
from person where id in (
|
||||
select author from post
|
||||
|
|
@ -387,7 +386,7 @@ proc selectThread(threadRow: seq[string], author: User): Thread =
|
|||
where thread = ?;"""
|
||||
const usersListQuery =
|
||||
sql"""
|
||||
select u.id, name, email, strftime('%s', lastOnline),
|
||||
select name, email, strftime('%s', lastOnline),
|
||||
strftime('%s', previousVisitAt), status, u.isDeleted,
|
||||
count(*)
|
||||
from person u, post p where p.author = u.id and p.thread = ?
|
||||
|
|
@ -400,10 +399,10 @@ proc selectThread(threadRow: seq[string], author: User): Thread =
|
|||
id: threadRow[0].parseInt,
|
||||
topic: threadRow[1],
|
||||
category: Category(
|
||||
id: threadRow[6].parseInt,
|
||||
name: threadRow[7],
|
||||
description: threadRow[8],
|
||||
color: threadRow[9]
|
||||
id: threadRow[5].parseInt,
|
||||
name: threadRow[6],
|
||||
description: threadRow[7],
|
||||
color: threadRow[8]
|
||||
),
|
||||
users: @[],
|
||||
replies: posts[0].parseInt-1,
|
||||
|
|
@ -412,7 +411,6 @@ proc selectThread(threadRow: seq[string], author: User): Thread =
|
|||
creation: posts[1].parseInt,
|
||||
isLocked: threadRow[4] == "1",
|
||||
isSolved: false, # TODO: Add a field to `post` to identify the solution.
|
||||
isPinned: threadRow[5] == "1"
|
||||
)
|
||||
|
||||
# Gather the users list.
|
||||
|
|
@ -500,10 +498,10 @@ proc updatePost(c: TForumData, postId: int, content: string,
|
|||
|
||||
# Verify that the current user has permissions to edit the specified post.
|
||||
let creation = fromUnix(postRow[1].parseInt)
|
||||
let isArchived = (getTime() - creation).inHours >= 2
|
||||
let isArchived = (getTime() - creation).inWeeks > 8
|
||||
let canEdit = c.rank == Admin or c.userid == postRow[0]
|
||||
if isArchived and c.rank < Admin:
|
||||
raise newForumError("This post is too old and can no longer be edited")
|
||||
if isArchived:
|
||||
raise newForumError("This post is archived and can no longer be edited")
|
||||
if not canEdit:
|
||||
raise newForumError("You cannot edit this post")
|
||||
|
||||
|
|
@ -534,7 +532,7 @@ proc updateThread(c: TForumData, threadId: string, queryKeys: seq[string], query
|
|||
let threadAuthor = selectThreadAuthor(threadId.parseInt)
|
||||
|
||||
# Verify that the current user has permissions to edit the specified thread.
|
||||
let canEdit = c.rank in {Admin, Moderator} or c.userid == threadAuthor.id
|
||||
let canEdit = c.rank == Admin or c.userid == threadAuthor.name
|
||||
if not canEdit:
|
||||
raise newForumError("You cannot edit this thread")
|
||||
|
||||
|
|
@ -710,25 +708,15 @@ proc executeLockState(c: TForumData, threadId: int, locked: bool) =
|
|||
# Save the like.
|
||||
exec(db, crud(crUpdate, "thread", "isLocked"), locked.int, threadId)
|
||||
|
||||
proc executePinState(c: TForumData, threadId: int, pinned: bool) =
|
||||
if c.rank < Moderator:
|
||||
raise newForumError("You do not have permission to pin this thread.")
|
||||
|
||||
# (Un)pin this thread
|
||||
exec(db, crud(crUpdate, "thread", "isPinned"), pinned.int, threadId)
|
||||
|
||||
proc executeDeletePost(c: TForumData, postId: int) =
|
||||
# Verify that this post belongs to the user.
|
||||
const postQuery = sql"""
|
||||
select p.author, p.id from post p
|
||||
select p.id from post p
|
||||
where p.author = ? and p.id = ?
|
||||
"""
|
||||
let
|
||||
row = getRow(db, postQuery, c.username, postId)
|
||||
author = row[0]
|
||||
id = row[1]
|
||||
let id = getValue(db, postQuery, c.username, postId)
|
||||
|
||||
if id.len == 0 and not (c.rank == Admin or c.userid == author):
|
||||
if id.len == 0 and c.rank < Admin:
|
||||
raise newForumError("You cannot delete this post")
|
||||
|
||||
# Set the `isDeleted` flag.
|
||||
|
|
@ -783,7 +771,7 @@ proc updateProfile(
|
|||
raise newForumError("Rank needs a change when setting new email.")
|
||||
|
||||
await sendSecureEmail(
|
||||
mailer, ActivateEmail, c.req, row[0], row[1], email, row[3]
|
||||
mailer, ActivateEmail, c.req, row[0], row[1], row[2], row[3]
|
||||
)
|
||||
|
||||
validateEmail(email, checkDuplicated=wasEmailChanged)
|
||||
|
|
@ -841,27 +829,27 @@ routes:
|
|||
categoryArgs.insert($categoryId, 0)
|
||||
|
||||
const threadsQuery =
|
||||
"""select t.id, t.name, views, strftime('%s', modified), isLocked, isPinned,
|
||||
"""select t.id, t.name, views, strftime('%s', modified), isLocked,
|
||||
c.id, c.name, c.description, c.color,
|
||||
u.id, u.name, u.email, strftime('%s', u.lastOnline),
|
||||
u.name, u.email, strftime('%s', u.lastOnline),
|
||||
strftime('%s', u.previousVisitAt), u.status, u.isDeleted
|
||||
from thread t, category c, person u
|
||||
where t.isDeleted = 0 and category = c.id and $#
|
||||
u.status <> 'Spammer' and u.status <> 'Troll' and
|
||||
u.id = (
|
||||
select p.author from post p
|
||||
where p.thread = t.id
|
||||
order by p.author
|
||||
u.id in (
|
||||
select u.id from post p, person u
|
||||
where p.author = u.id and p.thread = t.id
|
||||
order by u.id
|
||||
limit 1
|
||||
)
|
||||
order by isPinned desc, modified desc limit ?, ?;"""
|
||||
order by modified desc limit ?, ?;"""
|
||||
|
||||
let thrCount = getValue(db, countQuery, countArgs).parseInt()
|
||||
let moreCount = max(0, thrCount - (start + count))
|
||||
|
||||
var list = ThreadList(threads: @[], moreCount: moreCount)
|
||||
for data in getAllRows(db, sql(threadsQuery % categorySection), categoryArgs):
|
||||
let thread = selectThread(data[0 .. 9], selectUser(data[10 .. ^1]))
|
||||
let thread = selectThread(data[0 .. 8], selectUser(data[9 .. ^1]))
|
||||
list.threads.add(thread)
|
||||
|
||||
resp $(%list), "application/json"
|
||||
|
|
@ -876,24 +864,19 @@ routes:
|
|||
count = 10
|
||||
|
||||
const threadsQuery =
|
||||
sql"""select t.id, t.name, views, strftime('%s', modified), isLocked, isPinned,
|
||||
sql"""select t.id, t.name, views, strftime('%s', modified), isLocked,
|
||||
c.id, c.name, c.description, c.color
|
||||
from thread t, category c
|
||||
where t.id = ? and isDeleted = 0 and category = c.id;"""
|
||||
|
||||
let threadRow = getRow(db, threadsQuery, id)
|
||||
if threadRow[0].len == 0:
|
||||
let err = PostError(
|
||||
message: "Specified thread does not exist"
|
||||
)
|
||||
resp Http404, $(%err), "application/json"
|
||||
let thread = selectThread(threadRow, selectThreadAuthor(id))
|
||||
|
||||
let postsQuery =
|
||||
sql(
|
||||
"""select p.id, p.content, strftime('%s', p.creation), p.author,
|
||||
p.replyingTo,
|
||||
u.id, u.name, u.email, strftime('%s', u.lastOnline),
|
||||
u.name, u.email, strftime('%s', u.lastOnline),
|
||||
strftime('%s', u.previousVisitAt), u.status,
|
||||
u.isDeleted
|
||||
from post p, person u
|
||||
|
|
@ -932,20 +915,15 @@ routes:
|
|||
|
||||
get "/specific_posts.json":
|
||||
createTFD()
|
||||
var ids: JsonNode
|
||||
try:
|
||||
var
|
||||
ids = parseJson(@"ids")
|
||||
except JsonParsingError:
|
||||
let err = PostError(
|
||||
message: "Invalid JSON in the `ids` parameter"
|
||||
)
|
||||
resp Http400, $(%err), "application/json"
|
||||
|
||||
cond ids.kind == JArray
|
||||
let intIDs = ids.elems.map(x => x.getInt())
|
||||
let postsQuery = sql("""
|
||||
select p.id, p.content, strftime('%s', p.creation), p.author,
|
||||
p.replyingTo,
|
||||
u.id, u.name, u.email, strftime('%s', u.lastOnline),
|
||||
u.name, u.email, strftime('%s', u.lastOnline),
|
||||
strftime('%s', u.previousVisitAt), u.status,
|
||||
u.isDeleted
|
||||
from post p, person u
|
||||
|
|
@ -1014,7 +992,7 @@ routes:
|
|||
""" % postsFrom)
|
||||
|
||||
let userQuery = sql("""
|
||||
select id, name, email, strftime('%s', lastOnline),
|
||||
select name, email, strftime('%s', lastOnline),
|
||||
strftime('%s', previousVisitAt), status, isDeleted,
|
||||
strftime('%s', creation), id
|
||||
from person
|
||||
|
|
@ -1040,7 +1018,7 @@ routes:
|
|||
getValue(db, sql("select count(*) " & threadsFrom), userID).parseInt()
|
||||
|
||||
if c.rank >= Admin or c.username == username:
|
||||
profile.email = some(userRow[2])
|
||||
profile.email = some(userRow[1])
|
||||
|
||||
for row in db.getAllRows(postsQuery, username):
|
||||
profile.posts.add(
|
||||
|
|
@ -1357,33 +1335,6 @@ routes:
|
|||
except ForumError as exc:
|
||||
resp Http400, $(%exc.data), "application/json"
|
||||
|
||||
post re"/(pin|unpin)":
|
||||
createTFD()
|
||||
if not c.loggedIn():
|
||||
let err = PostError(
|
||||
errorFields: @[],
|
||||
message: "Not logged in."
|
||||
)
|
||||
resp Http401, $(%err), "application/json"
|
||||
|
||||
let formData = request.formData
|
||||
cond "id" in formData
|
||||
|
||||
let threadId = getInt(formData["id"].body, -1)
|
||||
cond threadId != -1
|
||||
|
||||
try:
|
||||
case request.path
|
||||
of "/pin":
|
||||
executePinState(c, threadId, true)
|
||||
of "/unpin":
|
||||
executePinState(c, threadId, false)
|
||||
else:
|
||||
assert false
|
||||
resp Http200, "{}", "application/json"
|
||||
except ForumError as exc:
|
||||
resp Http400, $(%exc.data), "application/json"
|
||||
|
||||
post re"/delete(Post|Thread)":
|
||||
createTFD()
|
||||
if not c.loggedIn():
|
||||
|
|
@ -1616,7 +1567,7 @@ routes:
|
|||
postId: rowFT[2].parseInt(),
|
||||
postContent: content,
|
||||
creation: rowFT[4].parseInt(),
|
||||
author: selectUser(rowFT[5 .. 11]),
|
||||
author: selectUser(rowFT[5 .. 10]),
|
||||
)
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -96,8 +96,8 @@ when defined(js):
|
|||
section(class="navbar-section"):
|
||||
tdiv(class="input-group input-inline"):
|
||||
input(class="search-input input-sm",
|
||||
`type`="search", placeholder="Search",
|
||||
id="search-box", required="required",
|
||||
`type`="text", placeholder="search",
|
||||
id="search-box",
|
||||
onKeyDown=onKeyDown)
|
||||
if state.loading:
|
||||
tdiv(class="loading")
|
||||
|
|
|
|||
|
|
@ -64,4 +64,4 @@ when defined(js):
|
|||
renderPostUrl(thread.id, post.id)
|
||||
|
||||
proc renderPostUrl*(link: PostLink): string =
|
||||
renderPostUrl(link.threadId, link.postId)
|
||||
renderPostUrl(link.threadId, link.postId)
|
||||
|
|
@ -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"
|
||||
|
|
@ -36,7 +36,6 @@ when defined(js):
|
|||
likeButton: LikeButton
|
||||
deleteModal: DeleteModal
|
||||
lockButton: LockButton
|
||||
pinButton: PinButton
|
||||
categoryPicker: CategoryPicker
|
||||
|
||||
proc onReplyPosted(id: int)
|
||||
|
|
@ -57,7 +56,6 @@ when defined(js):
|
|||
likeButton: newLikeButton(),
|
||||
deleteModal: newDeleteModal(onDeletePost, onDeleteThread, nil),
|
||||
lockButton: newLockButton(),
|
||||
pinButton: newPinButton(),
|
||||
categoryPicker: newCategoryPicker(onCategoryChanged)
|
||||
)
|
||||
|
||||
|
|
@ -211,12 +209,12 @@ when defined(js):
|
|||
let loggedIn = currentUser.isSome()
|
||||
let authoredByUser =
|
||||
loggedIn and currentUser.get().name == thread.author.name
|
||||
let canChangeCategory =
|
||||
loggedIn and currentUser.get().rank in {Admin, Moderator}
|
||||
let currentAdmin =
|
||||
currentUser.isSome() and currentUser.get().rank == Admin
|
||||
|
||||
result = buildHtml():
|
||||
tdiv():
|
||||
if authoredByUser or canChangeCategory:
|
||||
if authoredByUser or currentAdmin:
|
||||
render(state.categoryPicker, currentUser, compact=false)
|
||||
else:
|
||||
render(thread.category)
|
||||
|
|
@ -413,7 +411,6 @@ when defined(js):
|
|||
text " Reply"
|
||||
|
||||
render(state.lockButton, list.thread, currentUser)
|
||||
render(state.pinButton, list.thread, currentUser)
|
||||
|
||||
render(state.replyBox, list.thread, state.replyingTo, false)
|
||||
|
||||
|
|
|
|||
|
|
@ -15,7 +15,6 @@ type
|
|||
creation*: int64 ## Unix timestamp
|
||||
isLocked*: bool
|
||||
isSolved*: bool
|
||||
isPinned*: bool
|
||||
|
||||
ThreadList* = ref object
|
||||
threads*: seq[Thread]
|
||||
|
|
@ -97,18 +96,15 @@ when defined(js):
|
|||
else:
|
||||
return $duration.inSeconds & "s"
|
||||
|
||||
proc genThread(pos: int, thread: Thread, isNew: bool, noBorder: bool, displayCategory=true): VNode =
|
||||
proc genThread(thread: Thread, isNew: bool, noBorder: bool, displayCategory=true): VNode =
|
||||
let isOld = (getTime() - thread.creation.fromUnix).inWeeks > 2
|
||||
let isBanned = thread.author.rank.isBanned()
|
||||
result = buildHtml():
|
||||
tr(class=class({"no-border": noBorder, "banned": isBanned, "pinned": thread.isPinned, "thread-" & $pos: true})):
|
||||
tr(class=class({"no-border": noBorder, "banned": isBanned})):
|
||||
td(class="thread-title"):
|
||||
if thread.isLocked:
|
||||
italic(class="fas fa-lock fa-xs",
|
||||
title="Thread cannot be replied to")
|
||||
if thread.isPinned:
|
||||
italic(class="fas fa-thumbtack fa-xs",
|
||||
title="Pinned post")
|
||||
if isBanned:
|
||||
italic(class="fas fa-ban fa-xs",
|
||||
title="Thread author is banned")
|
||||
|
|
@ -120,11 +116,8 @@ when defined(js):
|
|||
title="Thread has a solution")
|
||||
a(href=makeUri("/t/" & $thread.id), onClick=anchorCB):
|
||||
text thread.topic
|
||||
tdiv(class="show-sm" & class({"d-none": not displayCategory})):
|
||||
tdiv(class=class({"d-none": not displayCategory})):
|
||||
render(thread.category)
|
||||
|
||||
td(class="hide-sm" & class({"d-none": not displayCategory})):
|
||||
render(thread.category)
|
||||
genUserAvatars(thread.users)
|
||||
td(class="thread-replies"): text $thread.replies
|
||||
td(class="hide-sm" & class({
|
||||
|
|
@ -206,7 +199,7 @@ when defined(js):
|
|||
|
||||
return buildHtml(tdiv(class="loading loading-lg"))
|
||||
|
||||
let displayCategory = categoryId.isNone
|
||||
let displayCategory = true
|
||||
|
||||
let list = state.list.get()
|
||||
result = buildHtml():
|
||||
|
|
@ -215,8 +208,7 @@ when defined(js):
|
|||
thead():
|
||||
tr:
|
||||
th(text "Topic")
|
||||
th(class="hide-sm" & class({"d-none": not displayCategory})): text "Category"
|
||||
th(class="thread-users"): text "Users"
|
||||
th(class="centered-header"): text "Users"
|
||||
th(class="centered-header"): text "Replies"
|
||||
th(class="hide-sm centered-header"): text "Views"
|
||||
th(class="centered-header"): text "Activity"
|
||||
|
|
@ -227,7 +219,7 @@ when defined(js):
|
|||
|
||||
let isLastThread = i+1 == list.threads.len
|
||||
let (isLastUnseen, isNew) = getInfo(list.threads, i, currentUser)
|
||||
genThread(i+1, thread, isNew,
|
||||
genThread(thread, isNew,
|
||||
noBorder=isLastUnseen or isLastThread,
|
||||
displayCategory=displayCategory)
|
||||
if isLastUnseen and (not isLastThread):
|
||||
|
|
|
|||
|
|
@ -17,7 +17,6 @@ type
|
|||
Admin ## Admin: can do everything
|
||||
|
||||
User* = object
|
||||
id*: string
|
||||
name*: string
|
||||
avatarUrl*: string
|
||||
lastOnline*: int64
|
||||
|
|
@ -76,4 +75,4 @@ when defined(js):
|
|||
title="User is a moderator")
|
||||
of Admin:
|
||||
italic(class="fas fa-chess-knight",
|
||||
title="User is an admin")
|
||||
title="User is an admin")
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
import asyncdispatch, smtp, strutils, json, os, rst, rstgen, xmltree, strtabs,
|
||||
htmlparser, streams, parseutils, options, logging
|
||||
from times import getTime, utc, format
|
||||
from times import getTime, getGMTime, format
|
||||
|
||||
# Used to be:
|
||||
# {'A'..'Z', 'a'..'z', '0'..'9', '_', '\128'..'\255'}
|
||||
let
|
||||
UsernameIdent* = IdentChars + {'-'} # TODO: Double check that everyone follows this.
|
||||
UsernameIdent* = IdentChars # TODO: Double check that everyone follows this.
|
||||
|
||||
import frontend/[karaxutils, error]
|
||||
export parseInt
|
||||
|
|
@ -17,8 +17,6 @@ type
|
|||
smtpUser*: string
|
||||
smtpPassword*: string
|
||||
smtpFromAddr*: string
|
||||
smtpTls*: bool
|
||||
smtpSsl*: bool
|
||||
mlistAddress*: string
|
||||
recaptchaSecretKey*: string
|
||||
recaptchaSiteKey*: string
|
||||
|
|
@ -57,8 +55,6 @@ proc loadConfig*(filename = getCurrentDir() / "forum.json"): Config =
|
|||
result.smtpUser = root{"smtpUser"}.getStr("")
|
||||
result.smtpPassword = root{"smtpPassword"}.getStr("")
|
||||
result.smtpFromAddr = root{"smtpFromAddr"}.getStr("")
|
||||
result.smtpTls = root{"smtpTls"}.getBool(false)
|
||||
result.smtpSsl = root{"smtpSsl"}.getBool(false)
|
||||
result.mlistAddress = root{"mlistAddress"}.getStr("")
|
||||
result.recaptchaSecretKey = root{"recaptchaSecretKey"}.getStr("")
|
||||
result.recaptchaSiteKey = root{"recaptchaSiteKey"}.getStr("")
|
||||
|
|
|
|||
|
|
@ -69,29 +69,6 @@ proc categoriesUserTests(session: Session, baseUrl: string) =
|
|||
|
||||
ensureExists title, LinkTextSelector
|
||||
|
||||
test "can create category thread and change category":
|
||||
with session:
|
||||
let newTitle = title & " Selection"
|
||||
click "#new-thread-btn"
|
||||
sendKeys "#thread-title", newTitle
|
||||
|
||||
selectCategory "fun"
|
||||
sendKeys "#reply-textarea", content
|
||||
|
||||
click "#create-thread-btn"
|
||||
checkText "#thread-title .category", "Fun"
|
||||
|
||||
selectCategory "announcements"
|
||||
|
||||
checkText "#thread-title .category", "Announcements"
|
||||
|
||||
# Make sure there is no error
|
||||
checkIsNone "#thread-title .text-error"
|
||||
|
||||
navigate baseUrl
|
||||
|
||||
ensureExists newTitle, LinkTextSelector
|
||||
|
||||
test "can navigate to categories page":
|
||||
with session:
|
||||
click "#categories-btn"
|
||||
|
|
@ -125,7 +102,7 @@ proc categoriesUserTests(session: Session, baseUrl: string) =
|
|||
click "#new-thread-btn"
|
||||
sendKeys "#thread-title", "Post 3"
|
||||
|
||||
selectCategory "unsorted"
|
||||
selectCategory "default"
|
||||
sendKeys "#reply-textarea", "Post 3"
|
||||
|
||||
click "#create-thread-btn"
|
||||
|
|
@ -135,11 +112,11 @@ proc categoriesUserTests(session: Session, baseUrl: string) =
|
|||
click "#categories-btn"
|
||||
ensureExists "#categories-list"
|
||||
|
||||
click "#category-unsorted"
|
||||
click "#category-default"
|
||||
checkText "#threads-list .thread-title a", "Post 3"
|
||||
for element in session.waitForElements("#threads-list .category-name"):
|
||||
# Have to user "innerText" because elements are hidden on this page
|
||||
assert element.getProperty("innerText") == "Unsorted"
|
||||
assert element.getProperty("innerText") == "Default"
|
||||
|
||||
selectCategory "announcements"
|
||||
checkText "#threads-list .thread-title a", "Post 2"
|
||||
|
|
|
|||
|
|
@ -30,8 +30,7 @@ proc elementIsSome(element: Option[Element]): bool =
|
|||
proc elementIsNone(element: Option[Element]): bool =
|
||||
return element.isNone
|
||||
|
||||
proc waitForElement*(session: Session, selector: string, strategy=CssSelector, timeout=20000, pollTime=50,
|
||||
waitCondition: proc(element: Option[Element]): bool = elementIsSome): Option[Element]
|
||||
proc waitForElement*(session: Session, selector: string, strategy=CssSelector, timeout=20000, pollTime=50, waitCondition=elementIsSome): Option[Element]
|
||||
|
||||
proc click*(session: Session, element: string, strategy=CssSelector) =
|
||||
let el = session.waitForElement(element, strategy)
|
||||
|
|
@ -72,14 +71,14 @@ proc setColor*(session: Session, element, color: string, strategy=CssSelector) =
|
|||
proc checkIsNone*(session: Session, element: string, strategy=CssSelector) =
|
||||
discard session.waitForElement(element, strategy, waitCondition=elementIsNone)
|
||||
|
||||
template checkText*(session: Session, element, expectedValue: string) =
|
||||
proc checkText*(session: Session, element, expectedValue: string) =
|
||||
let el = session.waitForElement(element)
|
||||
check el.get().getText() == expectedValue
|
||||
|
||||
proc waitForElement*(
|
||||
session: Session, selector: string, strategy=CssSelector,
|
||||
timeout=20000, pollTime=50,
|
||||
waitCondition: proc(element: Option[Element]): bool = elementIsSome
|
||||
waitCondition=elementIsSome
|
||||
): Option[Element] =
|
||||
var waitTime = 0
|
||||
|
||||
|
|
|
|||
|
|
@ -40,4 +40,4 @@ proc test*(session: Session, baseUrl: string) =
|
|||
register "TEst1", "test1", verify = false
|
||||
|
||||
ensureExists "#signup-form .has-error"
|
||||
navigate baseUrl
|
||||
navigate baseUrl
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import unittest, common
|
||||
|
||||
import webdriver
|
||||
|
||||
let
|
||||
|
|
@ -39,53 +40,10 @@ proc userTests(session: Session, baseUrl: string) =
|
|||
checkText "#thread-title .title-text", userTitleStr
|
||||
checkText ".original-post div.post-content", userContentStr
|
||||
|
||||
test "can delete thread":
|
||||
with session:
|
||||
# create thread to be deleted
|
||||
click "#new-thread-btn"
|
||||
|
||||
sendKeys "#thread-title", "To be deleted"
|
||||
sendKeys "#reply-textarea", "This thread is to be deleted"
|
||||
|
||||
click "#create-thread-btn"
|
||||
|
||||
click ".post-buttons .delete-button"
|
||||
|
||||
# click delete confirmation
|
||||
click "#delete-modal .delete-btn"
|
||||
|
||||
# Make sure the forum post is gone
|
||||
checkIsNone "To be deleted", LinkTextSelector
|
||||
|
||||
test "cannot (un)pin thread":
|
||||
with session:
|
||||
navigate(baseUrl)
|
||||
|
||||
click "#new-thread-btn"
|
||||
|
||||
sendKeys "#thread-title", "Unpinnable"
|
||||
sendKeys "#reply-textarea", "Cannot (un)pin as an user"
|
||||
|
||||
click "#create-thread-btn"
|
||||
|
||||
checkIsNone "#pin-btn"
|
||||
|
||||
test "cannot lock threads":
|
||||
with session:
|
||||
navigate(baseUrl)
|
||||
|
||||
click "#new-thread-btn"
|
||||
|
||||
sendKeys "#thread-title", "Locking"
|
||||
sendkeys "#reply-textarea", "Cannot lock as an user"
|
||||
|
||||
click "#create-thread-btn"
|
||||
|
||||
checkIsNone "#lock-btn"
|
||||
|
||||
session.logout()
|
||||
|
||||
proc anonymousTests(session: Session, baseUrl: string) =
|
||||
|
||||
suite "anonymous user tests":
|
||||
with session:
|
||||
navigate baseUrl
|
||||
|
|
@ -185,70 +143,6 @@ proc adminTests(session: Session, baseUrl: string) =
|
|||
# Make sure the forum post is gone
|
||||
checkIsNone adminTitleStr, LinkTextSelector
|
||||
|
||||
test "can pin a thread":
|
||||
with session:
|
||||
click "#new-thread-btn"
|
||||
sendKeys "#thread-title", "Pinned post"
|
||||
sendKeys "#reply-textarea", "A pinned post"
|
||||
click "#create-thread-btn"
|
||||
|
||||
navigate(baseUrl)
|
||||
click "#new-thread-btn"
|
||||
sendKeys "#thread-title", "Normal post"
|
||||
sendKeys "#reply-textarea", "A normal post"
|
||||
click "#create-thread-btn"
|
||||
|
||||
navigate(baseUrl)
|
||||
click "Pinned post", LinkTextSelector
|
||||
click "#pin-btn"
|
||||
checkText "#pin-btn", "Unpin Thread"
|
||||
|
||||
navigate(baseUrl)
|
||||
|
||||
# Make sure pin exists
|
||||
ensureExists "#threads-list .thread-1 .thread-title i"
|
||||
|
||||
checkText "#threads-list .thread-1 .thread-title a", "Pinned post"
|
||||
checkText "#threads-list .thread-2 .thread-title a", "Normal post"
|
||||
|
||||
test "can unpin a thread":
|
||||
with session:
|
||||
click "Pinned post", LinkTextSelector
|
||||
click "#pin-btn"
|
||||
checkText "#pin-btn", "Pin Thread"
|
||||
|
||||
navigate(baseUrl)
|
||||
|
||||
checkIsNone "#threads-list .thread-2 .thread-title i"
|
||||
|
||||
checkText "#threads-list .thread-1 .thread-title a", "Normal post"
|
||||
checkText "#threads-list .thread-2 .thread-title a", "Pinned post"
|
||||
|
||||
test "can lock a thread":
|
||||
with session:
|
||||
click "Locking", LinkTextSelector
|
||||
click "#lock-btn"
|
||||
|
||||
ensureExists "#thread-title i.fas.fa-lock.fa-xs"
|
||||
|
||||
test "locked thread appears on frontpage":
|
||||
with session:
|
||||
click "#new-thread-btn"
|
||||
sendKeys "#thread-title", "A new locked thread"
|
||||
sendKeys "#reply-textarea", "This thread should appear locked on the frontpage"
|
||||
click "#create-thread-btn"
|
||||
click "#lock-btn"
|
||||
|
||||
navigate(baseUrl)
|
||||
ensureExists "#threads-list .thread-1 .thread-title i.fas.fa-lock.fa-xs"
|
||||
|
||||
test "can unlock a thread":
|
||||
with session:
|
||||
click "Locking", LinkTextSelector
|
||||
click "#lock-btn"
|
||||
|
||||
checkIsNone "#thread-title i.fas.fa-lock.fa-xs"
|
||||
|
||||
session.logout()
|
||||
|
||||
proc test*(session: Session, baseUrl: string) =
|
||||
|
|
@ -264,4 +158,4 @@ proc test*(session: Session, baseUrl: string) =
|
|||
|
||||
unBanUser(session, baseUrl)
|
||||
|
||||
session.navigate(baseUrl)
|
||||
session.navigate(baseUrl)
|
||||
Loading…
Add table
Add a link
Reference in a new issue