Compare commits

..

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
23 changed files with 514 additions and 129 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
```
Development typically involves running `nimble devdb` which sets up the
database for development and testing, then `nimble backend`
which compiles and runs the forum's backend, and `nimble frontend`
@ -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
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

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
version = "2.0.2"
version = "2.1.0"
author = "Dominik Picheta"
description = "The Nim forum"
license = "MIT"
@ -13,13 +13,13 @@ skipExt = @["nim"]
# Dependencies
requires "nim >= 1.0.6"
requires "jester#d8a03aa"
requires "bcrypt#head"
requires "jester#405be2e"
requires "bcrypt#440c5676ff6"
requires "hmac#9c61ebe2fd134cf97"
requires "recaptcha#d06488e"
requires "sass#649e0701fa5c"
requires "karax#f6bda9a"
requires "karax#5f21dcd"
requires "webdriver#429933a"

View file

@ -22,7 +22,7 @@ table th {
// Custom styles.
// - Navigation bar.
$navbar-height: 60px;
$default-category-color: #98c766;
$default-category-color: #a3a3a3;
$logo-height: $navbar-height - 20px;
.navbar-button {
@ -301,10 +301,14 @@ $threads-meta-color: #545d70;
}
}
.thread-replies, .thread-time, .thread-users, .views-text, .centered-header {
.thread-replies, .thread-time, .views-text, .popular-text, .centered-header {
text-align: center;
}
.thread-users {
text-align: left;
}
.thread-time {
color: $threads-meta-color;

View file

@ -44,8 +44,18 @@ proc sendMail(
warn("Cannot send mail: no smtp from address configured (smtpFromAddr).")
return
var client = newAsyncSmtp()
await client.connect(mailer.config.smtpAddress, Port(mailer.config.smtpPort))
var client: AsyncSmtp
if mailer.config.smtpTls:
client = newAsyncSmtp(useSsl=false)
await client.connect(mailer.config.smtpAddress, Port(mailer.config.smtpPort))
await client.startTls()
elif mailer.config.smtpSsl:
client = newAsyncSmtp(useSsl=true)
await client.connect(mailer.config.smtpAddress, Port(mailer.config.smtpPort))
else:
client = newAsyncSmtp(useSsl=false)
await client.connect(mailer.config.smtpAddress, Port(mailer.config.smtpPort))
if mailer.config.smtpUser.len > 0:
await client.auth(mailer.config.smtpUser, mailer.config.smtpPassword)
@ -54,6 +64,9 @@ proc sendMail(
var headers = otherHeaders
headers.add(("From", mailer.config.smtpFromAddr))
let dateHeader = now().utc().format("ddd, dd MMM yyyy hh:mm:ss") & " +0000"
headers.add(("Date", dateHeader))
let encoded = createMessage(subject, message,
toList, @[], headers)

View file

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

View file

@ -96,8 +96,8 @@ when defined(js):
section(class="navbar-section"):
tdiv(class="input-group input-inline"):
input(class="search-input input-sm",
`type`="text", placeholder="search",
id="search-box",
`type`="search", placeholder="Search",
id="search-box", required="required",
onKeyDown=onKeyDown)
if state.loading:
tdiv(class="loading")

View file

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

View file

@ -190,7 +190,7 @@ when defined(js):
else: ""
result = buildHtml():
button(class="btn btn-secondary",
button(class="btn btn-secondary", id="lock-btn",
onClick=(e: Event, n: VNode) =>
onLockClick(e, n, state, thread),
"data-tooltip"=tooltip,
@ -201,4 +201,61 @@ when defined(js):
text " Unlock Thread"
else:
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
deleteModal: DeleteModal
lockButton: LockButton
pinButton: PinButton
categoryPicker: CategoryPicker
proc onReplyPosted(id: int)
@ -56,6 +57,7 @@ when defined(js):
likeButton: newLikeButton(),
deleteModal: newDeleteModal(onDeletePost, onDeleteThread, nil),
lockButton: newLockButton(),
pinButton: newPinButton(),
categoryPicker: newCategoryPicker(onCategoryChanged)
)
@ -209,12 +211,12 @@ when defined(js):
let loggedIn = currentUser.isSome()
let authoredByUser =
loggedIn and currentUser.get().name == thread.author.name
let currentAdmin =
currentUser.isSome() and currentUser.get().rank == Admin
let canChangeCategory =
loggedIn and currentUser.get().rank in {Admin, Moderator}
result = buildHtml():
tdiv():
if authoredByUser or currentAdmin:
if authoredByUser or canChangeCategory:
render(state.categoryPicker, currentUser, compact=false)
else:
render(thread.category)
@ -411,6 +413,7 @@ when defined(js):
text " Reply"
render(state.lockButton, list.thread, currentUser)
render(state.pinButton, list.thread, currentUser)
render(state.replyBox, list.thread, state.replyingTo, false)

View file

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

View file

@ -17,6 +17,7 @@ type
Admin ## Admin: can do everything
User* = object
id*: string
name*: string
avatarUrl*: string
lastOnline*: int64
@ -75,4 +76,4 @@ when defined(js):
title="User is a moderator")
of Admin:
italic(class="fas fa-chess-knight",
title="User is an admin")
title="User is an admin")

View file

@ -7,6 +7,7 @@ SELECT
post_id,
post_content,
cdate,
person.id,
person.name AS author,
person.email AS email,
strftime('%s', person.lastOnline) AS lastOnline,
@ -46,6 +47,7 @@ SELECT
THEN snippet(post_fts, '**', '**', '...', what, -45)
ELSE SUBSTR(post_fts.content, 1, 200) END AS content,
cdate,
person.id,
person.name AS author,
person.email AS email,
strftime('%s', person.lastOnline) AS lastOnline,

View file

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

View file

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

View file

@ -69,6 +69,29 @@ proc categoriesUserTests(session: Session, baseUrl: string) =
ensureExists title, LinkTextSelector
test "can create category thread and change category":
with session:
let newTitle = title & " Selection"
click "#new-thread-btn"
sendKeys "#thread-title", newTitle
selectCategory "fun"
sendKeys "#reply-textarea", content
click "#create-thread-btn"
checkText "#thread-title .category", "Fun"
selectCategory "announcements"
checkText "#thread-title .category", "Announcements"
# Make sure there is no error
checkIsNone "#thread-title .text-error"
navigate baseUrl
ensureExists newTitle, LinkTextSelector
test "can navigate to categories page":
with session:
click "#categories-btn"
@ -102,7 +125,7 @@ proc categoriesUserTests(session: Session, baseUrl: string) =
click "#new-thread-btn"
sendKeys "#thread-title", "Post 3"
selectCategory "default"
selectCategory "unsorted"
sendKeys "#reply-textarea", "Post 3"
click "#create-thread-btn"
@ -112,11 +135,11 @@ proc categoriesUserTests(session: Session, baseUrl: string) =
click "#categories-btn"
ensureExists "#categories-list"
click "#category-default"
click "#category-unsorted"
checkText "#threads-list .thread-title a", "Post 3"
for element in session.waitForElements("#threads-list .category-name"):
# Have to user "innerText" because elements are hidden on this page
assert element.getProperty("innerText") == "Default"
assert element.getProperty("innerText") == "Unsorted"
selectCategory "announcements"
checkText "#threads-list .thread-title a", "Post 2"

View file

@ -30,7 +30,8 @@ proc elementIsSome(element: Option[Element]): bool =
proc elementIsNone(element: Option[Element]): bool =
return element.isNone
proc waitForElement*(session: Session, selector: string, strategy=CssSelector, timeout=20000, pollTime=50, waitCondition=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) =
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) =
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)
check el.get().getText() == expectedValue
proc waitForElement*(
session: Session, selector: string, strategy=CssSelector,
timeout=20000, pollTime=50,
waitCondition=elementIsSome
waitCondition: proc(element: Option[Element]): bool = elementIsSome
): Option[Element] =
var waitTime = 0

View file

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

View file

@ -1,5 +1,4 @@
import unittest, common
import webdriver
let
@ -40,10 +39,53 @@ proc userTests(session: Session, baseUrl: string) =
checkText "#thread-title .title-text", userTitleStr
checkText ".original-post div.post-content", userContentStr
test "can delete thread":
with session:
# create thread to be deleted
click "#new-thread-btn"
sendKeys "#thread-title", "To be deleted"
sendKeys "#reply-textarea", "This thread is to be deleted"
click "#create-thread-btn"
click ".post-buttons .delete-button"
# click delete confirmation
click "#delete-modal .delete-btn"
# Make sure the forum post is gone
checkIsNone "To be deleted", LinkTextSelector
test "cannot (un)pin thread":
with session:
navigate(baseUrl)
click "#new-thread-btn"
sendKeys "#thread-title", "Unpinnable"
sendKeys "#reply-textarea", "Cannot (un)pin as an user"
click "#create-thread-btn"
checkIsNone "#pin-btn"
test "cannot lock threads":
with session:
navigate(baseUrl)
click "#new-thread-btn"
sendKeys "#thread-title", "Locking"
sendkeys "#reply-textarea", "Cannot lock as an user"
click "#create-thread-btn"
checkIsNone "#lock-btn"
session.logout()
proc anonymousTests(session: Session, baseUrl: string) =
suite "anonymous user tests":
with session:
navigate baseUrl
@ -143,6 +185,70 @@ proc adminTests(session: Session, baseUrl: string) =
# Make sure the forum post is gone
checkIsNone adminTitleStr, LinkTextSelector
test "can pin a thread":
with session:
click "#new-thread-btn"
sendKeys "#thread-title", "Pinned post"
sendKeys "#reply-textarea", "A pinned post"
click "#create-thread-btn"
navigate(baseUrl)
click "#new-thread-btn"
sendKeys "#thread-title", "Normal post"
sendKeys "#reply-textarea", "A normal post"
click "#create-thread-btn"
navigate(baseUrl)
click "Pinned post", LinkTextSelector
click "#pin-btn"
checkText "#pin-btn", "Unpin Thread"
navigate(baseUrl)
# Make sure pin exists
ensureExists "#threads-list .thread-1 .thread-title i"
checkText "#threads-list .thread-1 .thread-title a", "Pinned post"
checkText "#threads-list .thread-2 .thread-title a", "Normal post"
test "can unpin a thread":
with session:
click "Pinned post", LinkTextSelector
click "#pin-btn"
checkText "#pin-btn", "Pin Thread"
navigate(baseUrl)
checkIsNone "#threads-list .thread-2 .thread-title i"
checkText "#threads-list .thread-1 .thread-title a", "Normal post"
checkText "#threads-list .thread-2 .thread-title a", "Pinned post"
test "can lock a thread":
with session:
click "Locking", LinkTextSelector
click "#lock-btn"
ensureExists "#thread-title i.fas.fa-lock.fa-xs"
test "locked thread appears on frontpage":
with session:
click "#new-thread-btn"
sendKeys "#thread-title", "A new locked thread"
sendKeys "#reply-textarea", "This thread should appear locked on the frontpage"
click "#create-thread-btn"
click "#lock-btn"
navigate(baseUrl)
ensureExists "#threads-list .thread-1 .thread-title i.fas.fa-lock.fa-xs"
test "can unlock a thread":
with session:
click "Locking", LinkTextSelector
click "#lock-btn"
checkIsNone "#thread-title i.fas.fa-lock.fa-xs"
session.logout()
proc test*(session: Session, baseUrl: string) =
@ -158,4 +264,4 @@ proc test*(session: Session, baseUrl: string) =
unBanUser(session, baseUrl)
session.navigate(baseUrl)
session.navigate(baseUrl)