Compare commits
65 commits
mobile_fix
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
88c2011323 | ||
|
|
4a181e9cb1 | ||
|
|
4f00ea5942 | ||
|
|
9994fc7840 | ||
|
|
ceb04561cd | ||
|
|
40d7b1cf02 |
||
|
|
c4684155f5 | ||
|
|
f940f81861 | ||
|
|
a1601b4600 |
||
|
|
35e0de7b91 |
||
|
|
7954a38601 |
||
|
|
48c025ae78 |
||
|
|
8782dff349 | ||
|
|
0055a12fc1 | ||
|
|
8cd5c45cda | ||
|
|
5b7b271627 |
||
|
|
6c6552176a | ||
|
|
3ac9ec3ff6 | ||
|
|
7d8417ff97 | ||
|
|
b2932e9348 |
||
|
|
108aeda5b3 |
||
|
|
3d975e8386 | ||
|
|
4f8a585049 | ||
|
|
b27096ff75 | ||
|
|
ce3de27fb9 | ||
|
|
9739c34abd | ||
|
|
4821746c5d | ||
|
|
dc80ef022e |
||
|
|
77fd9af1cd | ||
|
|
e62ae672b3 | ||
|
|
6e32ec27b4 |
||
|
|
030c52020e |
||
|
|
abb770ebd1 |
||
|
|
16c9daea52 |
||
|
|
8bad518e4b |
||
|
|
5c4d9b36d3 |
||
|
|
14786d3d49 |
||
|
|
48314432e9 |
||
|
|
d808749054 |
||
|
|
03fd4fdc13 |
||
|
|
ebbfa265d5 |
||
|
|
b933a9b2e8 | ||
|
|
474fa63985 | ||
|
|
418bb3fe47 |
||
|
|
d372d9f980 |
||
|
|
2987955e8a |
||
|
|
16abee0596 |
||
|
|
f5e1a71e6e |
||
|
|
f0bfd5516a |
||
|
|
fcbf22a64a | ||
|
|
53ad811bf8 |
||
|
|
55c9476810 | ||
|
|
e749512818 | ||
|
|
a3052efd78 | ||
|
|
6c6ed08ec9 | ||
|
|
3b6e7363a9 | ||
|
|
cd565eabe0 |
||
|
|
f0d9a89167 |
||
|
|
d8661f62c7 |
||
|
|
8984074809 | ||
|
|
3adba32f1f | ||
|
|
f35d6c4a32 | ||
|
|
3db01a1d44 | ||
|
|
dcea4091f4 | ||
|
|
c2cc26ea77 |
24 changed files with 533 additions and 143 deletions
80
.github/workflows/main.yml
vendored
Normal file
80
.github/workflows/main.yml
vendored
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
# 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
45
.travis.yml
|
|
@ -1,45 +0,0 @@
|
||||||
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,7 +80,6 @@ nimble frontend
|
||||||
nimble backend
|
nimble backend
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
Development typically involves running `nimble devdb` which sets up the
|
Development typically involves running `nimble devdb` which sets up the
|
||||||
database for development and testing, then `nimble backend`
|
database for development and testing, then `nimble backend`
|
||||||
which compiles and runs the forum's backend, and `nimble frontend`
|
which compiles and runs the forum's backend, and `nimble frontend`
|
||||||
|
|
@ -88,6 +87,38 @@ separately to build the frontend. When making changes to the frontend it
|
||||||
should be enough to simply run `nimble frontend` again to rebuild. This command
|
should be enough to simply run `nimble frontend` again to rebuild. This command
|
||||||
will also build the SASS ``nimforum.scss`` file in the `public/css` directory.
|
will also build the SASS ``nimforum.scss`` file in the `public/css` directory.
|
||||||
|
|
||||||
|
### With docker
|
||||||
|
|
||||||
|
You can easily launch site on localhost if you have `docker` and `docker-compose`.
|
||||||
|
You don't have to setup dependencies (libsass, sglite, pcre, etc...) on you host PC.
|
||||||
|
|
||||||
|
To get up and running:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd docker
|
||||||
|
docker-compose build
|
||||||
|
docker-compose up
|
||||||
|
```
|
||||||
|
|
||||||
|
And you can access local NimForum site.
|
||||||
|
Open http://localhost:5000 .
|
||||||
|
|
||||||
|
# Troubleshooting
|
||||||
|
|
||||||
|
You might have to run `nimble install karax@#5f21dcd`, if setup fails
|
||||||
|
with:
|
||||||
|
|
||||||
|
```
|
||||||
|
andinus@circinus ~/projects/forks/nimforum> nimble --verbose devdb
|
||||||
|
[...]
|
||||||
|
Installing karax@#5f21dcd
|
||||||
|
Tip: 24 messages have been suppressed, use --verbose to show them.
|
||||||
|
Error: No binaries built, did you specify a valid binary name?
|
||||||
|
[...]
|
||||||
|
Error: Exception raised during nimble script execution
|
||||||
|
```
|
||||||
|
|
||||||
|
The hash needs to be replaced with the one specified in output.
|
||||||
|
|
||||||
# Copyright
|
# Copyright
|
||||||
|
|
||||||
|
|
|
||||||
14
docker/Dockerfile
Normal file
14
docker/Dockerfile
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
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
|
||||||
12
docker/docker-compose.yml
Normal file
12
docker/docker-compose.yml
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
version: "3.7"
|
||||||
|
|
||||||
|
services:
|
||||||
|
forum:
|
||||||
|
build:
|
||||||
|
context: ../
|
||||||
|
dockerfile: ./docker/Dockerfile
|
||||||
|
volumes:
|
||||||
|
- "../:/app"
|
||||||
|
ports:
|
||||||
|
- "5000:5000"
|
||||||
|
entrypoint: "/app/docker/entrypoint.sh"
|
||||||
19
docker/entrypoint.sh
Executable file
19
docker/entrypoint.sh
Executable file
|
|
@ -0,0 +1,19 @@
|
||||||
|
#!/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
|
# Package
|
||||||
version = "2.0.2"
|
version = "2.1.0"
|
||||||
author = "Dominik Picheta"
|
author = "Dominik Picheta"
|
||||||
description = "The Nim forum"
|
description = "The Nim forum"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|
@ -13,13 +13,13 @@ skipExt = @["nim"]
|
||||||
# Dependencies
|
# Dependencies
|
||||||
|
|
||||||
requires "nim >= 1.0.6"
|
requires "nim >= 1.0.6"
|
||||||
requires "jester#d8a03aa"
|
requires "jester#405be2e"
|
||||||
requires "bcrypt#head"
|
requires "bcrypt#440c5676ff6"
|
||||||
requires "hmac#9c61ebe2fd134cf97"
|
requires "hmac#9c61ebe2fd134cf97"
|
||||||
requires "recaptcha#d06488e"
|
requires "recaptcha#d06488e"
|
||||||
requires "sass#649e0701fa5c"
|
requires "sass#649e0701fa5c"
|
||||||
|
|
||||||
requires "karax#f6bda9a"
|
requires "karax#5f21dcd"
|
||||||
|
|
||||||
requires "webdriver#429933a"
|
requires "webdriver#429933a"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ table th {
|
||||||
// Custom styles.
|
// Custom styles.
|
||||||
// - Navigation bar.
|
// - Navigation bar.
|
||||||
$navbar-height: 60px;
|
$navbar-height: 60px;
|
||||||
$default-category-color: #98c766;
|
$default-category-color: #a3a3a3;
|
||||||
$logo-height: $navbar-height - 20px;
|
$logo-height: $navbar-height - 20px;
|
||||||
|
|
||||||
.navbar-button {
|
.navbar-button {
|
||||||
|
|
@ -51,6 +51,7 @@ $logo-height: $navbar-height - 20px;
|
||||||
// Unfortunately we must colour the controls in the navbar manually.
|
// Unfortunately we must colour the controls in the navbar manually.
|
||||||
.search-input {
|
.search-input {
|
||||||
@extend .form-input;
|
@extend .form-input;
|
||||||
|
min-width: 120px;
|
||||||
border-color: $navbar-border-color-dark;
|
border-color: $navbar-border-color-dark;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -128,6 +129,9 @@ $logo-height: $navbar-height - 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.category-status {
|
.category-status {
|
||||||
|
font-size: small;
|
||||||
|
font-weight: bold;
|
||||||
|
|
||||||
.topic-count {
|
.topic-count {
|
||||||
margin-left: 5px;
|
margin-left: 5px;
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
|
|
@ -258,7 +262,7 @@ $threads-meta-color: #545d70;
|
||||||
.category-color {
|
.category-color {
|
||||||
width: 0;
|
width: 0;
|
||||||
height: 0;
|
height: 0;
|
||||||
border: 0.3rem solid $default-category-color;
|
border: 0.25rem solid $default-category-color;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
margin-right: 5px;
|
margin-right: 5px;
|
||||||
}
|
}
|
||||||
|
|
@ -297,6 +301,14 @@ $threads-meta-color: #545d70;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.thread-replies, .thread-time, .views-text, .popular-text, .centered-header {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thread-users {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
.thread-time {
|
.thread-time {
|
||||||
color: $threads-meta-color;
|
color: $threads-meta-color;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -44,8 +44,18 @@ proc sendMail(
|
||||||
warn("Cannot send mail: no smtp from address configured (smtpFromAddr).")
|
warn("Cannot send mail: no smtp from address configured (smtpFromAddr).")
|
||||||
return
|
return
|
||||||
|
|
||||||
var client = newAsyncSmtp()
|
var client: AsyncSmtp
|
||||||
await client.connect(mailer.config.smtpAddress, Port(mailer.config.smtpPort))
|
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))
|
||||||
|
|
||||||
if mailer.config.smtpUser.len > 0:
|
if mailer.config.smtpUser.len > 0:
|
||||||
await client.auth(mailer.config.smtpUser, mailer.config.smtpPassword)
|
await client.auth(mailer.config.smtpUser, mailer.config.smtpPassword)
|
||||||
|
|
||||||
|
|
@ -54,6 +64,9 @@ proc sendMail(
|
||||||
var headers = otherHeaders
|
var headers = otherHeaders
|
||||||
headers.add(("From", mailer.config.smtpFromAddr))
|
headers.add(("From", mailer.config.smtpFromAddr))
|
||||||
|
|
||||||
|
let dateHeader = now().utc().format("ddd, dd MMM yyyy hh:mm:ss") & " +0000"
|
||||||
|
headers.add(("Date", dateHeader))
|
||||||
|
|
||||||
let encoded = createMessage(subject, message,
|
let encoded = createMessage(subject, message,
|
||||||
toList, @[], headers)
|
toList, @[], headers)
|
||||||
|
|
||||||
|
|
|
||||||
133
src/forum.nim
133
src/forum.nim
|
|
@ -276,25 +276,26 @@ template createTFD() =
|
||||||
new(c)
|
new(c)
|
||||||
init(c)
|
init(c)
|
||||||
c.req = request
|
c.req = request
|
||||||
if request.cookies.len > 0:
|
if cookies(request).len > 0:
|
||||||
checkLoggedIn(c)
|
checkLoggedIn(c)
|
||||||
|
|
||||||
#[ DB functions. TODO: Move to another module? ]#
|
#[ DB functions. TODO: Move to another module? ]#
|
||||||
|
|
||||||
proc selectUser(userRow: seq[string], avatarSize: int=80): User =
|
proc selectUser(userRow: seq[string], avatarSize: int=80): User =
|
||||||
result = User(
|
result = User(
|
||||||
name: userRow[0],
|
id: userRow[0],
|
||||||
avatarUrl: userRow[1].getGravatarUrl(avatarSize),
|
name: userRow[1],
|
||||||
lastOnline: userRow[2].parseInt,
|
avatarUrl: userRow[2].getGravatarUrl(avatarSize),
|
||||||
previousVisitAt: userRow[3].parseInt,
|
lastOnline: userRow[3].parseInt,
|
||||||
rank: parseEnum[Rank](userRow[4]),
|
previousVisitAt: userRow[4].parseInt,
|
||||||
isDeleted: userRow[5] == "1"
|
rank: parseEnum[Rank](userRow[5]),
|
||||||
|
isDeleted: userRow[6] == "1"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Don't give data about a deleted user.
|
# Don't give data about a deleted user.
|
||||||
if result.isDeleted:
|
if result.isDeleted:
|
||||||
result.name = "DeletedUser"
|
result.name = "DeletedUser"
|
||||||
result.avatarUrl = getGravatarUrl(result.name & userRow[1], avatarSize)
|
result.avatarUrl = getGravatarUrl(result.name & userRow[2], avatarSize)
|
||||||
|
|
||||||
proc selectPost(postRow: seq[string], skippedPosts: seq[int],
|
proc selectPost(postRow: seq[string], skippedPosts: seq[int],
|
||||||
replyingTo: Option[PostLink], history: seq[PostInfo],
|
replyingTo: Option[PostLink], history: seq[PostInfo],
|
||||||
|
|
@ -302,7 +303,7 @@ proc selectPost(postRow: seq[string], skippedPosts: seq[int],
|
||||||
return Post(
|
return Post(
|
||||||
id: postRow[0].parseInt,
|
id: postRow[0].parseInt,
|
||||||
replyingTo: replyingTo,
|
replyingTo: replyingTo,
|
||||||
author: selectUser(postRow[5..10]),
|
author: selectUser(postRow[5..11]),
|
||||||
likes: likes,
|
likes: likes,
|
||||||
seen: false, # TODO:
|
seen: false, # TODO:
|
||||||
history: history,
|
history: history,
|
||||||
|
|
@ -318,7 +319,7 @@ proc selectReplyingTo(replyingTo: string): Option[PostLink] =
|
||||||
|
|
||||||
const replyingToQuery = sql"""
|
const replyingToQuery = sql"""
|
||||||
select p.id, strftime('%s', p.creation), p.thread,
|
select p.id, strftime('%s', p.creation), p.thread,
|
||||||
u.name, u.email, strftime('%s', u.lastOnline),
|
u.id, u.name, u.email, strftime('%s', u.lastOnline),
|
||||||
strftime('%s', u.previousVisitAt), u.status,
|
strftime('%s', u.previousVisitAt), u.status,
|
||||||
u.isDeleted,
|
u.isDeleted,
|
||||||
t.name
|
t.name
|
||||||
|
|
@ -334,7 +335,7 @@ proc selectReplyingTo(replyingTo: string): Option[PostLink] =
|
||||||
topic: row[^1],
|
topic: row[^1],
|
||||||
threadId: row[2].parseInt(),
|
threadId: row[2].parseInt(),
|
||||||
postId: row[0].parseInt(),
|
postId: row[0].parseInt(),
|
||||||
author: some(selectUser(row[3..8]))
|
author: some(selectUser(row[3..9]))
|
||||||
))
|
))
|
||||||
|
|
||||||
proc selectHistory(postId: int): seq[PostInfo] =
|
proc selectHistory(postId: int): seq[PostInfo] =
|
||||||
|
|
@ -353,7 +354,7 @@ proc selectHistory(postId: int): seq[PostInfo] =
|
||||||
|
|
||||||
proc selectLikes(postId: int): seq[User] =
|
proc selectLikes(postId: int): seq[User] =
|
||||||
const likeQuery = sql"""
|
const likeQuery = sql"""
|
||||||
select u.name, u.email, strftime('%s', u.lastOnline),
|
select u.id, u.name, u.email, strftime('%s', u.lastOnline),
|
||||||
strftime('%s', u.previousVisitAt), u.status,
|
strftime('%s', u.previousVisitAt), u.status,
|
||||||
u.isDeleted
|
u.isDeleted
|
||||||
from like h, person u
|
from like h, person u
|
||||||
|
|
@ -368,7 +369,7 @@ proc selectLikes(postId: int): seq[User] =
|
||||||
proc selectThreadAuthor(threadId: int): User =
|
proc selectThreadAuthor(threadId: int): User =
|
||||||
const authorQuery =
|
const authorQuery =
|
||||||
sql"""
|
sql"""
|
||||||
select name, email, strftime('%s', lastOnline),
|
select id, name, email, strftime('%s', lastOnline),
|
||||||
strftime('%s', previousVisitAt), status, isDeleted
|
strftime('%s', previousVisitAt), status, isDeleted
|
||||||
from person where id in (
|
from person where id in (
|
||||||
select author from post
|
select author from post
|
||||||
|
|
@ -386,7 +387,7 @@ proc selectThread(threadRow: seq[string], author: User): Thread =
|
||||||
where thread = ?;"""
|
where thread = ?;"""
|
||||||
const usersListQuery =
|
const usersListQuery =
|
||||||
sql"""
|
sql"""
|
||||||
select name, email, strftime('%s', lastOnline),
|
select u.id, name, email, strftime('%s', lastOnline),
|
||||||
strftime('%s', previousVisitAt), status, u.isDeleted,
|
strftime('%s', previousVisitAt), status, u.isDeleted,
|
||||||
count(*)
|
count(*)
|
||||||
from person u, post p where p.author = u.id and p.thread = ?
|
from person u, post p where p.author = u.id and p.thread = ?
|
||||||
|
|
@ -399,10 +400,10 @@ proc selectThread(threadRow: seq[string], author: User): Thread =
|
||||||
id: threadRow[0].parseInt,
|
id: threadRow[0].parseInt,
|
||||||
topic: threadRow[1],
|
topic: threadRow[1],
|
||||||
category: Category(
|
category: Category(
|
||||||
id: threadRow[5].parseInt,
|
id: threadRow[6].parseInt,
|
||||||
name: threadRow[6],
|
name: threadRow[7],
|
||||||
description: threadRow[7],
|
description: threadRow[8],
|
||||||
color: threadRow[8]
|
color: threadRow[9]
|
||||||
),
|
),
|
||||||
users: @[],
|
users: @[],
|
||||||
replies: posts[0].parseInt-1,
|
replies: posts[0].parseInt-1,
|
||||||
|
|
@ -411,6 +412,7 @@ proc selectThread(threadRow: seq[string], author: User): Thread =
|
||||||
creation: posts[1].parseInt,
|
creation: posts[1].parseInt,
|
||||||
isLocked: threadRow[4] == "1",
|
isLocked: threadRow[4] == "1",
|
||||||
isSolved: false, # TODO: Add a field to `post` to identify the solution.
|
isSolved: false, # TODO: Add a field to `post` to identify the solution.
|
||||||
|
isPinned: threadRow[5] == "1"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Gather the users list.
|
# Gather the users list.
|
||||||
|
|
@ -498,10 +500,10 @@ proc updatePost(c: TForumData, postId: int, content: string,
|
||||||
|
|
||||||
# Verify that the current user has permissions to edit the specified post.
|
# Verify that the current user has permissions to edit the specified post.
|
||||||
let creation = fromUnix(postRow[1].parseInt)
|
let creation = fromUnix(postRow[1].parseInt)
|
||||||
let isArchived = (getTime() - creation).inWeeks > 8
|
let isArchived = (getTime() - creation).inHours >= 2
|
||||||
let canEdit = c.rank == Admin or c.userid == postRow[0]
|
let canEdit = c.rank == Admin or c.userid == postRow[0]
|
||||||
if isArchived:
|
if isArchived and c.rank < Admin:
|
||||||
raise newForumError("This post is archived and can no longer be edited")
|
raise newForumError("This post is too old and can no longer be edited")
|
||||||
if not canEdit:
|
if not canEdit:
|
||||||
raise newForumError("You cannot edit this post")
|
raise newForumError("You cannot edit this post")
|
||||||
|
|
||||||
|
|
@ -532,7 +534,7 @@ proc updateThread(c: TForumData, threadId: string, queryKeys: seq[string], query
|
||||||
let threadAuthor = selectThreadAuthor(threadId.parseInt)
|
let threadAuthor = selectThreadAuthor(threadId.parseInt)
|
||||||
|
|
||||||
# Verify that the current user has permissions to edit the specified thread.
|
# Verify that the current user has permissions to edit the specified thread.
|
||||||
let canEdit = c.rank == Admin or c.userid == threadAuthor.name
|
let canEdit = c.rank in {Admin, Moderator} or c.userid == threadAuthor.id
|
||||||
if not canEdit:
|
if not canEdit:
|
||||||
raise newForumError("You cannot edit this thread")
|
raise newForumError("You cannot edit this thread")
|
||||||
|
|
||||||
|
|
@ -708,15 +710,25 @@ proc executeLockState(c: TForumData, threadId: int, locked: bool) =
|
||||||
# Save the like.
|
# Save the like.
|
||||||
exec(db, crud(crUpdate, "thread", "isLocked"), locked.int, threadId)
|
exec(db, crud(crUpdate, "thread", "isLocked"), locked.int, threadId)
|
||||||
|
|
||||||
|
proc executePinState(c: TForumData, threadId: int, pinned: bool) =
|
||||||
|
if c.rank < Moderator:
|
||||||
|
raise newForumError("You do not have permission to pin this thread.")
|
||||||
|
|
||||||
|
# (Un)pin this thread
|
||||||
|
exec(db, crud(crUpdate, "thread", "isPinned"), pinned.int, threadId)
|
||||||
|
|
||||||
proc executeDeletePost(c: TForumData, postId: int) =
|
proc executeDeletePost(c: TForumData, postId: int) =
|
||||||
# Verify that this post belongs to the user.
|
# Verify that this post belongs to the user.
|
||||||
const postQuery = sql"""
|
const postQuery = sql"""
|
||||||
select p.id from post p
|
select p.author, p.id from post p
|
||||||
where p.author = ? and p.id = ?
|
where p.author = ? and p.id = ?
|
||||||
"""
|
"""
|
||||||
let id = getValue(db, postQuery, c.username, postId)
|
let
|
||||||
|
row = getRow(db, postQuery, c.username, postId)
|
||||||
|
author = row[0]
|
||||||
|
id = row[1]
|
||||||
|
|
||||||
if id.len == 0 and c.rank < Admin:
|
if id.len == 0 and not (c.rank == Admin or c.userid == author):
|
||||||
raise newForumError("You cannot delete this post")
|
raise newForumError("You cannot delete this post")
|
||||||
|
|
||||||
# Set the `isDeleted` flag.
|
# Set the `isDeleted` flag.
|
||||||
|
|
@ -771,7 +783,7 @@ proc updateProfile(
|
||||||
raise newForumError("Rank needs a change when setting new email.")
|
raise newForumError("Rank needs a change when setting new email.")
|
||||||
|
|
||||||
await sendSecureEmail(
|
await sendSecureEmail(
|
||||||
mailer, ActivateEmail, c.req, row[0], row[1], row[2], row[3]
|
mailer, ActivateEmail, c.req, row[0], row[1], email, row[3]
|
||||||
)
|
)
|
||||||
|
|
||||||
validateEmail(email, checkDuplicated=wasEmailChanged)
|
validateEmail(email, checkDuplicated=wasEmailChanged)
|
||||||
|
|
@ -829,27 +841,27 @@ routes:
|
||||||
categoryArgs.insert($categoryId, 0)
|
categoryArgs.insert($categoryId, 0)
|
||||||
|
|
||||||
const threadsQuery =
|
const threadsQuery =
|
||||||
"""select t.id, t.name, views, strftime('%s', modified), isLocked,
|
"""select t.id, t.name, views, strftime('%s', modified), isLocked, isPinned,
|
||||||
c.id, c.name, c.description, c.color,
|
c.id, c.name, c.description, c.color,
|
||||||
u.name, u.email, strftime('%s', u.lastOnline),
|
u.id, u.name, u.email, strftime('%s', u.lastOnline),
|
||||||
strftime('%s', u.previousVisitAt), u.status, u.isDeleted
|
strftime('%s', u.previousVisitAt), u.status, u.isDeleted
|
||||||
from thread t, category c, person u
|
from thread t, category c, person u
|
||||||
where t.isDeleted = 0 and category = c.id and $#
|
where t.isDeleted = 0 and category = c.id and $#
|
||||||
u.status <> 'Spammer' and u.status <> 'Troll' and
|
u.status <> 'Spammer' and u.status <> 'Troll' and
|
||||||
u.id in (
|
u.id = (
|
||||||
select u.id from post p, person u
|
select p.author from post p
|
||||||
where p.author = u.id and p.thread = t.id
|
where p.thread = t.id
|
||||||
order by u.id
|
order by p.author
|
||||||
limit 1
|
limit 1
|
||||||
)
|
)
|
||||||
order by modified desc limit ?, ?;"""
|
order by isPinned desc, modified desc limit ?, ?;"""
|
||||||
|
|
||||||
let thrCount = getValue(db, countQuery, countArgs).parseInt()
|
let thrCount = getValue(db, countQuery, countArgs).parseInt()
|
||||||
let moreCount = max(0, thrCount - (start + count))
|
let moreCount = max(0, thrCount - (start + count))
|
||||||
|
|
||||||
var list = ThreadList(threads: @[], moreCount: moreCount)
|
var list = ThreadList(threads: @[], moreCount: moreCount)
|
||||||
for data in getAllRows(db, sql(threadsQuery % categorySection), categoryArgs):
|
for data in getAllRows(db, sql(threadsQuery % categorySection), categoryArgs):
|
||||||
let thread = selectThread(data[0 .. 8], selectUser(data[9 .. ^1]))
|
let thread = selectThread(data[0 .. 9], selectUser(data[10 .. ^1]))
|
||||||
list.threads.add(thread)
|
list.threads.add(thread)
|
||||||
|
|
||||||
resp $(%list), "application/json"
|
resp $(%list), "application/json"
|
||||||
|
|
@ -864,19 +876,24 @@ routes:
|
||||||
count = 10
|
count = 10
|
||||||
|
|
||||||
const threadsQuery =
|
const threadsQuery =
|
||||||
sql"""select t.id, t.name, views, strftime('%s', modified), isLocked,
|
sql"""select t.id, t.name, views, strftime('%s', modified), isLocked, isPinned,
|
||||||
c.id, c.name, c.description, c.color
|
c.id, c.name, c.description, c.color
|
||||||
from thread t, category c
|
from thread t, category c
|
||||||
where t.id = ? and isDeleted = 0 and category = c.id;"""
|
where t.id = ? and isDeleted = 0 and category = c.id;"""
|
||||||
|
|
||||||
let threadRow = getRow(db, threadsQuery, id)
|
let threadRow = getRow(db, threadsQuery, id)
|
||||||
|
if threadRow[0].len == 0:
|
||||||
|
let err = PostError(
|
||||||
|
message: "Specified thread does not exist"
|
||||||
|
)
|
||||||
|
resp Http404, $(%err), "application/json"
|
||||||
let thread = selectThread(threadRow, selectThreadAuthor(id))
|
let thread = selectThread(threadRow, selectThreadAuthor(id))
|
||||||
|
|
||||||
let postsQuery =
|
let postsQuery =
|
||||||
sql(
|
sql(
|
||||||
"""select p.id, p.content, strftime('%s', p.creation), p.author,
|
"""select p.id, p.content, strftime('%s', p.creation), p.author,
|
||||||
p.replyingTo,
|
p.replyingTo,
|
||||||
u.name, u.email, strftime('%s', u.lastOnline),
|
u.id, u.name, u.email, strftime('%s', u.lastOnline),
|
||||||
strftime('%s', u.previousVisitAt), u.status,
|
strftime('%s', u.previousVisitAt), u.status,
|
||||||
u.isDeleted
|
u.isDeleted
|
||||||
from post p, person u
|
from post p, person u
|
||||||
|
|
@ -915,15 +932,20 @@ routes:
|
||||||
|
|
||||||
get "/specific_posts.json":
|
get "/specific_posts.json":
|
||||||
createTFD()
|
createTFD()
|
||||||
var
|
var ids: JsonNode
|
||||||
|
try:
|
||||||
ids = parseJson(@"ids")
|
ids = parseJson(@"ids")
|
||||||
|
except JsonParsingError:
|
||||||
|
let err = PostError(
|
||||||
|
message: "Invalid JSON in the `ids` parameter"
|
||||||
|
)
|
||||||
|
resp Http400, $(%err), "application/json"
|
||||||
cond ids.kind == JArray
|
cond ids.kind == JArray
|
||||||
let intIDs = ids.elems.map(x => x.getInt())
|
let intIDs = ids.elems.map(x => x.getInt())
|
||||||
let postsQuery = sql("""
|
let postsQuery = sql("""
|
||||||
select p.id, p.content, strftime('%s', p.creation), p.author,
|
select p.id, p.content, strftime('%s', p.creation), p.author,
|
||||||
p.replyingTo,
|
p.replyingTo,
|
||||||
u.name, u.email, strftime('%s', u.lastOnline),
|
u.id, u.name, u.email, strftime('%s', u.lastOnline),
|
||||||
strftime('%s', u.previousVisitAt), u.status,
|
strftime('%s', u.previousVisitAt), u.status,
|
||||||
u.isDeleted
|
u.isDeleted
|
||||||
from post p, person u
|
from post p, person u
|
||||||
|
|
@ -992,7 +1014,7 @@ routes:
|
||||||
""" % postsFrom)
|
""" % postsFrom)
|
||||||
|
|
||||||
let userQuery = sql("""
|
let userQuery = sql("""
|
||||||
select name, email, strftime('%s', lastOnline),
|
select id, name, email, strftime('%s', lastOnline),
|
||||||
strftime('%s', previousVisitAt), status, isDeleted,
|
strftime('%s', previousVisitAt), status, isDeleted,
|
||||||
strftime('%s', creation), id
|
strftime('%s', creation), id
|
||||||
from person
|
from person
|
||||||
|
|
@ -1018,7 +1040,7 @@ routes:
|
||||||
getValue(db, sql("select count(*) " & threadsFrom), userID).parseInt()
|
getValue(db, sql("select count(*) " & threadsFrom), userID).parseInt()
|
||||||
|
|
||||||
if c.rank >= Admin or c.username == username:
|
if c.rank >= Admin or c.username == username:
|
||||||
profile.email = some(userRow[1])
|
profile.email = some(userRow[2])
|
||||||
|
|
||||||
for row in db.getAllRows(postsQuery, username):
|
for row in db.getAllRows(postsQuery, username):
|
||||||
profile.posts.add(
|
profile.posts.add(
|
||||||
|
|
@ -1335,6 +1357,33 @@ routes:
|
||||||
except ForumError as exc:
|
except ForumError as exc:
|
||||||
resp Http400, $(%exc.data), "application/json"
|
resp Http400, $(%exc.data), "application/json"
|
||||||
|
|
||||||
|
post re"/(pin|unpin)":
|
||||||
|
createTFD()
|
||||||
|
if not c.loggedIn():
|
||||||
|
let err = PostError(
|
||||||
|
errorFields: @[],
|
||||||
|
message: "Not logged in."
|
||||||
|
)
|
||||||
|
resp Http401, $(%err), "application/json"
|
||||||
|
|
||||||
|
let formData = request.formData
|
||||||
|
cond "id" in formData
|
||||||
|
|
||||||
|
let threadId = getInt(formData["id"].body, -1)
|
||||||
|
cond threadId != -1
|
||||||
|
|
||||||
|
try:
|
||||||
|
case request.path
|
||||||
|
of "/pin":
|
||||||
|
executePinState(c, threadId, true)
|
||||||
|
of "/unpin":
|
||||||
|
executePinState(c, threadId, false)
|
||||||
|
else:
|
||||||
|
assert false
|
||||||
|
resp Http200, "{}", "application/json"
|
||||||
|
except ForumError as exc:
|
||||||
|
resp Http400, $(%exc.data), "application/json"
|
||||||
|
|
||||||
post re"/delete(Post|Thread)":
|
post re"/delete(Post|Thread)":
|
||||||
createTFD()
|
createTFD()
|
||||||
if not c.loggedIn():
|
if not c.loggedIn():
|
||||||
|
|
@ -1567,7 +1616,7 @@ routes:
|
||||||
postId: rowFT[2].parseInt(),
|
postId: rowFT[2].parseInt(),
|
||||||
postContent: content,
|
postContent: content,
|
||||||
creation: rowFT[4].parseInt(),
|
creation: rowFT[4].parseInt(),
|
||||||
author: selectUser(rowFT[5 .. 10]),
|
author: selectUser(rowFT[5 .. 11]),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@ when defined(js):
|
||||||
tdiv(class="category-color",
|
tdiv(class="category-color",
|
||||||
style=style(
|
style=style(
|
||||||
(StyleAttr.border,
|
(StyleAttr.border,
|
||||||
kstring"0.3rem solid #" & category.color)
|
kstring"0.25rem solid #" & category.color)
|
||||||
))
|
))
|
||||||
span(class="category-name"):
|
span(class="category-name"):
|
||||||
text category.name
|
text category.name
|
||||||
|
|
|
||||||
|
|
@ -96,8 +96,8 @@ when defined(js):
|
||||||
section(class="navbar-section"):
|
section(class="navbar-section"):
|
||||||
tdiv(class="input-group input-inline"):
|
tdiv(class="input-group input-inline"):
|
||||||
input(class="search-input input-sm",
|
input(class="search-input input-sm",
|
||||||
`type`="text", placeholder="search",
|
`type`="search", placeholder="Search",
|
||||||
id="search-box",
|
id="search-box", required="required",
|
||||||
onKeyDown=onKeyDown)
|
onKeyDown=onKeyDown)
|
||||||
if state.loading:
|
if state.loading:
|
||||||
tdiv(class="loading")
|
tdiv(class="loading")
|
||||||
|
|
|
||||||
|
|
@ -64,4 +64,4 @@ when defined(js):
|
||||||
renderPostUrl(thread.id, post.id)
|
renderPostUrl(thread.id, post.id)
|
||||||
|
|
||||||
proc renderPostUrl*(link: PostLink): string =
|
proc renderPostUrl*(link: PostLink): string =
|
||||||
renderPostUrl(link.threadId, link.postId)
|
renderPostUrl(link.threadId, link.postId)
|
||||||
|
|
|
||||||
|
|
@ -190,7 +190,7 @@ when defined(js):
|
||||||
else: ""
|
else: ""
|
||||||
|
|
||||||
result = buildHtml():
|
result = buildHtml():
|
||||||
button(class="btn btn-secondary",
|
button(class="btn btn-secondary", id="lock-btn",
|
||||||
onClick=(e: Event, n: VNode) =>
|
onClick=(e: Event, n: VNode) =>
|
||||||
onLockClick(e, n, state, thread),
|
onLockClick(e, n, state, thread),
|
||||||
"data-tooltip"=tooltip,
|
"data-tooltip"=tooltip,
|
||||||
|
|
@ -201,4 +201,61 @@ when defined(js):
|
||||||
text " Unlock Thread"
|
text " Unlock Thread"
|
||||||
else:
|
else:
|
||||||
italic(class="fas fa-lock")
|
italic(class="fas fa-lock")
|
||||||
text " Lock Thread"
|
text " Lock Thread"
|
||||||
|
|
||||||
|
type
|
||||||
|
PinButton* = ref object
|
||||||
|
error: Option[PostError]
|
||||||
|
loading: bool
|
||||||
|
|
||||||
|
proc newPinButton*(): PinButton =
|
||||||
|
PinButton()
|
||||||
|
|
||||||
|
proc onPost(httpStatus: int, response: kstring, state: PinButton,
|
||||||
|
thread: var Thread) =
|
||||||
|
postFinished:
|
||||||
|
thread.isPinned = not thread.isPinned
|
||||||
|
|
||||||
|
proc onPinClick(ev: Event, n: VNode, state: PinButton, thread: var Thread) =
|
||||||
|
if state.loading: return
|
||||||
|
|
||||||
|
state.loading = true
|
||||||
|
state.error = none[PostError]()
|
||||||
|
|
||||||
|
# Same as LockButton so the following is still a hack and karax should support this.
|
||||||
|
var formData = newFormData()
|
||||||
|
formData.append("id", $thread.id)
|
||||||
|
let uri =
|
||||||
|
if thread.isPinned:
|
||||||
|
makeUri("/unpin")
|
||||||
|
else:
|
||||||
|
makeUri("/pin")
|
||||||
|
ajaxPost(uri, @[], formData.to(cstring),
|
||||||
|
(s: int, r: kstring) => onPost(s, r, state, thread))
|
||||||
|
|
||||||
|
ev.preventDefault()
|
||||||
|
|
||||||
|
proc render*(state: PinButton, thread: var Thread,
|
||||||
|
currentUser: Option[User]): VNode =
|
||||||
|
if currentUser.isNone() or
|
||||||
|
currentUser.get().rank < Moderator:
|
||||||
|
return buildHtml(tdiv())
|
||||||
|
|
||||||
|
let tooltip =
|
||||||
|
if state.error.isSome(): state.error.get().message
|
||||||
|
else: ""
|
||||||
|
|
||||||
|
result = buildHtml():
|
||||||
|
button(class="btn btn-secondary", id="pin-btn",
|
||||||
|
onClick=(e: Event, n: VNode) =>
|
||||||
|
onPinClick(e, n, state, thread),
|
||||||
|
"data-tooltip"=tooltip,
|
||||||
|
onmouseleave=(e: Event, n: VNode) =>
|
||||||
|
(state.error = none[PostError]())):
|
||||||
|
if thread.isPinned:
|
||||||
|
italic(class="fas fa-thumbtack")
|
||||||
|
text " Unpin Thread"
|
||||||
|
else:
|
||||||
|
italic(class="fas fa-thumbtack")
|
||||||
|
text " Pin Thread"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ when defined(js):
|
||||||
likeButton: LikeButton
|
likeButton: LikeButton
|
||||||
deleteModal: DeleteModal
|
deleteModal: DeleteModal
|
||||||
lockButton: LockButton
|
lockButton: LockButton
|
||||||
|
pinButton: PinButton
|
||||||
categoryPicker: CategoryPicker
|
categoryPicker: CategoryPicker
|
||||||
|
|
||||||
proc onReplyPosted(id: int)
|
proc onReplyPosted(id: int)
|
||||||
|
|
@ -56,6 +57,7 @@ when defined(js):
|
||||||
likeButton: newLikeButton(),
|
likeButton: newLikeButton(),
|
||||||
deleteModal: newDeleteModal(onDeletePost, onDeleteThread, nil),
|
deleteModal: newDeleteModal(onDeletePost, onDeleteThread, nil),
|
||||||
lockButton: newLockButton(),
|
lockButton: newLockButton(),
|
||||||
|
pinButton: newPinButton(),
|
||||||
categoryPicker: newCategoryPicker(onCategoryChanged)
|
categoryPicker: newCategoryPicker(onCategoryChanged)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -209,12 +211,12 @@ when defined(js):
|
||||||
let loggedIn = currentUser.isSome()
|
let loggedIn = currentUser.isSome()
|
||||||
let authoredByUser =
|
let authoredByUser =
|
||||||
loggedIn and currentUser.get().name == thread.author.name
|
loggedIn and currentUser.get().name == thread.author.name
|
||||||
let currentAdmin =
|
let canChangeCategory =
|
||||||
currentUser.isSome() and currentUser.get().rank == Admin
|
loggedIn and currentUser.get().rank in {Admin, Moderator}
|
||||||
|
|
||||||
result = buildHtml():
|
result = buildHtml():
|
||||||
tdiv():
|
tdiv():
|
||||||
if authoredByUser or currentAdmin:
|
if authoredByUser or canChangeCategory:
|
||||||
render(state.categoryPicker, currentUser, compact=false)
|
render(state.categoryPicker, currentUser, compact=false)
|
||||||
else:
|
else:
|
||||||
render(thread.category)
|
render(thread.category)
|
||||||
|
|
@ -411,6 +413,7 @@ when defined(js):
|
||||||
text " Reply"
|
text " Reply"
|
||||||
|
|
||||||
render(state.lockButton, list.thread, currentUser)
|
render(state.lockButton, list.thread, currentUser)
|
||||||
|
render(state.pinButton, list.thread, currentUser)
|
||||||
|
|
||||||
render(state.replyBox, list.thread, state.replyingTo, false)
|
render(state.replyBox, list.thread, state.replyingTo, false)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ type
|
||||||
creation*: int64 ## Unix timestamp
|
creation*: int64 ## Unix timestamp
|
||||||
isLocked*: bool
|
isLocked*: bool
|
||||||
isSolved*: bool
|
isSolved*: bool
|
||||||
|
isPinned*: bool
|
||||||
|
|
||||||
ThreadList* = ref object
|
ThreadList* = ref object
|
||||||
threads*: seq[Thread]
|
threads*: seq[Thread]
|
||||||
|
|
@ -74,7 +75,7 @@ when defined(js):
|
||||||
return true
|
return true
|
||||||
|
|
||||||
proc genUserAvatars(users: seq[User]): VNode =
|
proc genUserAvatars(users: seq[User]): VNode =
|
||||||
result = buildHtml(td):
|
result = buildHtml(td(class="thread-users")):
|
||||||
for user in users:
|
for user in users:
|
||||||
render(user, "avatar avatar-sm", showStatus=true)
|
render(user, "avatar avatar-sm", showStatus=true)
|
||||||
text " "
|
text " "
|
||||||
|
|
@ -96,15 +97,18 @@ when defined(js):
|
||||||
else:
|
else:
|
||||||
return $duration.inSeconds & "s"
|
return $duration.inSeconds & "s"
|
||||||
|
|
||||||
proc genThread(thread: Thread, isNew: bool, noBorder: bool, displayCategory=true): VNode =
|
proc genThread(pos: int, thread: Thread, isNew: bool, noBorder: bool, displayCategory=true): VNode =
|
||||||
let isOld = (getTime() - thread.creation.fromUnix).inWeeks > 2
|
let isOld = (getTime() - thread.creation.fromUnix).inWeeks > 2
|
||||||
let isBanned = thread.author.rank.isBanned()
|
let isBanned = thread.author.rank.isBanned()
|
||||||
result = buildHtml():
|
result = buildHtml():
|
||||||
tr(class=class({"no-border": noBorder, "banned": isBanned})):
|
tr(class=class({"no-border": noBorder, "banned": isBanned, "pinned": thread.isPinned, "thread-" & $pos: true})):
|
||||||
td(class="thread-title"):
|
td(class="thread-title"):
|
||||||
if thread.isLocked:
|
if thread.isLocked:
|
||||||
italic(class="fas fa-lock fa-xs",
|
italic(class="fas fa-lock fa-xs",
|
||||||
title="Thread cannot be replied to")
|
title="Thread cannot be replied to")
|
||||||
|
if thread.isPinned:
|
||||||
|
italic(class="fas fa-thumbtack fa-xs",
|
||||||
|
title="Pinned post")
|
||||||
if isBanned:
|
if isBanned:
|
||||||
italic(class="fas fa-ban fa-xs",
|
italic(class="fas fa-ban fa-xs",
|
||||||
title="Thread author is banned")
|
title="Thread author is banned")
|
||||||
|
|
@ -114,14 +118,16 @@ when defined(js):
|
||||||
if thread.isSolved:
|
if thread.isSolved:
|
||||||
italic(class="fas fa-check-square fa-xs",
|
italic(class="fas fa-check-square fa-xs",
|
||||||
title="Thread has a solution")
|
title="Thread has a solution")
|
||||||
a(href=makeUri("/t/" & $thread.id),
|
a(href=makeUri("/t/" & $thread.id), onClick=anchorCB):
|
||||||
onClick=anchorCB):
|
|
||||||
text thread.topic
|
text thread.topic
|
||||||
td(class=class({"d-none": not displayCategory})):
|
tdiv(class="show-sm" & class({"d-none": not displayCategory})):
|
||||||
|
render(thread.category)
|
||||||
|
|
||||||
|
td(class="hide-sm" & class({"d-none": not displayCategory})):
|
||||||
render(thread.category)
|
render(thread.category)
|
||||||
genUserAvatars(thread.users)
|
genUserAvatars(thread.users)
|
||||||
td(): text $thread.replies
|
td(class="thread-replies"): text $thread.replies
|
||||||
td(class=class({
|
td(class="hide-sm" & class({
|
||||||
"views-text": thread.views < 999,
|
"views-text": thread.views < 999,
|
||||||
"popular-text": thread.views > 999 and thread.views < 5000,
|
"popular-text": thread.views > 999 and thread.views < 5000,
|
||||||
"super-popular-text": thread.views > 5000
|
"super-popular-text": thread.views > 5000
|
||||||
|
|
@ -200,7 +206,7 @@ when defined(js):
|
||||||
|
|
||||||
return buildHtml(tdiv(class="loading loading-lg"))
|
return buildHtml(tdiv(class="loading loading-lg"))
|
||||||
|
|
||||||
let displayCategory = true
|
let displayCategory = categoryId.isNone
|
||||||
|
|
||||||
let list = state.list.get()
|
let list = state.list.get()
|
||||||
result = buildHtml():
|
result = buildHtml():
|
||||||
|
|
@ -209,12 +215,11 @@ when defined(js):
|
||||||
thead():
|
thead():
|
||||||
tr:
|
tr:
|
||||||
th(text "Topic")
|
th(text "Topic")
|
||||||
th(class=class({"d-none": not displayCategory})):
|
th(class="hide-sm" & class({"d-none": not displayCategory})): text "Category"
|
||||||
text "Category"
|
th(class="thread-users"): text "Users"
|
||||||
th(style=style((StyleAttr.width, kstring"8rem"))): text "Users"
|
th(class="centered-header"): text "Replies"
|
||||||
th(text "Replies")
|
th(class="hide-sm centered-header"): text "Views"
|
||||||
th(text "Views")
|
th(class="centered-header"): text "Activity"
|
||||||
th(text "Activity")
|
|
||||||
tbody():
|
tbody():
|
||||||
for i in 0 ..< list.threads.len:
|
for i in 0 ..< list.threads.len:
|
||||||
let thread = list.threads[i]
|
let thread = list.threads[i]
|
||||||
|
|
@ -222,7 +227,7 @@ when defined(js):
|
||||||
|
|
||||||
let isLastThread = i+1 == list.threads.len
|
let isLastThread = i+1 == list.threads.len
|
||||||
let (isLastUnseen, isNew) = getInfo(list.threads, i, currentUser)
|
let (isLastUnseen, isNew) = getInfo(list.threads, i, currentUser)
|
||||||
genThread(thread, isNew,
|
genThread(i+1, thread, isNew,
|
||||||
noBorder=isLastUnseen or isLastThread,
|
noBorder=isLastUnseen or isLastThread,
|
||||||
displayCategory=displayCategory)
|
displayCategory=displayCategory)
|
||||||
if isLastUnseen and (not isLastThread):
|
if isLastUnseen and (not isLastThread):
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ type
|
||||||
Admin ## Admin: can do everything
|
Admin ## Admin: can do everything
|
||||||
|
|
||||||
User* = object
|
User* = object
|
||||||
|
id*: string
|
||||||
name*: string
|
name*: string
|
||||||
avatarUrl*: string
|
avatarUrl*: string
|
||||||
lastOnline*: int64
|
lastOnline*: int64
|
||||||
|
|
@ -75,4 +76,4 @@ when defined(js):
|
||||||
title="User is a moderator")
|
title="User is a moderator")
|
||||||
of Admin:
|
of Admin:
|
||||||
italic(class="fas fa-chess-knight",
|
italic(class="fas fa-chess-knight",
|
||||||
title="User is an admin")
|
title="User is an admin")
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ SELECT
|
||||||
post_id,
|
post_id,
|
||||||
post_content,
|
post_content,
|
||||||
cdate,
|
cdate,
|
||||||
|
person.id,
|
||||||
person.name AS author,
|
person.name AS author,
|
||||||
person.email AS email,
|
person.email AS email,
|
||||||
strftime('%s', person.lastOnline) AS lastOnline,
|
strftime('%s', person.lastOnline) AS lastOnline,
|
||||||
|
|
@ -46,6 +47,7 @@ SELECT
|
||||||
THEN snippet(post_fts, '**', '**', '...', what, -45)
|
THEN snippet(post_fts, '**', '**', '...', what, -45)
|
||||||
ELSE SUBSTR(post_fts.content, 1, 200) END AS content,
|
ELSE SUBSTR(post_fts.content, 1, 200) END AS content,
|
||||||
cdate,
|
cdate,
|
||||||
|
person.id,
|
||||||
person.name AS author,
|
person.name AS author,
|
||||||
person.email AS email,
|
person.email AS email,
|
||||||
strftime('%s', person.lastOnline) AS lastOnline,
|
strftime('%s', person.lastOnline) AS lastOnline,
|
||||||
|
|
|
||||||
|
|
@ -66,7 +66,7 @@ proc initialiseDb(admin: tuple[username, password, email: string],
|
||||||
|
|
||||||
db.exec(sql"""
|
db.exec(sql"""
|
||||||
insert into category (id, name, description, color)
|
insert into category (id, name, description, color)
|
||||||
values (0, 'Default', 'The default category', '');
|
values (0, 'Unsorted', 'No category has been chosen yet.', '');
|
||||||
""")
|
""")
|
||||||
|
|
||||||
# -- Thread
|
# -- Thread
|
||||||
|
|
@ -81,6 +81,7 @@ proc initialiseDb(admin: tuple[username, password, email: string],
|
||||||
isLocked boolean not null default 0,
|
isLocked boolean not null default 0,
|
||||||
solution integer,
|
solution integer,
|
||||||
isDeleted boolean not null default 0,
|
isDeleted boolean not null default 0,
|
||||||
|
isPinned boolean not null default 0,
|
||||||
|
|
||||||
foreign key (category) references category(id),
|
foreign key (category) references category(id),
|
||||||
foreign key (solution) references post(id)
|
foreign key (solution) references post(id)
|
||||||
|
|
@ -234,7 +235,7 @@ proc initialiseDb(admin: tuple[username, password, email: string],
|
||||||
proc initialiseConfig(
|
proc initialiseConfig(
|
||||||
name, title, hostname: string,
|
name, title, hostname: string,
|
||||||
recaptcha: tuple[siteKey, secretKey: string],
|
recaptcha: tuple[siteKey, secretKey: string],
|
||||||
smtp: tuple[address, user, password, fromAddr: string],
|
smtp: tuple[address, user, password, fromAddr: string, tls: bool],
|
||||||
isDev: bool,
|
isDev: bool,
|
||||||
dbPath: string,
|
dbPath: string,
|
||||||
ga: string=""
|
ga: string=""
|
||||||
|
|
@ -251,6 +252,7 @@ proc initialiseConfig(
|
||||||
"smtpUser": %smtp.user,
|
"smtpUser": %smtp.user,
|
||||||
"smtpPassword": %smtp.password,
|
"smtpPassword": %smtp.password,
|
||||||
"smtpFromAddr": %smtp.fromAddr,
|
"smtpFromAddr": %smtp.fromAddr,
|
||||||
|
"smtpTls": %smtp.tls,
|
||||||
"isDev": %isDev,
|
"isDev": %isDev,
|
||||||
"dbPath": %dbPath
|
"dbPath": %dbPath
|
||||||
}
|
}
|
||||||
|
|
@ -282,7 +284,7 @@ These can be changed later in the generated forum.json file.
|
||||||
|
|
||||||
echo("")
|
echo("")
|
||||||
echo("The following question are related to recaptcha. \nYou must set up a " &
|
echo("The following question are related to recaptcha. \nYou must set up a " &
|
||||||
"recaptcha for your forum before answering them. \nPlease do so now " &
|
"recaptcha v2 for your forum before answering them. \nPlease do so now " &
|
||||||
"and then answer these questions: https://www.google.com/recaptcha/admin")
|
"and then answer these questions: https://www.google.com/recaptcha/admin")
|
||||||
let recaptchaSiteKey = question("Recaptcha site key: ")
|
let recaptchaSiteKey = question("Recaptcha site key: ")
|
||||||
let recaptchaSecretKey = question("Recaptcha secret key: ")
|
let recaptchaSecretKey = question("Recaptcha secret key: ")
|
||||||
|
|
@ -294,6 +296,7 @@ These can be changed later in the generated forum.json file.
|
||||||
let smtpUser = question("SMTP user: ")
|
let smtpUser = question("SMTP user: ")
|
||||||
let smtpPassword = readPasswordFromStdin("SMTP pass: ")
|
let smtpPassword = readPasswordFromStdin("SMTP pass: ")
|
||||||
let smtpFromAddr = question("SMTP sending email address (eg: mail@mail.hostname.com): ")
|
let smtpFromAddr = question("SMTP sending email address (eg: mail@mail.hostname.com): ")
|
||||||
|
let smtpTls = parseBool(question("Enable TLS for SMTP: "))
|
||||||
|
|
||||||
echo("The following is optional. You can specify your Google Analytics ID " &
|
echo("The following is optional. You can specify your Google Analytics ID " &
|
||||||
"if you wish. Otherwise just leave it blank.")
|
"if you wish. Otherwise just leave it blank.")
|
||||||
|
|
@ -303,7 +306,7 @@ These can be changed later in the generated forum.json file.
|
||||||
let dbPath = "nimforum.db"
|
let dbPath = "nimforum.db"
|
||||||
initialiseConfig(
|
initialiseConfig(
|
||||||
name, title, hostname, (recaptchaSiteKey, recaptchaSecretKey),
|
name, title, hostname, (recaptchaSiteKey, recaptchaSecretKey),
|
||||||
(smtpAddress, smtpUser, smtpPassword, smtpFromAddr), isDev=false,
|
(smtpAddress, smtpUser, smtpPassword, smtpFromAddr, smtpTls), isDev=false,
|
||||||
dbPath, ga
|
dbPath, ga
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -338,7 +341,7 @@ when isMainModule:
|
||||||
"Development Forum",
|
"Development Forum",
|
||||||
"localhost",
|
"localhost",
|
||||||
recaptcha=("", ""),
|
recaptcha=("", ""),
|
||||||
smtp=("", "", "", ""),
|
smtp=("", "", "", "", false),
|
||||||
isDev=true,
|
isDev=true,
|
||||||
dbPath
|
dbPath
|
||||||
)
|
)
|
||||||
|
|
@ -355,7 +358,7 @@ when isMainModule:
|
||||||
"Test Forum",
|
"Test Forum",
|
||||||
"localhost",
|
"localhost",
|
||||||
recaptcha=("", ""),
|
recaptcha=("", ""),
|
||||||
smtp=("", "", "", ""),
|
smtp=("", "", "", "", false),
|
||||||
isDev=true,
|
isDev=true,
|
||||||
dbPath
|
dbPath
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
import asyncdispatch, smtp, strutils, json, os, rst, rstgen, xmltree, strtabs,
|
import asyncdispatch, smtp, strutils, json, os, rst, rstgen, xmltree, strtabs,
|
||||||
htmlparser, streams, parseutils, options, logging
|
htmlparser, streams, parseutils, options, logging
|
||||||
from times import getTime, getGMTime, format
|
from times import getTime, utc, format
|
||||||
|
|
||||||
# Used to be:
|
# Used to be:
|
||||||
# {'A'..'Z', 'a'..'z', '0'..'9', '_', '\128'..'\255'}
|
# {'A'..'Z', 'a'..'z', '0'..'9', '_', '\128'..'\255'}
|
||||||
let
|
let
|
||||||
UsernameIdent* = IdentChars # TODO: Double check that everyone follows this.
|
UsernameIdent* = IdentChars + {'-'} # TODO: Double check that everyone follows this.
|
||||||
|
|
||||||
import frontend/[karaxutils, error]
|
import frontend/[karaxutils, error]
|
||||||
export parseInt
|
export parseInt
|
||||||
|
|
@ -17,6 +17,8 @@ type
|
||||||
smtpUser*: string
|
smtpUser*: string
|
||||||
smtpPassword*: string
|
smtpPassword*: string
|
||||||
smtpFromAddr*: string
|
smtpFromAddr*: string
|
||||||
|
smtpTls*: bool
|
||||||
|
smtpSsl*: bool
|
||||||
mlistAddress*: string
|
mlistAddress*: string
|
||||||
recaptchaSecretKey*: string
|
recaptchaSecretKey*: string
|
||||||
recaptchaSiteKey*: string
|
recaptchaSiteKey*: string
|
||||||
|
|
@ -55,6 +57,8 @@ proc loadConfig*(filename = getCurrentDir() / "forum.json"): Config =
|
||||||
result.smtpUser = root{"smtpUser"}.getStr("")
|
result.smtpUser = root{"smtpUser"}.getStr("")
|
||||||
result.smtpPassword = root{"smtpPassword"}.getStr("")
|
result.smtpPassword = root{"smtpPassword"}.getStr("")
|
||||||
result.smtpFromAddr = root{"smtpFromAddr"}.getStr("")
|
result.smtpFromAddr = root{"smtpFromAddr"}.getStr("")
|
||||||
|
result.smtpTls = root{"smtpTls"}.getBool(false)
|
||||||
|
result.smtpSsl = root{"smtpSsl"}.getBool(false)
|
||||||
result.mlistAddress = root{"mlistAddress"}.getStr("")
|
result.mlistAddress = root{"mlistAddress"}.getStr("")
|
||||||
result.recaptchaSecretKey = root{"recaptchaSecretKey"}.getStr("")
|
result.recaptchaSecretKey = root{"recaptchaSecretKey"}.getStr("")
|
||||||
result.recaptchaSiteKey = root{"recaptchaSiteKey"}.getStr("")
|
result.recaptchaSiteKey = root{"recaptchaSiteKey"}.getStr("")
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,29 @@ proc categoriesUserTests(session: Session, baseUrl: string) =
|
||||||
|
|
||||||
ensureExists title, LinkTextSelector
|
ensureExists title, LinkTextSelector
|
||||||
|
|
||||||
|
test "can create category thread and change category":
|
||||||
|
with session:
|
||||||
|
let newTitle = title & " Selection"
|
||||||
|
click "#new-thread-btn"
|
||||||
|
sendKeys "#thread-title", newTitle
|
||||||
|
|
||||||
|
selectCategory "fun"
|
||||||
|
sendKeys "#reply-textarea", content
|
||||||
|
|
||||||
|
click "#create-thread-btn"
|
||||||
|
checkText "#thread-title .category", "Fun"
|
||||||
|
|
||||||
|
selectCategory "announcements"
|
||||||
|
|
||||||
|
checkText "#thread-title .category", "Announcements"
|
||||||
|
|
||||||
|
# Make sure there is no error
|
||||||
|
checkIsNone "#thread-title .text-error"
|
||||||
|
|
||||||
|
navigate baseUrl
|
||||||
|
|
||||||
|
ensureExists newTitle, LinkTextSelector
|
||||||
|
|
||||||
test "can navigate to categories page":
|
test "can navigate to categories page":
|
||||||
with session:
|
with session:
|
||||||
click "#categories-btn"
|
click "#categories-btn"
|
||||||
|
|
@ -102,7 +125,7 @@ proc categoriesUserTests(session: Session, baseUrl: string) =
|
||||||
click "#new-thread-btn"
|
click "#new-thread-btn"
|
||||||
sendKeys "#thread-title", "Post 3"
|
sendKeys "#thread-title", "Post 3"
|
||||||
|
|
||||||
selectCategory "default"
|
selectCategory "unsorted"
|
||||||
sendKeys "#reply-textarea", "Post 3"
|
sendKeys "#reply-textarea", "Post 3"
|
||||||
|
|
||||||
click "#create-thread-btn"
|
click "#create-thread-btn"
|
||||||
|
|
@ -112,19 +135,19 @@ proc categoriesUserTests(session: Session, baseUrl: string) =
|
||||||
click "#categories-btn"
|
click "#categories-btn"
|
||||||
ensureExists "#categories-list"
|
ensureExists "#categories-list"
|
||||||
|
|
||||||
click "#category-default"
|
click "#category-unsorted"
|
||||||
checkText "#threads-list .thread-title", "Post 3"
|
checkText "#threads-list .thread-title a", "Post 3"
|
||||||
for element in session.waitForElements("#threads-list .category-name"):
|
for element in session.waitForElements("#threads-list .category-name"):
|
||||||
# Have to user "innerText" because elements are hidden on this page
|
# Have to user "innerText" because elements are hidden on this page
|
||||||
assert element.getProperty("innerText") == "Default"
|
assert element.getProperty("innerText") == "Unsorted"
|
||||||
|
|
||||||
selectCategory "announcements"
|
selectCategory "announcements"
|
||||||
checkText "#threads-list .thread-title", "Post 2"
|
checkText "#threads-list .thread-title a", "Post 2"
|
||||||
for element in session.waitForElements("#threads-list .category-name"):
|
for element in session.waitForElements("#threads-list .category-name"):
|
||||||
assert element.getProperty("innerText") == "Announcements"
|
assert element.getProperty("innerText") == "Announcements"
|
||||||
|
|
||||||
selectCategory "fun"
|
selectCategory "fun"
|
||||||
checkText "#threads-list .thread-title", "Post 1"
|
checkText "#threads-list .thread-title a", "Post 1"
|
||||||
for element in session.waitForElements("#threads-list .category-name"):
|
for element in session.waitForElements("#threads-list .category-name"):
|
||||||
assert element.getProperty("innerText") == "Fun"
|
assert element.getProperty("innerText") == "Fun"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,8 @@ proc elementIsSome(element: Option[Element]): bool =
|
||||||
proc elementIsNone(element: Option[Element]): bool =
|
proc elementIsNone(element: Option[Element]): bool =
|
||||||
return element.isNone
|
return element.isNone
|
||||||
|
|
||||||
proc waitForElement*(session: Session, selector: string, strategy=CssSelector, timeout=20000, pollTime=50, waitCondition=elementIsSome): Option[Element]
|
proc waitForElement*(session: Session, selector: string, strategy=CssSelector, timeout=20000, pollTime=50,
|
||||||
|
waitCondition: proc(element: Option[Element]): bool = elementIsSome): Option[Element]
|
||||||
|
|
||||||
proc click*(session: Session, element: string, strategy=CssSelector) =
|
proc click*(session: Session, element: string, strategy=CssSelector) =
|
||||||
let el = session.waitForElement(element, strategy)
|
let el = session.waitForElement(element, strategy)
|
||||||
|
|
@ -71,14 +72,14 @@ proc setColor*(session: Session, element, color: string, strategy=CssSelector) =
|
||||||
proc checkIsNone*(session: Session, element: string, strategy=CssSelector) =
|
proc checkIsNone*(session: Session, element: string, strategy=CssSelector) =
|
||||||
discard session.waitForElement(element, strategy, waitCondition=elementIsNone)
|
discard session.waitForElement(element, strategy, waitCondition=elementIsNone)
|
||||||
|
|
||||||
proc checkText*(session: Session, element, expectedValue: string) =
|
template checkText*(session: Session, element, expectedValue: string) =
|
||||||
let el = session.waitForElement(element)
|
let el = session.waitForElement(element)
|
||||||
check el.get().getText() == expectedValue
|
check el.get().getText() == expectedValue
|
||||||
|
|
||||||
proc waitForElement*(
|
proc waitForElement*(
|
||||||
session: Session, selector: string, strategy=CssSelector,
|
session: Session, selector: string, strategy=CssSelector,
|
||||||
timeout=20000, pollTime=50,
|
timeout=20000, pollTime=50,
|
||||||
waitCondition=elementIsSome
|
waitCondition: proc(element: Option[Element]): bool = elementIsSome
|
||||||
): Option[Element] =
|
): Option[Element] =
|
||||||
var waitTime = 0
|
var waitTime = 0
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -40,4 +40,4 @@ proc test*(session: Session, baseUrl: string) =
|
||||||
register "TEst1", "test1", verify = false
|
register "TEst1", "test1", verify = false
|
||||||
|
|
||||||
ensureExists "#signup-form .has-error"
|
ensureExists "#signup-form .has-error"
|
||||||
navigate baseUrl
|
navigate baseUrl
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import unittest, common
|
import unittest, common
|
||||||
|
|
||||||
import webdriver
|
import webdriver
|
||||||
|
|
||||||
let
|
let
|
||||||
|
|
@ -40,10 +39,53 @@ proc userTests(session: Session, baseUrl: string) =
|
||||||
checkText "#thread-title .title-text", userTitleStr
|
checkText "#thread-title .title-text", userTitleStr
|
||||||
checkText ".original-post div.post-content", userContentStr
|
checkText ".original-post div.post-content", userContentStr
|
||||||
|
|
||||||
|
test "can delete thread":
|
||||||
|
with session:
|
||||||
|
# create thread to be deleted
|
||||||
|
click "#new-thread-btn"
|
||||||
|
|
||||||
|
sendKeys "#thread-title", "To be deleted"
|
||||||
|
sendKeys "#reply-textarea", "This thread is to be deleted"
|
||||||
|
|
||||||
|
click "#create-thread-btn"
|
||||||
|
|
||||||
|
click ".post-buttons .delete-button"
|
||||||
|
|
||||||
|
# click delete confirmation
|
||||||
|
click "#delete-modal .delete-btn"
|
||||||
|
|
||||||
|
# Make sure the forum post is gone
|
||||||
|
checkIsNone "To be deleted", LinkTextSelector
|
||||||
|
|
||||||
|
test "cannot (un)pin thread":
|
||||||
|
with session:
|
||||||
|
navigate(baseUrl)
|
||||||
|
|
||||||
|
click "#new-thread-btn"
|
||||||
|
|
||||||
|
sendKeys "#thread-title", "Unpinnable"
|
||||||
|
sendKeys "#reply-textarea", "Cannot (un)pin as an user"
|
||||||
|
|
||||||
|
click "#create-thread-btn"
|
||||||
|
|
||||||
|
checkIsNone "#pin-btn"
|
||||||
|
|
||||||
|
test "cannot lock threads":
|
||||||
|
with session:
|
||||||
|
navigate(baseUrl)
|
||||||
|
|
||||||
|
click "#new-thread-btn"
|
||||||
|
|
||||||
|
sendKeys "#thread-title", "Locking"
|
||||||
|
sendkeys "#reply-textarea", "Cannot lock as an user"
|
||||||
|
|
||||||
|
click "#create-thread-btn"
|
||||||
|
|
||||||
|
checkIsNone "#lock-btn"
|
||||||
|
|
||||||
session.logout()
|
session.logout()
|
||||||
|
|
||||||
proc anonymousTests(session: Session, baseUrl: string) =
|
proc anonymousTests(session: Session, baseUrl: string) =
|
||||||
|
|
||||||
suite "anonymous user tests":
|
suite "anonymous user tests":
|
||||||
with session:
|
with session:
|
||||||
navigate baseUrl
|
navigate baseUrl
|
||||||
|
|
@ -143,6 +185,70 @@ proc adminTests(session: Session, baseUrl: string) =
|
||||||
# Make sure the forum post is gone
|
# Make sure the forum post is gone
|
||||||
checkIsNone adminTitleStr, LinkTextSelector
|
checkIsNone adminTitleStr, LinkTextSelector
|
||||||
|
|
||||||
|
test "can pin a thread":
|
||||||
|
with session:
|
||||||
|
click "#new-thread-btn"
|
||||||
|
sendKeys "#thread-title", "Pinned post"
|
||||||
|
sendKeys "#reply-textarea", "A pinned post"
|
||||||
|
click "#create-thread-btn"
|
||||||
|
|
||||||
|
navigate(baseUrl)
|
||||||
|
click "#new-thread-btn"
|
||||||
|
sendKeys "#thread-title", "Normal post"
|
||||||
|
sendKeys "#reply-textarea", "A normal post"
|
||||||
|
click "#create-thread-btn"
|
||||||
|
|
||||||
|
navigate(baseUrl)
|
||||||
|
click "Pinned post", LinkTextSelector
|
||||||
|
click "#pin-btn"
|
||||||
|
checkText "#pin-btn", "Unpin Thread"
|
||||||
|
|
||||||
|
navigate(baseUrl)
|
||||||
|
|
||||||
|
# Make sure pin exists
|
||||||
|
ensureExists "#threads-list .thread-1 .thread-title i"
|
||||||
|
|
||||||
|
checkText "#threads-list .thread-1 .thread-title a", "Pinned post"
|
||||||
|
checkText "#threads-list .thread-2 .thread-title a", "Normal post"
|
||||||
|
|
||||||
|
test "can unpin a thread":
|
||||||
|
with session:
|
||||||
|
click "Pinned post", LinkTextSelector
|
||||||
|
click "#pin-btn"
|
||||||
|
checkText "#pin-btn", "Pin Thread"
|
||||||
|
|
||||||
|
navigate(baseUrl)
|
||||||
|
|
||||||
|
checkIsNone "#threads-list .thread-2 .thread-title i"
|
||||||
|
|
||||||
|
checkText "#threads-list .thread-1 .thread-title a", "Normal post"
|
||||||
|
checkText "#threads-list .thread-2 .thread-title a", "Pinned post"
|
||||||
|
|
||||||
|
test "can lock a thread":
|
||||||
|
with session:
|
||||||
|
click "Locking", LinkTextSelector
|
||||||
|
click "#lock-btn"
|
||||||
|
|
||||||
|
ensureExists "#thread-title i.fas.fa-lock.fa-xs"
|
||||||
|
|
||||||
|
test "locked thread appears on frontpage":
|
||||||
|
with session:
|
||||||
|
click "#new-thread-btn"
|
||||||
|
sendKeys "#thread-title", "A new locked thread"
|
||||||
|
sendKeys "#reply-textarea", "This thread should appear locked on the frontpage"
|
||||||
|
click "#create-thread-btn"
|
||||||
|
click "#lock-btn"
|
||||||
|
|
||||||
|
navigate(baseUrl)
|
||||||
|
ensureExists "#threads-list .thread-1 .thread-title i.fas.fa-lock.fa-xs"
|
||||||
|
|
||||||
|
test "can unlock a thread":
|
||||||
|
with session:
|
||||||
|
click "Locking", LinkTextSelector
|
||||||
|
click "#lock-btn"
|
||||||
|
|
||||||
|
checkIsNone "#thread-title i.fas.fa-lock.fa-xs"
|
||||||
|
|
||||||
session.logout()
|
session.logout()
|
||||||
|
|
||||||
proc test*(session: Session, baseUrl: string) =
|
proc test*(session: Session, baseUrl: string) =
|
||||||
|
|
@ -158,4 +264,4 @@ proc test*(session: Session, baseUrl: string) =
|
||||||
|
|
||||||
unBanUser(session, baseUrl)
|
unBanUser(session, baseUrl)
|
||||||
|
|
||||||
session.navigate(baseUrl)
|
session.navigate(baseUrl)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue