Compare commits

...
Sign in to create a new pull request.

65 commits

Author SHA1 Message Date
Joey
88c2011323 Update submodules 2022-01-02 22:10:15 -07:00
Joey
4a181e9cb1 Install libsass with apt 2022-01-02 22:10:15 -07:00
Joey
4f00ea5942 Add mkdir 2022-01-02 22:10:15 -07:00
Joey
9994fc7840 test_devel -> test_stable 2022-01-02 22:10:15 -07:00
Joey
ceb04561cd Create main github actions file 2022-01-02 22:10:11 -07:00
Danil Yarantsev
40d7b1cf02
Fixes a few crashes and search functionality. (#307)
* Fixes a few crashes and search functionality.

* Use PostError
2021-11-21 23:40:04 +00:00
Dominik Picheta
c4684155f5 Run CI on all branches and every week. 2021-11-11 22:32:42 +00:00
Dominik Picheta
f940f81861 Lock CI Nim ver and update to Nim 1.6.0. 2021-11-11 22:32:42 +00:00
Juan Carlos
a1601b4600
Use input type search on search instead of text (#291) 2021-05-17 00:04:01 +01:00
zetashift
35e0de7b91
Tests for locking threads (#284)
* Initial try at locking threads tests

* Uncomment tests

* Consist casing

* Add correct query

* Remove redundant navigate call and add frontpage check

* Improve locked thread on frontpage test
2021-04-27 10:23:48 -06:00
zetashift
7954a38601
Pinned Threads (#278)
* Added isSticky field to `Thread` and in the sql query making a Thread

 - Modified indices in `data` and `selectUser` to support `isSticky`
 - Add backend procs for initial sticky logic, modeled after locking threads
 - Fix indices in selectThread
 - Fixup posts.json's threadquery to match Thread with sticky field

* Implement StickyButton for postbutton.nim and add it to postlist.nim

* Fix sticky routes

* Order sticky in a way that they actually appear at the top

* Add border for isSticky on genThread

* Rename stickies to pinned, so professional!

* Add pinned tests

 - Add an id to pin button, and add first attempt at useful tests
 - Improve pin tests, refactored it into adminTests and userTests
 - Add an id to pin button, and add first attempt at useful tests
 - Improve pin tests, refactored it into adminTests and userTests

* Make tests more reliable

Co-authored-by: Joey Yakimowich-Payne <jyapayne@gmail.com>
2021-04-25 18:39:03 -06:00
Joey
48c025ae78
Merge pull request #281 from nim-lang/github_actions
Add github actions
2021-04-24 16:23:05 -06:00
Joey Yakimowich-Payne
8782dff349 Use matrix nim version 2021-04-24 16:19:39 -06:00
Joey Yakimowich-Payne
0055a12fc1 Use choosenim instead 2021-04-24 16:17:17 -06:00
Joey Yakimowich-Payne
8cd5c45cda Remove travis 2021-04-22 16:42:32 -06:00
Joey
5b7b271627
Add github actions 2021-04-22 16:39:25 -06:00
j-james
6c6552176a Support dashes in usernames 2021-01-10 23:07:20 +00:00
digitalcraftsman
3ac9ec3ff6 Center view count of popular threads
Otherwise the counter is misaligned in the corresponding column.
2020-09-22 18:44:42 +01:00
Dominik Picheta
7d8417ff97 Revert "Replace webdriver with halonium" 2020-09-01 18:27:58 +01:00
Joey
b2932e9348
Merge pull request #263 from jyapayne/change-to-halonium
Replace webdriver with halonium
2020-09-01 07:14:00 -06:00
Joey
108aeda5b3
Merge pull request #262 from jyapayne/fix-cannot-edit-category
Fix cannot edit category
2020-09-01 06:04:18 -06:00
Joey Yakimowich-Payne
3d975e8386 Replace webdriver with halonium 2020-08-31 15:43:23 -06:00
Joey Yakimowich-Payne
4f8a585049 Remove unnecessary table identifier 2020-08-31 15:35:17 -06:00
narimiran
b27096ff75 allowed editing time is now much shorter
Other forums usually have allowed editing times measured in
*minutes*, we had it in weeks.

Two hours should be plenty of time to edit a post, but more importantly
it should prevent spamming mis-usages that sometimes happened before:
You read a perfectly normal post (usually copy-pasted from somewhere)
and then much later on (when most of us regular forum users don't notice
anymore because we frequently read new threads/posts) it is edited to
contain spammy links and content.

Admins must be able to always edit a post, no matter of its age.
2020-08-31 22:02:03 +01:00
Joey Yakimowich-Payne
ce3de27fb9 Fix user row index 2020-08-29 12:48:57 -06:00
Joey Yakimowich-Payne
9739c34abd Add test for category change 2020-08-29 11:49:00 -06:00
Joey Yakimowich-Payne
4821746c5d Add user id to the user object and fix thread user check 2020-08-29 11:31:24 -06:00
Miran
dc80ef022e
properly do #255 - moderators can change categories (#258) 2020-08-28 11:10:17 +02:00
Dominik Picheta
77fd9af1cd Version 2.1.0. 2020-08-24 21:02:44 +01:00
Dominik Picheta
e62ae672b3 Optimise threads.json SQL query. 2020-08-24 20:47:01 +01:00
Miran
6e32ec27b4
moderators should be able to edit categories (#255) 2020-08-24 13:48:23 +02:00
jiro
030c52020e
Add docker environment for local development (#257)
* Add docker environment

* Add document of docker

* Move docker files

* Change context

* Move git submodule command to entrypoint.sh

* Update README
2020-08-23 14:30:06 +01:00
Joey
abb770ebd1
Merge pull request #251 from andinus/update-readme-setup-info
Add additional setup information to README
2020-08-06 06:11:45 -06:00
Miran
16c9daea52
fix deprecated import (#254) 2020-08-06 14:07:31 +02:00
Andinus
8bad518e4b
Move setup fail information to Troubleshooting section
This also pastes the output instead of linking to pastebin.
2020-06-13 00:55:32 +05:30
Andinus
5c4d9b36d3
Add additional setup information to README
The build failed for me & was fixed after installing "karax" with
nimble.
2020-06-13 00:06:38 +05:30
Joey
14786d3d49
Merge pull request #250 from andinus/fix-email-change-update
Send confirmation email to updated address
2020-06-12 12:02:52 -06:00
Joey
48314432e9
Merge pull request #249 from hvnsweeting/only-v2-works
Gives note that only reCAPTCHA v2 works at the moment
2020-06-12 11:18:48 -06:00
Joey
d808749054
Merge pull request #248 from hvnsweeting/ssl-support
Add SSL support for sending email
2020-06-12 11:18:18 -06:00
Joey
03fd4fdc13
Merge pull request #246 from andinus/fix-email-date-header
Add Date header to emails
2020-06-12 11:17:51 -06:00
Andinus
ebbfa265d5
Send confirmation email to updated address
This will fix https://github.com/nim-lang/nimforum/issues/155.

Currently nimforum sends the confirmation email to the address in
database but it should've sent it to the new address.

Activity: User changes email
Issue: Confirmation email is sent to old address
Fix: Send the confirmation email to updated address
2020-06-12 22:21:45 +05:30
Viet Hung Nguyen
b933a9b2e8 Gives note that only v2 work at the moment 2020-06-06 21:36:14 +07:00
Viet Hung Nguyen
474fa63985 Add SSL support for sending email 2020-06-06 14:52:35 +07:00
Andinus
418bb3fe47
Add Date header to emails
UTC time is used because we cannot format time as "Fri, 22 May 2020
06:33:00 +0000" with the times package.

"zz" returns +00 & "zzz" returns +00:00, note that the former doesn't
return minutes value so it'll return +05 for systems in timezone
+0530 & "zzz" will return +05:30 for the same.

Instead of parsing it again & removing ':' manually we use UTC time &
add "+0000".
2020-05-22 12:12:11 +05:30
Joey
d372d9f980
Merge pull request #240 from hlaaftana/patch-1
Let users delete their own posts, fixes #208
2020-05-10 10:38:32 -06:00
hlaaftana
2987955e8a
fix delete thread test 2020-05-10 19:15:02 +03:00
hlaaftana
16abee0596
add user delete thread test 2020-05-10 17:54:32 +03:00
hlaaftana
f5e1a71e6e
add clear variables 2020-05-10 17:00:39 +03:00
Joey
f0bfd5516a
Merge pull request #244 from Yardanico/emailtls
Add ability to enable TLS for SMTP
2020-05-10 06:58:18 -06:00
Danil Yarantsev
fcbf22a64a Merge branch 'master' of github.com:nim-lang/nimforum into emailtls 2020-05-10 15:52:13 +03:00
Joey
53ad811bf8
Merge pull request #245 from Yardanico/updkarax
Update Karax and Jester dependencies
2020-05-10 06:31:13 -06:00
Danil Yarantsev
55c9476810 Karax to 1.1.2 2020-05-10 13:11:46 +03:00
Danil Yarantsev
e749512818 Update Karax and Jester dependencies 2020-05-10 13:09:12 +03:00
Danil Yarantsev
a3052efd78 Add smptTls to config loading 2020-05-10 12:59:31 +03:00
Danil Yarantsev
6c6ed08ec9 startTls should be after connection of course 2020-05-10 12:56:13 +03:00
Danil Yarantsev
3b6e7363a9 Add ability to enable TLS for SMTP 2020-05-10 12:52:14 +03:00
hlaaftana
cd565eabe0
Change to row 2020-04-17 14:40:18 +03:00
hlaaftana
f0d9a89167
Fix query for executeDeletePost 2020-04-17 14:30:58 +03:00
hlaaftana
d8661f62c7
Let users delete their own posts, fixes #208 2020-04-15 10:29:42 +03:00
Joey Yakimowich-Payne
8984074809 Fix tests 2020-03-26 22:58:48 +00:00
Joey Yakimowich-Payne
3adba32f1f Left align users 2020-03-26 22:58:48 +00:00
Joey Yakimowich-Payne
f35d6c4a32 Rename default category and change color to grey 2020-03-26 22:58:48 +00:00
Joey Yakimowich-Payne
3db01a1d44 Show category column if not on mobile 2020-03-07 01:21:15 +01:00
Joey Yakimowich-Payne
dcea4091f4 Fix tests 2020-03-06 11:27:41 +01:00
Joey Yakimowich-Payne
c2cc26ea77 Make mobile viewing more friendly 2020-03-06 11:27:41 +01:00
24 changed files with 533 additions and 143 deletions

80
.github/workflows/main.yml vendored Normal file
View 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

View file

@ -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

View file

@ -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
View 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
View 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
View 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

View file

@ -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"

View file

@ -22,7 +22,7 @@ table th {
// Custom styles. // Custom styles.
// - Navigation bar. // - Navigation bar.
$navbar-height: 60px; $navbar-height: 60px;
$default-category-color: #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;

View file

@ -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)

View file

@ -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]),
) )
) )

View file

@ -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

View file

@ -96,8 +96,8 @@ when defined(js):
section(class="navbar-section"): section(class="navbar-section"):
tdiv(class="input-group input-inline"): tdiv(class="input-group input-inline"):
input(class="search-input input-sm", input(class="search-input input-sm",
`type`="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")

View file

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

View file

@ -190,7 +190,7 @@ when defined(js):
else: "" else: ""
result = buildHtml(): result = buildHtml():
button(class="btn btn-secondary", 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"

View file

@ -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)

View file

@ -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):

View file

@ -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")

View file

@ -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,

View file

@ -66,7 +66,7 @@ proc initialiseDb(admin: tuple[username, password, email: string],
db.exec(sql""" db.exec(sql"""
insert into category (id, name, description, color) insert into category (id, name, description, color)
values (0, '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
) )

View file

@ -1,11 +1,11 @@
import asyncdispatch, smtp, strutils, json, os, rst, rstgen, xmltree, strtabs, import asyncdispatch, smtp, strutils, json, os, rst, rstgen, xmltree, strtabs,
htmlparser, streams, parseutils, options, logging htmlparser, streams, parseutils, options, logging
from times import getTime, 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("")

View file

@ -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"

View file

@ -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

View file

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

View file

@ -1,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)