Compare commits

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

197 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
Dominik Picheta
b91bdeb450
Remove reloading and fix issues with state not being refreshed (#232)
* Remove reloading and fix issues with state not being refreshed

* Fix PR comments

* Remove unnecessary closure

* Change proc to anonymous proc

* categoryIdOption -> categoryId
2020-03-04 23:57:27 +01:00
Joey Yakimowich-Payne
7a7a7145ee Fix using magic number as default category id 2020-03-04 23:27:23 +01:00
Joey Yakimowich-Payne
0d63fef0f7 Add one more test for user in categories page 2020-03-03 15:11:26 +01:00
Joey Yakimowich-Payne
b26085cbd0 Update webdriver version 2020-03-03 15:11:26 +01:00
Joey Yakimowich-Payne
60ace9c65a Remove unnecessary check 2020-03-03 15:11:26 +01:00
Joey Yakimowich-Payne
5f930c7f5a Ignore more generated files 2020-03-03 15:11:26 +01:00
Joey Yakimowich-Payne
3b092ae2d1 Change unnecessary templates to procs 2020-03-03 15:11:26 +01:00
Joey Yakimowich-Payne
31d3b2701d Add create category test 2020-03-03 15:11:26 +01:00
Joey Yakimowich-Payne
9127bc4c88 Add 'add category button' to categories page 2020-03-03 15:11:26 +01:00
Joey Yakimowich-Payne
95b21874db Separate out add category modal 2020-03-03 15:11:26 +01:00
Joey Yakimowich-Payne
7ec3ff9cac Minor cleanup 2020-03-03 15:11:26 +01:00
Joey Yakimowich-Payne
5ce99a5a3d Add tests and improve testing experience 2020-03-03 15:11:26 +01:00
Joey Yakimowich-Payne
5fc811e797 Only hide category on category page for testing purposes 2020-03-03 15:11:26 +01:00
Joey Yakimowich-Payne
91746924dc Remove rate limit check on compiler flag 2020-03-03 15:11:26 +01:00
Joey Yakimowich-Payne
5e033d0356 Upgrade webdriver and run test backend separately 2020-03-03 15:11:26 +01:00
Joey Yakimowich-Payne
1f0f736915 Fix category clicking 2020-03-03 15:11:26 +01:00
Joey Yakimowich-Payne
433a21aa87 Fix compact and reloading 2020-03-03 15:11:26 +01:00
Joey Yakimowich-Payne
fd80f754d2 Make categories compact by default 2020-03-03 15:11:26 +01:00
Joey Yakimowich-Payne
6c70713afb Cleanup test imports 2020-03-03 15:11:26 +01:00
Joey Yakimowich-Payne
f3396777fb Add expanded version of category drop down and add it to categories page 2020-03-03 15:11:26 +01:00
Joey Yakimowich-Payne
38a21f34e6 Work around elements not refreshing on url operations 2020-03-03 15:11:26 +01:00
Joey Yakimowich-Payne
70ad45d8f6 Add number of topics to category list 2020-03-03 15:11:26 +01:00
Joey Yakimowich-Payne
da7045ecca Cleanup 2020-03-03 15:11:26 +01:00
Joey Payne
9d19d70558 Add categories page 2020-03-03 15:11:26 +01:00
Joey Yakimowich-Payne
5a4f44b4ee Speedup and simplify tests
This drastically speeds up tests and simplifies test writing by making
it so that no explicit calls for waiting are needed. Elements that are
queried for now implicitly waits for them to be available.

On my machine, tests used to take 3-4 minutes to complete. Now they take
~1 minute to complete.
2020-02-17 08:53:30 +01:00
Joey Yakimowich-Payne
616c6eb100 Cleanup unused imports and compiler warnings 2020-02-16 14:07:00 +00:00
Joey Yakimowich-Payne
8d317ae0e3 Get rid of casts 2020-02-16 14:07:00 +00:00
Dominik Picheta
42985fd2bc
Merge pull request #194 from jyapayne/add_categories
Add preliminary category support
2020-02-15 17:08:26 +00:00
Joey Yakimowich-Payne
01d13aa0f3 Add todo about categories query 2020-02-15 09:36:50 -07:00
Joey Yakimowich-Payne
7337bceff3 Remove casting from formdata 2020-02-15 09:35:05 -07:00
Joey Yakimowich-Payne
ce9cde4a0d Refactor category picker 2020-02-15 09:02:00 -07:00
Joey Yakimowich-Payne
918cda96cf Minor cleanup 2020-02-15 08:53:56 -07:00
Joey Yakimowich-Payne
d9335ee0f0 Tighten updateThread fields and add todos 2020-02-15 08:52:13 -07:00
Joey Yakimowich-Payne
46d6a3b6bb Fix indentation 2020-02-15 08:37:28 -07:00
Joey Yakimowich-Payne
de7b391d11 Remove unnecessary readme line 2020-02-15 08:36:08 -07:00
Joey Yakimowich-Payne
fac828b1e9 Merge branch 'master' into add_categories 2020-02-14 09:48:08 -07:00
Joey Yakimowich-Payne
2717496bb5 Handle no replyingTo option better 2020-02-14 17:43:17 +01:00
Joey Yakimowich-Payne
14a0864d86 Version bump 2020-02-14 17:43:17 +01:00
Joey Yakimowich-Payne
64262978db Fix travis 2020-02-14 17:43:17 +01:00
Joey Yakimowich-Payne
f93bd87316 Update for Nim 1.0.6 2020-02-14 17:43:17 +01:00
Dominik Picheta
35baa815fd
Merge pull request #219 from lallulli/patch-1
Better way of updating nginx configuration
2019-11-01 21:46:22 +00:00
lallulli
f4af965a2e
Better way of updating nginx configuration
Using `nginx -s reload` is better than restarting the server on live systems, because if there is any problem with new config files, nginx will warn you, refuse to reload and continue to work with old configuration. Moreover, this command will minimize downtime.
2019-10-31 11:36:05 +01:00
Joey Payne
41a1a36dbf Fix issues compiling and testing 2019-01-24 20:37:03 -07:00
Dominik Picheta
b65fccd416
Merge pull request #201 from LemonBoy/hide-avatars
Hide all the avatars but the first on small screens
2018-08-22 21:01:17 +01:00
LemonBoy
30dc09f453 Hide all the avatars but the first on small screens 2018-08-22 17:43:24 +02:00
Dominik Picheta
bedaec3540
Merge pull request #199 from jyapayne/case_insensitive_usernames
Case insensitive usernames
2018-08-16 12:06:29 +01:00
Joey Yakimowich-Payne
2b88a54f54 Add test for case insensitive name check 2018-08-16 11:29:57 +09:00
Joey Yakimowich-Payne
7321ee6f61 Add case insensitive check to username validation 2018-08-16 11:10:49 +09:00
Joey
f8781ba5f3 Add better RST preview error (#196)
* Add better RST preview error

* Add exception information back
2018-08-10 08:51:22 +01:00
Joey Yakimowich-Payne
4e1b906b49 Rename square to category 2018-08-09 10:14:03 +09:00
Joey Yakimowich-Payne
416d2601fb Fix another test issue 2018-08-09 09:51:37 +09:00
Joey Yakimowich-Payne
796d8ee20c Fix tests 2018-08-08 21:41:20 +09:00
Joey Yakimowich-Payne
0af291dc10 Add test for category adding 2018-08-08 19:12:28 +09:00
Joey Yakimowich-Payne
d5df46823a Add category add button on new thread 2018-08-08 19:10:46 +09:00
Joey Yakimowich-Payne
82463ea423 Add server check for category adding 2018-08-08 19:09:54 +09:00
Joey Yakimowich-Payne
f1c5db2ced Add frontend for adding a category with the category picker 2018-08-08 18:47:47 +09:00
Joey Yakimowich-Payne
3b681e32f6 Add backend for creating a category 2018-08-08 18:22:41 +09:00
Joey Yakimowich-Payne
a05667ef78 Add server side checking for user permissions 2018-08-07 21:03:05 +09:00
Joey Yakimowich-Payne
cb7418f825 Add frontend for category picker when admin or thread author 2018-08-07 20:26:48 +09:00
Joey Yakimowich-Payne
b0639c4da2 Add backend for updating thread 2018-08-07 20:25:35 +09:00
Joey Yakimowich-Payne
2e42ede2ad Add basic categories test 2018-08-06 17:30:07 +09:00
Joey Yakimowich-Payne
d35f1e90cf Add name slug for categories and remove unused function 2018-08-06 17:21:18 +09:00
Joey Yakimowich-Payne
495ddf5d93 Add test data for categories 2018-08-06 16:54:55 +09:00
Joey Yakimowich-Payne
3df30386d9 Fix misc compiler warnings 2018-08-06 16:54:16 +09:00
Joey Yakimowich-Payne
5ed17333f9 Add category id to picker 2018-08-06 16:46:04 +09:00
Joey Yakimowich-Payne
f0bcb9abfd Add backend for categories 2018-08-06 16:32:20 +09:00
Joey Yakimowich-Payne
7cda14e9fe Add frontend category picker and enable categories in threadlist 2018-08-06 16:22:32 +09:00
Mr.Chun
7e42479228 make send mail from address configurable (#191)
* feat(email): make send mail from address configurable

* Exit sendMail early if there is no smtpFromAddr.

* Small adjustment to setup_nimforum
2018-07-29 16:04:36 +01:00
Dominik Picheta
ee3324cfb7
Merge pull request #187 from jyapayne/fix_tests
Fix tests
2018-07-26 11:53:23 +01:00
Joey Yakimowich-Payne
85f31aaf6b Update webdriver commit hash 2018-07-23 19:59:05 +09:00
Joey Yakimowich-Payne
7925c4b8b1 Remove navigate from login/logout and add clear command 2018-07-23 10:13:06 +09:00
Joey Yakimowich-Payne
465ba1e024 Fix tests and merge 2018-07-22 20:10:41 +09:00
Joey Yakimowich-Payne
a6c0fe691c Add test for banned user 2018-07-22 19:28:21 +09:00
Dominik Picheta
8c16a776b6 Bump version to 2.0.1. 2018-07-20 15:45:11 +01:00
Dominik Picheta
5714ad0c6a Adds attempt to test issue #181. Moves procs to common too.
Sadly the attempt wasn't a success. I decided to commit what I've
got so far and come back to it later. Out of time for this now.
2018-07-20 15:16:39 +01:00
Dominik Picheta
9f9d16467f Fixes #181. 2018-07-20 14:14:59 +01:00
Dominik Picheta
9ee0ddf176 Sessions now persist across different IP addresses. 2018-07-20 13:52:27 +01:00
Dominik Picheta
adaae54e7b
Merge pull request #185 from markprocess/master
Refactoring exception handling
2018-07-19 21:43:04 +01:00
markprocess
954fe7b05a Refactoring exception handling 2018-07-19 16:09:36 -04:00
Dominik Picheta
22ec94590c Update Jester to 0.4.0. 2018-07-19 19:37:46 +01:00
Dominik Picheta
93913d9b13
Merge pull request #184 from jyapayne/rework_tests
Make tests nicer to write
2018-07-17 16:31:58 +01:00
Joey Yakimowich-Payne
b405f63a32 Separate user and admin tests 2018-07-17 22:51:01 +09:00
Joey Yakimowich-Payne
80558b6bfb Make 5000 ms default wait 2018-07-17 22:32:38 +09:00
Joey Yakimowich-Payne
c361fda523 Replace len check with assert 2018-07-17 22:29:51 +09:00
Joey Yakimowich-Payne
0050ad42f5 Try to appease travis 2018-07-17 20:04:02 +09:00
Joey Yakimowich-Payne
b2fc4dfbe0 Add license id 2018-07-17 12:50:23 +09:00
Joey Yakimowich-Payne
f315be7361 Add tests for like/delete thread 2018-07-16 21:10:46 +09:00
Joey Yakimowich-Payne
3324f37faa Cleanup in scenario1 2018-07-16 21:09:55 +09:00
Joey Yakimowich-Payne
6fb5cfbfa2 Add more friendly classes 2018-07-16 21:09:24 +09:00
Joey Yakimowich-Payne
0d67eab626 Create test and dev users for testing 2018-07-16 19:06:00 +09:00
Joey Yakimowich-Payne
3c93224817 Fix user/password combo 2018-07-16 19:05:26 +09:00
Joey Yakimowich-Payne
d7f3a038a9 Rewrite threads tests with new macro 2018-07-16 14:51:21 +09:00
Joey Yakimowich-Payne
1b55aec5d2 Rewrite scenario1 with new macro 2018-07-16 14:50:33 +09:00
Joey Yakimowich-Payne
cb5923d9f8 Add helpful macro and procs for testing 2018-07-16 14:49:56 +09:00
Joey Yakimowich-Payne
d422b07394 Add more helpful classes and ids 2018-07-16 14:49:06 +09:00
Dominik Picheta
82ff2fb212
Merge pull request #179 from jyapayne/add_threads_test
Add threads tests
2018-07-13 11:50:42 +01:00
Joey Yakimowich-Payne
b650a9f401 Add basic test for creating a thread 2018-07-13 09:18:09 +09:00
Joey Yakimowich-Payne
122e279256 Add helpful ids and classes for testing 2018-07-13 09:16:48 +09:00
Dominik Picheta
8701aa92ac
Merge pull request #173 from jyapayne/patch-1
Update readme with required devdb call
2018-07-06 14:52:05 +01:00
Joey
34f5b3f80a
Update readme with required devdb call 2018-07-06 21:55:34 +09:00
Dominik Picheta
c3c3d91a0a Improves setup document. 2018-05-27 21:45:08 +01:00
Dominik Picheta
48d6480ea4 Version 2.0.0 2018-05-27 21:26:06 +01:00
Dominik Picheta
5c15dd3f11 Add setup file. Fixes #139. 2018-05-27 21:25:08 +01:00
Dominik Picheta
6e207327a5 Fixes post deletion for users. 2018-05-27 20:58:38 +01:00
Dominik Picheta
0e30f4f625 Add --setup option to setup_nimforum script. 2018-05-27 20:55:31 +01:00
Dominik Picheta
b958904b6a Fixes new thread style regression. 2018-05-25 22:59:58 +01:00
Dominik Picheta
8d991374f6 Improves .gitignore and removes outdated forum.json.example file. 2018-05-25 22:57:53 +01:00
Dominik Picheta
d0ec32321c Hotfix mentions in anchors. 2018-05-25 22:57:38 +01:00
Dominik Picheta
e515c168b2 Bump karax dependency to fix Edge issues. 2018-05-25 22:03:23 +01:00
Dominik Picheta
3054be9111 Fixes unicode profile names in backend. 2018-05-25 22:00:51 +01:00
Dominik Picheta
c32daefec7 Fixes profile UTF-8 decoding issue. 2018-05-25 21:43:41 +01:00
Dominik Picheta
a64121fcf9 Add forum hyperlink to readme. 2018-05-24 17:25:37 +01:00
Dominik Picheta
449d47108a Adjust padding for panel-body in #new-thread. 2018-05-24 17:07:29 +01:00
Dominik Picheta
571da55460 Fixes #135. 2018-05-24 17:01:40 +01:00
Dominik Picheta
919c09b4a9 Improve default stylesheet. 2018-05-24 16:06:41 +01:00
Dominik Picheta
d6a2af2217 Adds features to readme. 2018-05-24 16:06:34 +01:00
Dominik Picheta
32b81b94cf Fixes loading of posts/profiles multiple times unnecessarily. 2018-05-24 15:35:52 +01:00
Dominik Picheta
2d4608cb64 Bump version to 2.0. 2018-05-24 15:28:07 +01:00
Dominik Picheta
256bb5815d Improves readme. 2018-05-24 15:27:48 +01:00
Dominik Picheta
adeed9b5be Don't show the "last visit" line after last thread. 2018-05-24 14:52:17 +01:00
Dominik Picheta
5f198a35db Show usernames of people that liked a post on hover. 2018-05-24 14:46:59 +01:00
Dominik Picheta
2e3f8d4235 Adds rst cheatsheet. 2018-05-24 14:43:18 +01:00
Dominik Picheta
dbb3d5c7c1 Fixes Ctrl/Cmd not opening links in new tab. 2018-05-24 14:15:53 +01:00
Dominik Picheta
7baae0bde0 Spammers are now hidden from the thread list properly. 2018-05-24 14:07:52 +01:00
Dominik Picheta
c566b05347 Update Karax and small frontend fixes. 2018-05-24 12:00:06 +01:00
Dominik Picheta
32dbf30781 Fixes overflow for <pre> blocks without .code. 2018-05-23 14:27:44 +01:00
Dominik Picheta
934dbef44c Add recaptcha to reset password modal. 2018-05-23 14:00:49 +01:00
Dominik Picheta
6c4fd6f50b Pin sass package commit. 2018-05-23 12:56:07 +01:00
Dominik Picheta
b8c1ae1fe0 Implements ability to specify the HTTP port. 2018-05-23 12:48:31 +01:00
Dominik Picheta
b9df0ec895 Fixes newThread regression. 2018-05-22 23:04:08 +01:00
Dominik Picheta
3a87ecb0ab Adds titles to threads and profiles. 2018-05-22 22:57:28 +01:00
Dominik Picheta
2f2d83b86c Change 'scripts' to 'ga' for google analytics specific config. 2018-05-22 22:49:34 +01:00
56 changed files with 2583 additions and 647 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

11
.gitignore vendored
View file

@ -1,7 +1,7 @@
# Wildcard patterns.
*.swp
nimcache/
*.db
*.db*
# Specific paths
/createdb
@ -12,3 +12,12 @@ nimcache/
forum
createdb
editdb
.vscode
forum.json*
browsertester
setup_nimforum
buildcss
nimforum.css
/src/frontend/forum.js

View file

@ -1,44 +0,0 @@
os:
- linux
language: c
cache:
directories:
- "$HOME/.nimble"
- "$HOME/.choosenim"
addons:
firefox: "60.0.1"
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.20.1/geckodriver-v0.20.1-linux64.tar.gz
- mkdir geckodriver
- tar -xzf geckodriver-v0.20.1-linux64.tar.gz -C geckodriver
- export PATH=$PATH:$PWD/geckodriver
install:
- export CHOOSENIM_CHOOSE_VERSION="#987bf13"
- |
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 test

130
README.md
View file

@ -1,67 +1,131 @@
# nimforum
This is Nim's forum. Available at http://forum.nim-lang.org.
NimForum is a light-weight forum implementation
with many similarities to Discourse. It is implemented in
the [Nim](https://nim-lang.org) programming
language and uses SQLite for its database.
## Building
## Examples in the wild
You can use ``nimble`` (available [here](https://github.com/nim-lang/nimble))
to get all the necessary
[dependencies](https://github.com/nim-lang/nimforum/blob/master/nimforum.nimble#L11).
[![forum.nim-lang.org](https://i.imgur.com/hdIF5Az.png)](https://forum.nim-lang.org)
Clone this repo and execute ``nimble build`` in this repositories directory.
<p align="center" margin="0"><a href="https://forum.nim-lang.org"><b>forum.nim-lang.org</b></a></p>
See also: <a href="#running-the-forum">Running the forum</a> for how to create the database.
## Features
* Efficient, type safe and clean **single-page application** developed using the
[Karax](https://github.com/pragmagic/karax) and
[Jester](https://github.com/dom96/jester) frameworks.
* **Utilizes SQLite** making set up much easier.
* Endlessly **customizable** using SASS.
* Spam blocking via new user sandboxing with great tools for moderators.
* reStructuredText enriched by Markdown to make formatting your posts a breeze.
* Search powered by SQLite's full-text search.
* Context-aware replies.
* Last visit tracking.
* Gravatar support.
* And much more!
## Setup
[See this document.](https://github.com/nim-lang/nimforum/blob/master/setup.md)
## Dependencies
The code depends on the RST parser of the Nim compiler and on Jester.
The captchas for registration uses the [reCaptcha module](https://github.com/euantorano/recaptcha.nim).
The following lists the dependencies which you may need to install manually
in order to get NimForum running, compiled*, or tested†.
#### bcrypt
* libsass
* SQLite
* pcre
* Nim (and the Nimble package manager)*
* [geckodriver](https://github.com/mozilla/geckodriver)†
* Firefox†
On macosx you also need to make sure to use the bcrypt >= 0.2.1 module if that
is not yet updated you can install it with:
[*] Build time dependencies
[†] Test time dependencies
## Development
Check out the tasks defined by this project's ``nimforum.nimble`` file by
running ``nimble tasks``, as of writing they are:
```
nimble install https://github.com/oderwat/bcryptnim.git@#fix-osx
backend Compiles and runs the forum backend
runbackend Runs the forum backend
frontend Builds the necessary JS frontend (with CSS)
minify Minifies the JS using Google's closure compiler
testdb Creates a test DB (with admin account!)
devdb Creates a test DB (with admin account!)
blankdb Creates a blank DB
test Runs tester
fasttest Runs tester without recompiling backend
```
You may also need to change `nimforum.nimble` such that it uses 0.2.1 by
changing the dependencies slightly.
To get up and running:
```
[Deps]
Requires: "nim >= 0.14.0, jester#head, bcrypt >= 0.2.1, recaptcha >= 1.0.0"
```bash
git clone https://github.com/nim-lang/nimforum
cd nimforum
git submodule update --init --recursive
# Setup the db with user: admin, pass: admin and some other users
nimble devdb
# Run this again if frontend code changes
nimble frontend
# Will start a server at localhost:5000
nimble backend
```
# Running the forum
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`
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.
**Important: You need to compile and run `createdb` to generate the initial database
before you can run `forum` the first time**!
### With docker
**Note: If you do not have a mail server set up locally, you can specify
``-d:dev`` during compilation to prevent nimforum from attempting to send
emails and to automatically activate user accounts**
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.
This is as simple as:
To get up and running:
```
nim c -r createdb
```bash
cd docker
docker-compose build
docker-compose up
```
After that you can just run `forum` and if everything is ok you will get the info which URL you need to open in your browser (http://localhost:5000) to access it.
And you can access local NimForum site.
Open http://localhost:5000 .
_There is an update helper `editdb` which you can safely ignore for now._
# Troubleshooting
_The file `cache.nim` is included by `forum.nim` and do
not need to be compiled by you._
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 (c) 2012-2017 Andreas Rumpf, Dominik Picheta.
Copyright (c) 2012-2018 Andreas Rumpf, Dominik Picheta.
All rights reserved.
# License
Nimforum is licensed under the MIT license.
NimForum is licensed under the MIT license.

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,4 +0,0 @@
{
"recaptchaSecretKey": "",
"recaptchaSiteKey": ""
}

View file

@ -1,4 +1,4 @@
Copyright (C) 2015 Andreas Rumpf, Dominik Picheta
Copyright (C) 2018 Andreas Rumpf, Dominik Picheta
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
@ -7,7 +7,7 @@ use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
of the Software, and to permit persons to whom the Software is furnished to do
so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR

View file

@ -9,4 +9,8 @@ $secondary-btn-color: #f1f1f1;
$body-bg: #ffffff;
$navbar-color: $body-bg;
$navbar-border-color-dark: $body-bg;
$navbar-primary-color: #e80080;
$navbar-primary-color: #e80080;
#main-navbar input#search-box {
border: 1px solid #e6e6e6;
}

View file

@ -1,5 +1,5 @@
# Package
version = "0.1.0"
version = "2.1.0"
author = "Dominik Picheta"
description = "The Nim forum"
license = "MIT"
@ -12,16 +12,16 @@ skipExt = @["nim"]
# Dependencies
requires "nim >= 0.14.0"
requires "jester#64295c8"
requires "bcrypt#head"
requires "nim >= 1.0.6"
requires "jester#405be2e"
requires "bcrypt#440c5676ff6"
requires "hmac#9c61ebe2fd134cf97"
requires "recaptcha 1.0.2"
requires "sass"
requires "recaptcha#d06488e"
requires "sass#649e0701fa5c"
requires "https://github.com/dom96/karax#7a884fb"
requires "karax#5f21dcd"
requires "webdriver#a2be578"
requires "webdriver#429933a"
# Tasks
@ -32,26 +32,33 @@ task backend, "Compiles and runs the forum backend":
task runbackend, "Runs the forum backend":
exec "./src/forum"
task testbackend, "Runs the forum backend in test mode":
exec "nimble c -r -d:skipRateLimitCheck src/forum.nim"
task frontend, "Builds the necessary JS frontend (with CSS)":
exec "nimble c -r src/buildcss"
exec "nimble js -d:release src/frontend/forum.nim"
mkDir "public/js"
cpFile "src/frontend/nimcache/forum.js", "public/js/forum.js"
cpFile "src/frontend/forum.js", "public/js/forum.js"
task minify, "Minifies the JS using Google's closure compiler":
exec "closure-compiler public/js/forum.js --js_output_file public/js/forum.js.opt"
task testdb, "Creates a test DB":
task testdb, "Creates a test DB (with admin account!)":
exec "nimble c src/setup_nimforum"
exec "./src/setup_nimforum --test"
task devdb, "Creates a test DB":
task devdb, "Creates a test DB (with admin account!)":
exec "nimble c src/setup_nimforum"
exec "./src/setup_nimforum --dev"
task blankdb, "Creates a blank DB":
exec "nimble c src/setup_nimforum"
exec "./src/setup_nimforum --blank"
task test, "Runs tester":
exec "nimble c -y src/forum.nim"
exec "nimble c -y -r tests/browsertester"
exec "nimble c -y -r -d:actionDelayMs=0 tests/browsertester"
task fasttest, "Runs tester without recompiling backend":
exec "nimble c -r tests/browsertester"
exec "nimble c -r -d:actionDelayMs=0 tests/browsertester"

View file

@ -22,6 +22,7 @@ table th {
// Custom styles.
// - Navigation bar.
$navbar-height: 60px;
$default-category-color: #a3a3a3;
$logo-height: $navbar-height - 20px;
.navbar-button {
@ -50,6 +51,7 @@ $logo-height: $navbar-height - 20px;
// Unfortunately we must colour the controls in the navbar manually.
.search-input {
@extend .form-input;
min-width: 120px;
border-color: $navbar-border-color-dark;
}
@ -107,12 +109,51 @@ $logo-height: $navbar-height - 20px;
}
}
#category-selection {
.dropdown {
.btn {
margin-right: 0px;
}
}
.plus-btn {
margin-right: 0px;
i {
margin-right: 0px;
}
}
}
.category-description {
opacity: 0.6;
font-size: small;
}
.category-status {
font-size: small;
font-weight: bold;
.topic-count {
margin-left: 5px;
opacity: 0.7;
font-size: small;
}
}
.category {
white-space: nowrap;
}
#new-thread {
.modal-container .modal-body {
max-height: none;
}
.form-input {
.panel-body {
padding-top: $control-padding-y*2;
padding-bottom: $control-padding-y*2;
}
.form-input[name='subject'] {
margin-bottom: $control-padding-y*2;
}
@ -143,6 +184,33 @@ $logo-height: $navbar-height - 20px;
}
}
.thread-list {
@extend .container;
@extend .grid-xl;
}
.category-list {
@extend .thread-list;
.category-title {
@extend .thread-title;
a, a:hover {
color: lighten($body-font-color, 10%);
text-decoration: none;
}
}
.category-description {
opacity: 0.6;
}
}
#categories-list .category {
border-left: 6px solid;
border-left-color: $default-category-color;
}
$super-popular-color: #f86713;
$popular-color: darken($super-popular-color, 25%);
$threads-meta-color: #545d70;
@ -191,14 +259,12 @@ $threads-meta-color: #545d70;
}
}
.triangle {
// TODO: Abstract this into a "category" class.
.category-color {
width: 0;
height: 0;
border-left: 0.3rem solid transparent;
border-right: 0.3rem solid transparent;
border-bottom: 0.6rem solid #98c766;
border: 0.25rem solid $default-category-color;
display: inline-block;
margin-right: 5px;
}
.load-more-separator {
@ -235,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 {
color: $threads-meta-color;
@ -248,8 +322,11 @@ $threads-meta-color: #545d70;
}
#threads-list tr.banned {
display: none; // TODO: Fix server so that it doesn't send banned threads.
// Hide all the avatars but the first on small screens.
@media screen and (max-width: 600px) {
#threads-list a:not(:first-child) > .avatar {
display: none;
}
}
.posts, .about {
@ -353,7 +430,7 @@ $threads-meta-color: #545d70;
}
}
.post-content {
.post-content, .about {
img {
max-width: 100%;
}
@ -483,6 +560,12 @@ blockquote {
display: none;
}
.post-content {
pre:not(.code) {
overflow: scroll;
}
}
.information {
@extend .tile;
border-top: 1px solid $border-color;
@ -696,18 +779,3 @@ hr {
margin-top: $control-padding-y*2;
}
}
// - Hide features that have not been implemented yet.
#main-buttons > section.navbar-section:nth-child(1) {
display: none;
}
#threads-list.table {
tr > th:nth-child(2), tr > td:nth-child(2) {
display: none;
}
}
.category, div.flag-button {
display: none;
}

View file

@ -11,12 +11,21 @@
<link rel="stylesheet" href="/css/nimforum.css">
<link rel="stylesheet" href="https://use.fontawesome.com/releases/v5.0.12/css/all.css" integrity="sha384-G0fIWCsCzJIMAVNQPfjH08cyYaUtMwjJwqiRKxxE/rx96Uroj1BtIQ6MLJuheaO9" crossorigin="anonymous">
<link rel="icon" href="/images/favicon.png">
<!-- Global site tag (gtag.js) - Google Analytics -->
<script async src="https://www.googletagmanager.com/gtag/js?id=$ga"></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', '$ga');
</script>
</head>
<body>
<div id="ROOT" />
<div id="ROOT"></div>
<script type="text/javascript" src="/js/forum.js?t=$timestamp"></script>
$scripts
</body>
</html>

View file

@ -1,5 +1,5 @@
reStructuredText cheat sheet
===========================================================================
Markdown and RST supported by this forum
========================================
This is a cheat sheet for the *reStructuredText* dialect as implemented by
Nim's documentation generator which has been reused for this forum.
@ -11,7 +11,6 @@ for further information.
Elements of **markdown** are also supported.
Inline elements
---------------
@ -28,6 +27,18 @@ Plain text Result
``\\escape`` \\escape
=============================== ============================================
Quoting other users can be done by prefixing their message with ``>``::
> Hello World
Hi!
Which will result in:
> Hello World
Hi!
Links
-----
@ -44,33 +55,22 @@ Or like this::
Code blocks
-----------
are done this way::
The code blocks can be written in the same style as most common Markdown
flavours::
```nim
if x == "abc":
echo "xyz"
```
or using RST syntax::
.. code-block:: nim
if x == "abc":
echo "xyz"
Is rendered as:
.. code-block:: nim
if x == "abc":
echo "xyz"
Except Nim, the programming languages C, C++, Java and C# have highlighting
support.
An alternative github-like syntax is also supported. This has the advantage
that no excessive indentation is needed::
```nim
if x == "abc":
echo "xyz"```
Is rendered as:
Both are rendered as:
.. code-block:: nim
@ -78,18 +78,20 @@ Is rendered as:
echo "xyz"
Apart from Nim, the programming languages C, C++, Java and C# also
have highlighting support.
Literal blocks
--------------
Are introduced by '::' and a newline. The block is indicated by indentation:
These are introduced by '::' and a newline. The block is indicated by indentation:
::
::
if x == "abc":
echo "xyz"
Is rendered as::
The above is rendered as::
if x == "abc":
echo "xyz"
@ -99,7 +101,7 @@ Is rendered as::
Bullet lists
------------
look like this::
Bullet lists look like this::
* Item 1
* Item 2 that
@ -110,7 +112,7 @@ look like this::
- item 3b
- valid bullet characters are ``+``, ``*`` and ``-``
Is rendered as:
The above rendered as:
* Item 1
* Item 2 that
spans over multiple lines
@ -124,7 +126,7 @@ Is rendered as:
Enumerated lists
----------------
are written like this::
Enumerated lists are written like this::
1. This is the first item
2. This is the second item
@ -132,60 +134,13 @@ are written like this::
single letters, or roman numerals
#. This item is auto-enumerated
Is rendered as:
They are rendered as:
1. This is the first item
2. This is the second item
3. Enumerators are arabic numbers,
single letters, or roman numerals
#. This item is auto-enumerated
Quoting someone
---------------
quotes are just::
**Someone said**: Indented paragraphs,
and they may nest.
Is rendered as:
**Someone said**: Indented paragraphs,
and they may nest.
Definition lists
----------------
are written like this::
what
Definition lists associate a term with
a definition.
how
The term is a one-line phrase, and the
definition is one or more paragraphs or
body elements, indented relative to the
term. Blank lines are not allowed
between term and definition.
and look like:
what
Definition lists associate a term with
a definition.
how
The term is a one-line phrase, and the
definition is one or more paragraphs or
body elements, indented relative to the
term. Blank lines are not allowed
between term and definition.
#. This item is auto-enumerated
Tables
@ -221,6 +176,35 @@ Cell 7 Cell 8 Cell 9
Images
------
Image embedding is supported. This includes GIFs as well as mp4 (for which a
<video> tag will be automatically generated).
For example:
```
.. image:: path/to/img.png
```
.. image:: https://upload.wikimedia.org/wikipedia/commons/6/69/Dog_morphological_variation.png
```
Will render as:
.. image:: https://upload.wikimedia.org/wikipedia/commons/6/69/Dog_morphological_variation.png
And a GIF example:
```
.. image:: https://upload.wikimedia.org/wikipedia/commons/2/2c/Rotating_earth_%28large%29.gif
```
Will render as:
.. image:: https://upload.wikimedia.org/wikipedia/commons/2/2c/Rotating_earth_%28large%29.gif
You can also specify the size of the image:
```
.. image:: https://upload.wikimedia.org/wikipedia/commons/6/69/Dog_morphological_variation.png
:width: 40%
```
.. image:: https://upload.wikimedia.org/wikipedia/commons/6/69/Dog_morphological_variation.png
:width: 40%

171
setup.md Normal file
View file

@ -0,0 +1,171 @@
# NimForum setup
This document describes the steps needed to setup a working NimForum instance.
## Requirements
* Ubuntu 16.04+
* Some Linux knowledge
## Installation
Begin by downloading the latest NimForum release from
[here](https://github.com/nim-lang/nimforum/releases). The macOS releases are
mainly provided for testing.
Extract the downloaded tarball on your server. These steps can be done using
the following commands:
```
wget https://github.com/nim-lang/nimforum/releases/download/v2.0.0/nimforum_2.0.0_linux.tar.xz
tar -xf nimforum_2.0.0_linux.tar.xz
```
Then ``cd`` into the forum's directory:
```
cd nimforum_2.0.0_linux
```
### Dependencies
The following may need to be installed on your server:
```
sudo apt install libsass-dev sqlite3
```
## Configuration and DB creation
The NimForum release comes with a handy ``setup_nimforum`` program. Run
it to begin the setup process:
```
./setup_nimforum --setup
```
The program will ask you multiple questions which will require some
additional setup, including mail server info and recaptcha keys. You can
just specify dummy values if you want to play around with the forum as
quickly as possible and set these up later.
This program will create a ``nimforum.db`` file, this contains your forum's
database. It will also create a ``forum.json`` file, you can modify this
file after running the ``setup_nimforum`` script if you've made any mistakes
or just want to change things.
## Running the forum
Executing the forum is simple, just run the ``forum`` binary:
```
./forum
```
The forum will start listening to HTTP requests on port 5000 (by default, this
can be changed in ``forum.json``).
On your server you should set up a separate HTTP server. The recommended choice
is nginx. You can then use it as a reverse proxy for NimForum.
### HTTP server
#### nginx
Once you have nginx installed on your server, you will need to configure it.
Create a ``forum.hostname.com`` file (replace the hostname with your forum's
hostname) inside ``/etc/nginx/sites-available/``.
Place the following inside it:
```
server {
server_name forum.hostname.com;
autoindex off;
location / {
proxy_pass http://localhost:5000;
proxy_set_header Host $host;
proxy_set_header X-Real_IP $remote_addr;
}
}
```
Again, be sure to replace ``forum.hostname.com`` with your forum's
hostname.
You should then create a symlink to this file inside ``/etc/nginx/sites-enabled/``:
```
ln -s /etc/nginx/sites-available/<forum.hostname.com> /etc/nginx/sites-enabled/<forum.hostname.com>
```
Then reload nginx configuration by running ``sudo nginx -s reload``.
### Supervisor
#### systemd
In order to ensure the forum is always running, even after a crash or a server
reboot, you should create a systemd service file.
Create a new file called ``nimforum.service`` inside ``/lib/systemd/system/nimforum.service``.
Place the following inside it:
```
[Unit]
Description=nimforum
After=network.target httpd.service
Wants=network-online.target
[Service]
Type=simple
WorkingDirectory=/home/<user>/nimforum-2.0.0/ # MODIFY THIS
ExecStart=/usr/bin/stdbuf -oL /home/<user>/nimforum-2.0.0/forum # MODIFY THIS
# Restart when crashes.
Restart=always
RestartSec=1
User=dom
StandardOutput=syslog+console
StandardError=syslog+console
[Install]
WantedBy=multi-user.target
```
**Be sure to specify the correct ``WorkingDirectory`` and ``ExecStart``!**
You can then enable and start the service by running the following:
```
sudo systemctl enable nimforum
sudo systemctl start nimforum
```
To check that everything is in order, run this:
```
systemctl status nimforum
```
You should see something like this:
```
● nimforum.service - nimforum
Loaded: loaded (/lib/systemd/system/nimforum.service; enabled; vendor preset: enabled)
Active: active (running) since Fri 2018-05-25 22:09:59 UTC; 1 day 22h ago
Main PID: 21474 (forum)
Tasks: 1
Memory: 55.2M
CPU: 1h 15min 31.905s
CGroup: /system.slice/nimforum.service
└─21474 /home/dom/nimforum/src/forum
```
## Conclusion
That should be all you need to get started. Your forum should now be accessible
via your hostname, assuming that it points to your VPS' IP address.

View file

@ -71,13 +71,13 @@ when isMainModule:
"test",
"$2a$08$bY85AhoD1e9u0IsD9sM7Ee6kFSLeXRLxJ6rMgfb1wDnU9liaymoTG",
1526908753,
"*B2a] IL\"~sh)q-GBd/i$^>.TL]PR~>1IX>Fp-:M3pCm^cFD\um"
"*B2a] IL\"~sh)q-GBd/i$^>.TL]PR~>1IX>Fp-:M3pCm^cFD\\um"
)
let ident2 = makeIdentHash(
"test",
"$2a$08$bY85AhoD1e9u0IsD9sM7Ee6kFSLeXRLxJ6rMgfb1wDnU9liaymoTG",
1526908753,
"*B2a] IL\"~sh)q-GBd/i$^>.TL]PR~>1IX>Fp-:M3pCm^cFD\um"
"*B2a] IL\"~sh)q-GBd/i$^>.TL]PR~>1IX>Fp-:M3pCm^cFD\\um"
)
doAssert ident == ident2
@ -85,6 +85,6 @@ when isMainModule:
"test",
"$2a$08$bY85AhoD1e9u0IsD9sM7Ee6kFSLeXRLxJ6rMgfb1wDnU9liaymoTG",
1526908754,
"*B2a] IL\"~sh)q-GBd/i$^>.TL]PR~>1IX>Fp-:M3pCm^cFD\um"
"*B2a] IL\"~sh)q-GBd/i$^>.TL]PR~>1IX>Fp-:M3pCm^cFD\\um"
)
doAssert ident != invalid
doAssert ident != invalid

View file

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

View file

@ -20,7 +20,7 @@ proc newMailer*(config: Config): Mailer =
proc rateCheck(mailer: Mailer, address: string): bool =
## Returns true if we've emailed the address too much.
let diff = getTime() - mailer.lastReset
if diff.hours >= 1:
if diff.inHours >= 1:
mailer.lastReset = getTime()
mailer.emailsSent.clear()
@ -30,7 +30,6 @@ proc rateCheck(mailer: Mailer, address: string): bool =
proc sendMail(
mailer: Mailer,
subject, message, recipient: string,
fromAddr = "forum@nim-lang.org",
otherHeaders:seq[(string, string)] = @[]
) {.async.} =
# Ensure we aren't emailing this address too much.
@ -41,21 +40,37 @@ proc sendMail(
if mailer.config.smtpAddress.len == 0:
warn("Cannot send mail: no smtp server configured (smtpAddress).")
return
if mailer.config.smtpFromAddr.len == 0:
warn("Cannot send mail: no smtp from address configured (smtpFromAddr).")
return
var client: AsyncSmtp
if mailer.config.smtpTls:
client = newAsyncSmtp(useSsl=false)
await client.connect(mailer.config.smtpAddress, Port(mailer.config.smtpPort))
await client.startTls()
elif mailer.config.smtpSsl:
client = newAsyncSmtp(useSsl=true)
await client.connect(mailer.config.smtpAddress, Port(mailer.config.smtpPort))
else:
client = newAsyncSmtp(useSsl=false)
await client.connect(mailer.config.smtpAddress, Port(mailer.config.smtpPort))
var client = newAsyncSmtp()
await client.connect(mailer.config.smtpAddress, Port(mailer.config.smtpPort))
if mailer.config.smtpUser.len > 0:
await client.auth(mailer.config.smtpUser, mailer.config.smtpPassword)
let toList = @[recipient]
var headers = otherHeaders
headers.add(("From", fromAddr))
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)
await client.sendMail(fromAddr, toList, $encoded)
await client.sendMail(mailer.config.smtpFromAddr, toList, $encoded)
proc sendPassReset(mailer: Mailer, email, user, resetUrl: string) {.async.} =
let message = """Hello $1,
@ -133,4 +148,4 @@ proc sendSecureEmail*(
if emailSentFut.error of ForumError:
raise emailSentFut.error
else:
raise newForumError("Couldn't send email", @["email"])
raise newForumError("Couldn't send email", @["email"])

View file

@ -8,7 +8,7 @@
import system except Thread
import
os, strutils, times, md5, strtabs, math, db_sqlite,
scgi, jester, asyncdispatch, asyncnet, sequtils,
jester, asyncdispatch, asyncnet, sequtils,
parseutils, random, rst, recaptcha, json, re, sugar,
strformat, logging
import cgi except setCookie
@ -76,7 +76,6 @@ proc getGravatarUrl(email: string, size = 80): string =
# -----------------------------------------------------------------------------
template `||`(x: untyped): untyped = (if not isNil(x): x else: "")
proc validateCaptcha(recaptchaResp, ip: string) {.async.} =
# captcha validation:
@ -105,7 +104,8 @@ proc sendResetPassword(
if row[0] == "":
raise newForumError("Email or username not found", @["email"])
await validateCaptcha(recaptchaResp, userIp)
if not c.loggedIn:
await validateCaptcha(recaptchaResp, userIp)
await sendSecureEmail(
mailer,
@ -114,27 +114,27 @@ proc sendResetPassword(
)
proc logout(c: TForumData) =
const query = sql"delete from session where ip = ? and key = ?"
const query = sql"delete from session where key = ?"
c.username = ""
c.userpass = ""
exec(db, query, c.req.ip, c.req.cookies["sid"])
exec(db, query, c.req.cookies["sid"])
proc checkLoggedIn(c: TForumData) =
if not c.req.cookies.hasKey("sid"): return
let sid = c.req.cookies["sid"]
if execAffectedRows(db,
sql("update session set lastModified = DATETIME('now') " &
"where ip = ? and key = ?"),
c.req.ip, sid) > 0:
"where key = ?"),
sid) > 0:
c.userid = getValue(db,
sql"select userid from session where ip = ? and key = ?",
c.req.ip, sid)
sql"select userid from session where key = ?",
sid)
let row = getRow(db,
sql"select name, email, status from person where id = ?", c.userid)
c.username = ||row[0]
c.email = ||row[1]
c.rank = parseEnum[Rank](||row[2])
c.username = row[0]
c.email = row[1]
c.rank = parseEnum[Rank](row[2])
# In order to handle the "last visit" line appropriately, i.e.
# it shouldn't disappear after a refresh, we need to manage a
@ -151,7 +151,7 @@ proc checkLoggedIn(c: TForumData) =
)
c.previousVisitAt = personRow[1].parseInt
let diff = getTime() - fromUnix(personRow[0].parseInt)
if diff.minutes > 30:
if diff.inMinutes > 30:
c.previousVisitAt = personRow[0].parseInt
db.exec(
sql"""
@ -238,7 +238,7 @@ proc verifyIdentHash(
let newIdent = makeIdentHash(name, row[0], epoch, row[1])
# Check that it hasn't expired.
let diff = getTime() - epoch.fromUnix()
if diff.hours > 2:
if diff.inHours > 2:
raise newForumError("Link expired")
if newIdent != ident:
raise newForumError("Invalid ident hash")
@ -267,7 +267,7 @@ proc initialise() =
{
"title": config.title,
"timestamp": encodeUrl(CompileDate & CompileTime),
"scripts": config.scripts
"ga": config.ga
}.newStringTable()
@ -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
@ -380,13 +381,13 @@ proc selectThreadAuthor(threadId: int): User =
return selectUser(getRow(db, authorQuery, threadId))
proc selectThread(threadRow: seq[string]): Thread =
proc selectThread(threadRow: seq[string], author: User): Thread =
const postsQuery =
sql"""select count(*), min(strftime('%s', creation)) from post
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]): 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]): 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.
@ -418,7 +420,7 @@ proc selectThread(threadRow: seq[string]): Thread =
thread.users.add(selectUser(user))
# Grab the author.
thread.author = selectThreadAuthor(thread.id)
thread.author = author
return thread
@ -434,8 +436,9 @@ proc executeReply(c: TForumData, threadId: int, content: string,
else:
raise newForumError("You are not allowed to post")
if rateLimitCheck(c):
raise newForumError("You're posting too fast!")
when not defined(skipRateLimitCheck):
if rateLimitCheck(c):
raise newForumError("You're posting too fast!")
if content.strip().len == 0:
raise newForumError("Message cannot be empty")
@ -447,9 +450,7 @@ proc executeReply(c: TForumData, threadId: int, content: string,
let isLocked = getValue(
db,
sql"""
select isLocked from thread where id in (
select thread from post where id = ?
)
select isLocked from thread where id = ?;
""",
threadId
)
@ -459,13 +460,21 @@ proc executeReply(c: TForumData, threadId: int, content: string,
if isLocked == "1":
raise newForumError("Cannot reply to a locked thread.")
let retID = insertID(
db,
crud(crCreate, "post", "author", "ip", "content", "thread", "replyingTo"),
c.userId, c.req.ip, content, $threadId,
if replyingTo.isSome(): $replyingTo.get()
else: nil
)
var retID: int64
if replyingTo.isSome():
retID = insertID(
db,
crud(crCreate, "post", "author", "ip", "content", "thread", "replyingTo"),
c.userId, c.req.ip, content, $threadId, $replyingTo.get()
)
else:
retID = insertID(
db,
crud(crCreate, "post", "author", "ip", "content", "thread"),
c.userId, c.req.ip, content, $threadId
)
discard tryExec(
db,
crud(crCreate, "post_fts", "id", "content"),
@ -491,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).weeks > 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")
@ -521,10 +530,20 @@ proc updatePost(c: TForumData, postId: int, content: string,
if row[0] == $postId:
exec(db, crud(crUpdate, "thread", "name"), subject.get(), threadId)
proc executeNewThread(c: TForumData, subject, msg: string): (int64, int64) =
proc updateThread(c: TForumData, threadId: string, queryKeys: seq[string], queryValues: seq[string]) =
let threadAuthor = selectThreadAuthor(threadId.parseInt)
# Verify that the current user has permissions to edit the specified thread.
let canEdit = c.rank in {Admin, Moderator} or c.userid == threadAuthor.id
if not canEdit:
raise newForumError("You cannot edit this thread")
exec(db, crud(crUpdate, "thread", queryKeys), queryValues)
proc executeNewThread(c: TForumData, subject, msg, categoryID: string): (int64, int64) =
const
query = sql"""
insert into thread(name, views, modified) values (?, 0, DATETIME('now'))
insert into thread(name, views, modified, category) values (?, 0, DATETIME('now'), ?)
"""
assert c.loggedIn()
@ -544,13 +563,18 @@ proc executeNewThread(c: TForumData, subject, msg: string): (int64, int64) =
if msg.len == 0:
raise newForumError("Message is empty", @["msg"])
let catID = getInt(categoryID, -1)
if catID == -1:
raise newForumError("CategoryID is invalid", @["categoryId"])
if not validateRst(c, msg):
raise newForumError("Message needs to be valid RST", @["msg"])
if rateLimitCheck(c):
raise newForumError("You're posting too fast!")
when not defined(skipRateLimitCheck):
if rateLimitCheck(c):
raise newForumError("You're posting too fast!")
result[0] = tryInsertID(db, query, subject).int
result[0] = tryInsertID(db, query, subject, categoryID).int
if result[0] < 0:
raise newForumError("Subject already exists", @["subject"])
@ -609,7 +633,7 @@ proc executeRegister(c: TForumData, name, pass, antibot, userIp,
raise newForumError("Invalid username", @["username"])
if getValue(
db,
sql"select name from person where name = ? and isDeleted = 0",
sql"select name from person where name = ? collate nocase and isDeleted = 0",
name
).len > 0:
raise newForumError("Username already exists", @["username"])
@ -652,6 +676,18 @@ proc executeLike(c: TForumData, postId: int) =
# Save the like.
exec(db, crud(crCreate, "like", "author", "post"), c.userid, postId)
proc executeNewCategory(c: TForumData, name, color, description: string): int64 =
let canAdd = c.rank == Admin
if not canAdd:
raise newForumError("You do not have permissions to add a category.")
if name.len == 0:
raise newForumError("Category name must not be empty!", @["name"])
result = insertID(db, crud(crCreate, "category", "name", "color", "description"), name, color, description)
proc executeUnlike(c: TForumData, postId: int) =
# Verify the post and like exists for the current user.
const likeQuery = sql"""
@ -674,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, postId, c.username)
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.
@ -737,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)
@ -752,26 +798,70 @@ include "main.tmpl"
initialise()
settings:
port = config.port.Port
routes:
get "/categories.json":
# TODO: Limit this query in the case of many many categories
const categoriesQuery =
sql"""
select c.*, count(thread.category)
from category c
left join thread on c.id == thread.category
group by c.id;
"""
var list = CategoryList(categories: @[])
for data in getAllRows(db, categoriesQuery):
let category = Category(
id: data[0].getInt, name: data[1], description: data[2], color: data[3], numTopics: data[4].parseInt
)
list.categories.add(category)
resp $(%list), "application/json"
get "/threads.json":
var
start = getInt(@"start", 0)
count = getInt(@"count", 30)
categoryId = getInt(@"categoryId", -1)
var
categorySection = ""
categoryArgs: seq[string] = @[$start, $count]
countQuery = sql"select count(*) from thread;"
countArgs: seq[string] = @[]
if categoryId != -1:
categorySection = "c.id == ? and "
countQuery = sql"select count(*) from thread t, category c where category == c.id and c.id == ?;"
countArgs.add($categoryId)
categoryArgs.insert($categoryId, 0)
const threadsQuery =
sql"""select t.id, t.name, views, strftime('%s', modified), isLocked,
c.id, c.name, c.description, c.color
from thread t, category c
where isDeleted = 0 and category = c.id
order by modified desc limit ?, ?;"""
"""select t.id, t.name, views, strftime('%s', modified), isLocked, isPinned,
c.id, c.name, c.description, c.color,
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 = (
select p.author from post p
where p.thread = t.id
order by p.author
limit 1
)
order by isPinned desc, modified desc limit ?, ?;"""
let thrCount = getValue(db, sql"select count(*) from thread;").parseInt()
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, threadsQuery, start, count):
let thread = selectThread(data)
for data in getAllRows(db, sql(threadsQuery % categorySection), categoryArgs):
let thread = selectThread(data[0 .. 9], selectUser(data[10 .. ^1]))
list.threads.add(thread)
resp $(%list), "application/json"
@ -786,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)
let thread = selectThread(threadRow)
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
@ -837,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
@ -914,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
@ -940,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(
@ -1011,8 +1111,22 @@ routes:
let session = executeLogin(c, username, password)
setCookie("sid", session)
resp Http200, "{}", "application/json"
except ForumError:
let exc = (ref ForumError)(getCurrentException())
except ForumError as exc:
resp Http400, $(%exc.data), "application/json"
post "/createCategory":
createTFD()
let formData = request.formData
let name = formData["name"].body
let color = formData["color"].body.replace("#", "")
let description = formData["description"].body
try:
let id = executeNewCategory(c, name, color, description)
let category = Category(id: id.int, name: name, color: color, description: description)
resp Http200, $(%category), "application/json"
except ForumError as exc:
resp Http400, $(%exc.data), "application/json"
get "/status.json":
@ -1061,7 +1175,8 @@ routes:
except EParseError:
let err = PostError(
errorFields: @[],
message: getCurrentExceptionMsg()
message: "Message needs to be valid RST! Error: " &
getCurrentExceptionMsg()
)
resp Http400, $(%err), "application/json"
@ -1125,6 +1240,45 @@ routes:
except ForumError as exc:
resp Http400, $(%exc.data), "application/json"
post "/updateThread":
# TODO: Add some way of keeping track of modifications for historical
# purposes
createTFD()
if not c.loggedIn():
let err = PostError(
errorFields: @[],
message: "Not logged in."
)
resp Http401, $(%err), "application/json"
let formData = request.formData
cond "threadId" in formData
let threadId = formData["threadId"].body
# TODO: might want to add more properties here under a tighter permissions
# model
let keys = ["name", "category", "solution"]
# optional parameters
var
queryValues: seq[string] = @[]
queryKeys: seq[string] = @[]
for key in keys:
if key in formData:
queryKeys.add(key)
queryValues.add(formData[key].body)
if queryKeys.len() > 0:
queryValues.add(threadId)
try:
updateThread(c, threadId, queryKeys, queryValues)
resp Http200, "{}", "application/json"
except ForumError as exc:
resp Http400, $(%exc.data), "application/json"
post "/newthread":
createTFD()
if not c.loggedIn():
@ -1137,13 +1291,14 @@ routes:
let formData = request.formData
cond "msg" in formData
cond "subject" in formData
cond "categoryId" in formData
let msg = formData["msg"].body
let subject = formData["subject"].body
# TODO: category
let categoryID = formData["categoryId"].body
try:
let res = executeNewThread(c, subject, msg)
let res = executeNewThread(c, subject, msg, categoryID)
resp Http200, $(%[res[0], res[1]]), "application/json"
except ForumError as exc:
resp Http400, $(%exc.data), "application/json"
@ -1202,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():
@ -1270,8 +1452,7 @@ routes:
try:
await updateProfile(c, username, email, rank)
resp Http200, "{}", "application/json"
except ForumError:
let exc = (ref ForumError)(getCurrentException())
except ForumError as exc:
resp Http400, $(%exc.data), "application/json"
post "/sendResetPassword":
@ -1289,7 +1470,7 @@ routes:
if "g-recaptcha-response" notin formData:
let err = PostError(
errorFields: @[],
message: "Not logged in."
message: "Not logged in and no recaptcha."
)
resp Http401, $(%err), "application/json"
@ -1299,8 +1480,7 @@ routes:
c, formData["email"].body, recaptcha, request.host
)
resp Http200, "{}", "application/json"
except ForumError:
let exc = (ref ForumError)(getCurrentException())
except ForumError as exc:
resp Http400, $(%exc.data), "application/json"
post "/resetPassword":
@ -1338,7 +1518,7 @@ routes:
)
resp Http200, "{}", "application/json"
except ForumError as exc:
resp Http400, $(%exc.data),"application/json"
resp Http400, $(%exc.data), "application/json"
post "/activateEmail":
createTFD()
@ -1359,7 +1539,7 @@ routes:
)
resp Http200, "{}", "application/json"
except ForumError as exc:
resp Http400, $(%exc.data),"application/json"
resp Http400, $(%exc.data), "application/json"
get "/t/@id":
cond "id" in request.params
@ -1379,11 +1559,12 @@ routes:
get "/profile/@username":
cond "username" in request.params
let username = decodeUrl(@"username")
const threadsQuery =
sql"""select name from person where name = ? and isDeleted = 0;"""
let value = getValue(db, threadsQuery, @"username")
if value == @"username":
let value = getValue(db, threadsQuery, username)
if value == username:
pass
else:
redirect uri("/404")
@ -1399,6 +1580,10 @@ routes:
}.newStringTable()
resp content.rstToHtml()
get "/about/rst.html":
let content = readFile("public/rst.rst")
resp content.rstToHtml()
get "/threadActivity.xml":
createTFD()
resp genThreadsRSS(c), "application/atom+xml"
@ -1431,7 +1616,7 @@ routes:
postId: rowFT[2].parseInt(),
postContent: content,
creation: rowFT[4].parseInt(),
author: selectUser(rowFT[5 .. 10]),
author: selectUser(rowFT[5 .. 11]),
)
)
@ -1439,4 +1624,4 @@ routes:
get re"/(.*)":
cond request.matches[0].splitFile.ext == ""
resp karaxHtml
resp karaxHtml

View file

@ -1,11 +1,11 @@
when defined(js):
import sugar, httpcore, options, json
import sugar, httpcore
import dom except Event
include karax/prelude
import karax / [kajax, kdom]
import karax / [kajax]
import error, replybox, threadlist, post
import error
import karaxutils
type

View file

@ -5,7 +5,7 @@ when defined(js):
include karax/prelude
import karax / [kajax, kdom]
import error, replybox, threadlist, post
import error
import karaxutils
type
@ -13,17 +13,12 @@ when defined(js):
loading: bool
status: HttpCode
error: Option[PostError]
newPassword: kstring
proc newActivateEmail*(): ActivateEmail =
ActivateEmail(
status: Http200,
newPassword: ""
status: Http200
)
proc onPassChange(e: Event, n: VNode, state: ActivateEmail) =
state.newPassword = n.value
proc onPost(httpStatus: int, response: kstring, state: ActivateEmail) =
postFinished:
navigateTo(makeUri("/activateEmail/success"))

View file

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

View file

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

View file

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

View file

@ -0,0 +1,135 @@
when defined(js):
import sugar, httpcore, options, json, strutils, algorithm
import dom except Event
include karax/prelude
import karax / [kajax, kdom, vdom]
import error, category, user
import category, karaxutils, addcategorymodal
type
CategoryPicker* = ref object of VComponent
list: Option[CategoryList]
selectedCategoryID*: int
loading: bool
addEnabled: bool
status: HttpCode
error: Option[PostError]
addCategoryModal: AddCategoryModal
onCategoryChange: CategoryChangeEvent
onAddCategory: CategoryEvent
proc onCategoryLoad(state: CategoryPicker): proc (httpStatus: int, response: kstring) =
return
proc (httpStatus: int, response: kstring) =
state.loading = false
state.status = httpStatus.HttpCode
if state.status != Http200: return
let parsed = parseJson($response)
let list = parsed.to(CategoryList)
list.categories.sort(cmpNames)
if state.list.isSome:
state.list.get().categories.add(list.categories)
else:
state.list = some(list)
if state.selectedCategoryID > state.list.get().categories.len():
state.selectedCategoryID = 0
proc loadCategories(state: CategoryPicker) =
if not state.loading:
state.loading = true
ajaxGet(makeUri("categories.json"), @[], onCategoryLoad(state))
proc `[]`*(state: CategoryPicker, id: int): Category =
for cat in state.list.get().categories:
if cat.id == id:
return cat
raise newException(IndexError, "Category at " & $id & " not found!")
let nullAddCategory: CategoryEvent = proc (category: Category) = discard
let nullCategoryChange: CategoryChangeEvent = proc (oldCategory: Category, newCategory: Category) = discard
proc select*(state: CategoryPicker, id: int) =
state.selectedCategoryID = id
state.markDirty()
proc onCategory(state: CategoryPicker): CategoryEvent =
result =
proc (category: Category) =
state.list.get().categories.add(category)
state.list.get().categories.sort(cmpNames)
state.select(category.id)
state.onAddCategory(category)
proc newCategoryPicker*(onCategoryChange=nullCategoryChange, onAddCategory=nullAddCategory): CategoryPicker =
result = CategoryPicker(
list: none[CategoryList](),
selectedCategoryID: 0,
loading: false,
addEnabled: false,
status: Http200,
error: none[PostError](),
onCategoryChange: onCategoryChange,
onAddCategory: onAddCategory
)
let state = result
result.addCategoryModal = newAddCategoryModal(
onAddCategory=onCategory(state)
)
proc setAddEnabled*(state: CategoryPicker, enabled: bool) =
state.addEnabled = enabled
proc onCategoryClick(state: CategoryPicker, category: Category): proc (ev: Event, n: VNode) =
# this is necessary to capture the right value
let cat = category
return
proc (ev: Event, n: VNode) =
let oldCategory = state[state.selectedCategoryID]
state.select(cat.id)
state.onCategoryChange(oldCategory, cat)
proc genAddCategory(state: CategoryPicker): VNode =
result = buildHtml():
tdiv(id="add-category"):
button(class="plus-btn btn btn-link",
onClick=(ev: Event, n: VNode) => (
state.addCategoryModal.setModalShown(true)
)):
italic(class="fas fa-plus")
render(state.addCategoryModal)
proc render*(state: CategoryPicker, currentUser: Option[User], compact=true): VNode =
state.setAddEnabled(currentUser.isAdmin())
if state.status != Http200:
return renderError("Couldn't retrieve categories.", state.status)
if state.list.isNone:
state.loadCategories()
return buildHtml(tdiv(class="loading loading-lg"))
let list = state.list.get().categories
let selectedCategory = state[state.selectedCategoryID]
result = buildHtml():
tdiv(id="category-selection", class="input-group"):
tdiv(class="dropdown"):
a(class="btn btn-link dropdown-toggle", tabindex="0"):
tdiv(class="selected-category d-inline-block"):
render(selectedCategory)
text " "
italic(class="fas fa-caret-down")
ul(class="menu"):
for category in list:
li(class="menu-item"):
a(class="category-" & $category.id & " " & category.name.slug,
onClick=onCategoryClick(state, category)):
render(category, compact)
if state.addEnabled:
genAddCategory(state)

View file

@ -1,6 +1,7 @@
when defined(js):
import sugar, httpcore, options, json
import dom except Event
import jsffi except `&`
include karax/prelude
import karax / [kajax, kdom]
@ -59,7 +60,7 @@ when defined(js):
formData.append("id", $state.post.id)
of DeleteThread:
formData.append("id", $state.thread.id)
ajaxPost(uri, @[], cast[cstring](formData),
ajaxPost(uri, @[], formData.to(cstring),
(s: int, r: kstring) => onDeletePost(s, r, state))
proc onClose(ev: Event, n: VNode, state: DeleteModal) =
@ -94,7 +95,7 @@ when defined(js):
proc render*(state: DeleteModal): VNode =
result = buildHtml():
tdiv(class=class({"active": state.shown}, "modal modal-sm"),
id="login-modal"):
id="delete-modal"):
a(href="", class="modal-overlay", "aria-label"="close",
onClick=(ev: Event, n: VNode) => onClose(ev, n, state))
tdiv(class="modal-container"):
@ -122,11 +123,11 @@ when defined(js):
button(class=class(
{"loading": state.loading},
"btn btn-primary"
"btn btn-primary delete-btn"
),
onClick=(ev: Event, n: VNode) => onDelete(ev, n, state)):
italic(class="fas fa-trash-alt")
text " Delete"
button(class="btn",
button(class="btn cancel-btn",
onClick=(ev: Event, n: VNode) => (state.shown = false)):
text "Cancel"
text "Cancel"

View file

@ -1,5 +1,6 @@
when defined(js):
import httpcore, options, sugar, json
import jsffi except `&`
include karax/prelude
import karax/kajax
@ -54,7 +55,7 @@ when defined(js):
formData.append("postId", $state.post.id)
# TODO: Subject
let uri = makeUri("/updatePost")
ajaxPost(uri, @[], cast[cstring](formData),
ajaxPost(uri, @[], formData.to(cstring),
(s: int, r: kstring) => onEditPost(s, r, state))
proc render*(state: EditBox, post: Post): VNode =
@ -87,7 +88,7 @@ when defined(js):
text state.error.get().message
tdiv(class="edit-buttons"):
tdiv(class="reply-button"):
tdiv(class="cancel-button"):
button(class="btn btn-link",
onClick=(e: Event, n: VNode) => (state.onEditCancel())):
text " Cancel"
@ -95,4 +96,4 @@ when defined(js):
button(class=class({"loading": state.loading}, "btn btn-primary"),
onClick=(e: Event, n: VNode) => state.save()):
italic(class="fas fa-check")
text " Save"
text " Save"

View file

@ -1,13 +1,12 @@
import options, httpcore
import httpcore
type
PostError* = object
errorFields*: seq[string] ## IDs of the fields with an error.
message*: string
when defined(js):
import json
import json, options
include karax/prelude
import karax / [vstyles, kajax, kdom]
import karaxutils
@ -86,8 +85,8 @@ when defined(js):
state.error = some(error)
except:
kout(getCurrentExceptionMsg().cstring)
echo getCurrentExceptionMsg()
state.error = some(PostError(
errorFields: @[],
message: "Unknown error occurred."
))
))

View file

@ -1,15 +1,18 @@
import strformat, times, options, json, tables, sugar, httpcore, uri
from dom import window, Location
import options, tables, sugar, httpcore
from dom import window, Location, document, decodeURI
include karax/prelude
import karax/[kdom]
import jester/[patterns]
import threadlist, postlist, header, profile, newthread, error, about
import categorylist
import resetpassword, activateemail, search
import karaxutils
type
State = ref object
originalTitle: cstring
url: Location
profile: ProfileState
newThread: NewThread
@ -33,6 +36,7 @@ proc copyLocation(loc: Location): Location =
proc newState(): State =
State(
originalTitle: document.title,
url: copyLocation(window.location),
profile: newProfileState(),
newThread: newNewThread(),
@ -47,7 +51,8 @@ proc onPopState(event: dom.Event) =
# This event is usually only called when the user moves back in their
# history. I fire it in karaxutils.anchorCB as well to ensure the URL is
# always updated. This should be moved into Karax in the future.
kout(kstring"New URL: ", window.location.href, " ", state.url.href)
echo "New URL: ", window.location.href, " ", state.url.href
document.title = state.originalTitle
if state.url.href != window.location.href:
state = newState() # Reload the state to remove stale data.
state.url = copyLocation(window.location)
@ -78,13 +83,27 @@ proc render(): VNode =
result = buildHtml(tdiv()):
renderHeader()
route([
r("/categories",
(params: Params) =>
(renderCategoryList(getLoggedInUser()))
),
r("/c/@id",
(params: Params) =>
(renderThreadList(getLoggedInUser(), some(params["id"].parseInt)))
),
r("/newthread",
(params: Params) =>
(render(state.newThread))
(render(state.newThread, getLoggedInUser()))
),
r("/profile/@username",
(params: Params) =>
(render(state.profile, params["username"], getLoggedInUser()))
(
render(
state.profile,
decodeURI(params["username"]),
getLoggedInUser()
)
)
),
r("/t/@id",
(params: Params) =>

View file

@ -1,12 +1,13 @@
import options, times, httpcore, json, sugar
import options, httpcore
import threadlist, user
import user
type
UserStatus* = object
user*: Option[User]
recaptchaSiteKey*: Option[string]
when defined(js):
import times, json, sugar
include karax/prelude
import karax / [kajax, kdom]
@ -31,7 +32,7 @@ when defined(js):
var
state = newState()
proc getStatus(logout: bool=false)
proc getStatus(logout=false)
proc newState(): State =
State(
data: none[UserStatus](),
@ -60,10 +61,10 @@ when defined(js):
state.lastUpdate = getTime()
proc getStatus(logout: bool=false) =
proc getStatus(logout=false) =
if state.loading: return
let diff = getTime() - state.lastUpdate
if diff.minutes < 5:
if diff.inMinutes < 5:
return
state.loading = true
@ -95,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")
@ -113,7 +114,6 @@ when defined(js):
render(state.userMenu, user.get())
# Modals
render(state.loginModal)
if state.data.isSome():
render(state.loginModal, state.data.get().recaptchaSiteKey)
render(state.signupModal, state.data.get().recaptchaSiteKey)

View file

@ -1,4 +1,15 @@
import strutils, options, strformat, parseutils, tables
import strutils, strformat, parseutils, tables
proc limit*(str: string, n: int): string =
## Limit the number of characters in a string. Ends with a elipsis
if str.len > n:
return str[0..<n-3] & "..."
else:
return str
proc slug*(name: string): string =
## Transforms text into a url slug
name.strip().replace(" ", "-").toLowerAscii
proc parseIntSafe*(s: string, value: var int) {.noSideEffect.} =
## parses `s` into an integer in the range `validRange`. If successful,
@ -25,7 +36,7 @@ proc getInt64*(s: string, default = 0): int64 =
when defined(js):
include karax/prelude
import karax / [kdom]
import karax / [kdom, kajax]
from dom import nil
@ -77,24 +88,20 @@ when defined(js):
# Fire the popState event.
dom.dispatchEvent(dom.window, dom.newEvent("popstate"))
proc anchorCB*(e: kdom.Event, n: VNode) = # TODO: Why does this need disamb?
e.preventDefault()
proc anchorCB*(e: Event, n: VNode) =
let mE = e.MouseEvent
if not (mE.metaKey or mE.ctrlKey):
e.preventDefault()
# TODO: Why does Karax have it's own Node type? That's just silly.
let url = n.getAttr("href")
# TODO: Why does Karax have it's own Node type? That's just silly.
let url = n.getAttr("href")
navigateTo(url)
navigateTo(url)
type
FormData* = ref object
proc newFormData*(): FormData
{.importcpp: "new FormData()", constructor.}
proc newFormData*(form: dom.Element): FormData
{.importcpp: "new FormData(@)", constructor.}
proc get*(form: FormData, key: cstring): cstring
{.importcpp: "#.get(@)".}
proc append*(form: FormData, key, val: cstring)
{.importcpp: "#.append(@)".}
proc renderProfileUrl*(username: string): string =
makeUri(fmt"/profile/{username}")
@ -118,4 +125,4 @@ when defined(js):
inc(i) # Skip =
i += query.parseUntil(val, '&', i)
inc(i) # Skip &
result[$decodeUri(key)] = $decodeUri(val)
result[$decodeUri(key)] = $decodeUri(val)

View file

@ -1,6 +1,7 @@
when defined(js):
import sugar, httpcore, options, json
import dom except Event
import dom except Event, KeyboardEvent
import jsffi except `&`
include karax/prelude
import karax / [kajax, kdom]
@ -30,7 +31,7 @@ when defined(js):
let form = dom.document.getElementById("login-form")
# TODO: This is a hack, karax should support this.
let formData = newFormData(form)
ajaxPost(uri, @[], cast[cstring](formData),
ajaxPost(uri, @[], formData.to(cstring),
(s: int, r: kstring) => onLogInPost(s, r, state))
proc onClose(ev: Event, n: VNode, state: LoginModal) =
@ -53,7 +54,7 @@ when defined(js):
if event.key == "Enter":
onLogInClick(e, n, state)
proc render*(state: LoginModal): VNode =
proc render*(state: LoginModal, recaptchaSiteKey: Option[string]): VNode =
result = buildHtml(tdiv()):
tdiv(class=class({"active": state.shown}, "modal modal-sm"),
id="login-modal"):
@ -93,4 +94,4 @@ when defined(js):
(state.onSignUp(); state.shown = false)):
text "Create account"
render(state.resetPasswordModal)
render(state.resetPasswordModal, recaptchaSiteKey)

View file

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

View file

@ -1,12 +1,13 @@
when defined(js):
import sugar, httpcore, options, json
import dom except Event
import jsffi except `&`
include karax/prelude
import karax / [kajax, kdom]
import error, replybox, threadlist, post
import karaxutils
import error, replybox, threadlist, post, user
import karaxutils, categorypicker
type
NewThread* = ref object
@ -14,11 +15,13 @@ when defined(js):
error: Option[PostError]
replyBox: ReplyBox
subject: kstring
categoryPicker: CategoryPicker
proc newNewThread*(): NewThread =
NewThread(
replyBox: newReplyBox(nil),
subject: ""
subject: "",
categoryPicker: newCategoryPicker()
)
proc onSubjectChange(e: Event, n: VNode, state: NewThread) =
@ -37,31 +40,40 @@ when defined(js):
let uri = makeUri("newthread")
# TODO: This is a hack, karax should support this.
let formData = newFormData()
let categoryID = state.categoryPicker.selectedCategoryID
formData.append("subject", state.subject)
formData.append("msg", state.replyBox.getText())
ajaxPost(uri, @[], cast[cstring](formData),
formData.append("categoryId", $categoryID)
ajaxPost(uri, @[], formData.to(cstring),
(s: int, r: kstring) => onCreatePost(s, r, state))
proc render*(state: NewThread): VNode =
proc render*(state: NewThread, currentUser: Option[User]): VNode =
result = buildHtml():
section(class="container grid-xl"):
tdiv(id="new-thread"):
tdiv(class="title"):
p(): text "New Thread"
tdiv(class="content"):
input(class="form-input", `type`="text", name="subject",
input(id="thread-title", class="form-input", `type`="text", name="subject",
placeholder="Type the title here",
oninput=(e: Event, n: VNode) => onSubjectChange(e, n, state))
if state.error.isSome():
p(class="text-error"):
text state.error.get().message
tdiv():
label(class="d-inline-block form-label"):
text "Category"
render(state.categoryPicker, currentUser, compact=false)
renderContent(state.replyBox, none[Thread](), none[Post]())
tdiv(class="footer"):
button(class=class(
{"loading": state.loading},
"btn btn-primary"
button(id="create-thread-btn",
class=class(
{"loading": state.loading},
"btn btn-primary"
),
onClick=(ev: Event, n: VNode) =>
(onCreateClick(ev, n, state))):
text "Create thread"
text "Create thread"

View file

@ -1,6 +1,6 @@
import strformat, options
import options
import user, threadlist
import user
type
PostInfo* = object
@ -32,7 +32,8 @@ proc lastEdit*(post: Post): PostInfo =
post.history[^1]
proc isModerated*(post: Post): bool =
## Determines whether the specified thread is under moderation.
## Determines whether the specified post is under moderation
## (i.e. whether the post is invisible to ordinary users).
post.author.rank <= Moderated
proc isLikedBy*(post: Post, user: Option[User]): bool =
@ -57,10 +58,10 @@ type
email*: Option[string]
when defined(js):
import karaxutils
import karaxutils, threadlist
proc renderPostUrl*(post: Post, thread: Thread): string =
renderPostUrl(thread.id, post.id)
proc renderPostUrl*(link: PostLink): string =
renderPostUrl(link.threadId, link.postId)
renderPostUrl(link.threadId, link.postId)

View file

@ -3,10 +3,11 @@
##
## Used for password reset emails.
import options, httpcore, json, sugar
import options, httpcore, json, sugar, sequtils, strutils
when defined(js):
include karax/prelude
import karax/[kajax, kdom]
import jsffi except `&`
import error, karaxutils, post, user, threadlist
@ -116,7 +117,7 @@ when defined(js):
makeUri("/unlike")
else:
makeUri("/like")
ajaxPost(uri, @[], cast[cstring](formData),
ajaxPost(uri, @[], formData.to(cstring),
(s: int, r: kstring) =>
onPost(s, r, state, post, currentUser.get()))
@ -139,7 +140,8 @@ when defined(js):
onmouseleave=(e: Event, n: VNode) =>
(state.error = none[PostError]())):
if post.likes.len > 0:
span(class="like-count"):
let names = post.likes.map(x => x.name).join(", ")
span(class="like-count tooltip", "data-tooltip"=names):
text $post.likes.len
italic(class=class({"far": not liked, "fas": liked}, "fa-heart"))
@ -171,7 +173,7 @@ when defined(js):
makeUri("/unlock")
else:
makeUri("/lock")
ajaxPost(uri, @[], cast[cstring](formData),
ajaxPost(uri, @[], formData.to(cstring),
(s: int, r: kstring) =>
onPost(s, r, state, thread))
@ -188,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,
@ -199,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

@ -1,6 +1,6 @@
import system except Thread
import options, json, times, httpcore, strformat, sugar, math, strutils
import options, json, times, httpcore, sugar, strutils
import sequtils
import threadlist, category, post, user
@ -14,18 +14,21 @@ type
posts*: seq[Post]
when defined(js):
from dom import nil
from dom import document
import jsffi except `&`
include karax/prelude
import karax / [vstyles, kajax, kdom]
import karax / [kajax, kdom]
import karaxutils, error, replybox, editbox, postbutton, delete
import categorypicker
type
State = ref object
list: Option[PostList]
loading: bool
status: HttpCode
error: Option[PostError]
replyingTo: Option[Post]
replyBox: ReplyBox
editing: Option[Post] ## If in edit mode, this contains the post.
@ -33,8 +36,11 @@ when defined(js):
likeButton: LikeButton
deleteModal: DeleteModal
lockButton: LockButton
pinButton: PinButton
categoryPicker: CategoryPicker
proc onReplyPosted(id: int)
proc onCategoryChanged(oldCategory: Category, newCategory: Category)
proc onEditPosted(id: int, content: string, subject: Option[string])
proc onEditCancelled()
proc onDeletePost(post: Post)
@ -44,17 +50,38 @@ when defined(js):
list: none[PostList](),
loading: false,
status: Http200,
error: none[PostError](),
replyingTo: none[Post](),
replyBox: newReplyBox(onReplyPosted),
editBox: newEditBox(onEditPosted, onEditCancelled),
likeButton: newLikeButton(),
deleteModal: newDeleteModal(onDeletePost, onDeleteThread, nil),
lockButton: newLockButton()
lockButton: newLockButton(),
pinButton: newPinButton(),
categoryPicker: newCategoryPicker(onCategoryChanged)
)
var
state = newState()
proc onCategoryPost(httpStatus: int, response: kstring, state: State) =
state.loading = false
postFinished:
discard
# TODO: show success message
proc onCategoryChanged(oldCategory: Category, newCategory: Category) =
let uri = makeUri("/updateThread")
let formData = newFormData()
formData.append("threadId", $state.list.get().thread.id)
formData.append("category", $newCategory.id)
state.loading = true
ajaxPost(uri, @[], formData.to(cstring),
(s: int, r: kstring) => onCategoryPost(s, r, state))
proc onPostList(httpStatus: int, response: kstring, postId: Option[int]) =
state.loading = false
state.status = httpStatus.HttpCode
@ -65,6 +92,9 @@ when defined(js):
state.list = some(list)
dom.document.title = list.thread.topic & " - " & dom.document.title
state.categoryPicker.select(list.thread.category.id)
# The anchor should be jumped to once all the posts have been loaded.
if postId.isSome():
discard setTimeout(
@ -177,6 +207,20 @@ when defined(js):
span(class="more-post-count"):
text "(" & $post.moreBefore.len & ")"
proc genCategories(thread: Thread, currentUser: Option[User]): VNode =
let loggedIn = currentUser.isSome()
let authoredByUser =
loggedIn and currentUser.get().name == thread.author.name
let canChangeCategory =
loggedIn and currentUser.get().rank in {Admin, Moderator}
result = buildHtml():
tdiv():
if authoredByUser or canChangeCategory:
render(state.categoryPicker, currentUser, compact=false)
else:
render(thread.category)
proc genPostButtons(post: Post, currentUser: Option[User]): Vnode =
let loggedIn = currentUser.isSome()
let authoredByUser =
@ -218,8 +262,11 @@ when defined(js):
): VNode =
let postCopy = post # TODO: Another workaround here, closure capture :(
let originalPost = thread.author == post.author
result = buildHtml():
tdiv(class=class({"highlight": highlight}, "post"), id = $post.id):
tdiv(class=class({"highlight": highlight, "original-post": originalPost}, "post"),
id = $post.id):
tdiv(class="post-icon"):
render(post.author, "post-avatar")
tdiv(class="post-main"):
@ -281,12 +328,12 @@ when defined(js):
]
var diffStr = tmpl[0]
let diff = latestTime - prevPost.info.creation.fromUnix()
if diff.weeks > 48:
let years = diff.weeks div 48
if diff.inWeeks > 48:
let years = diff.inWeeks div 48
diffStr =
(if years == 1: tmpl[1] else: tmpl[2]) % $years
elif diff.weeks > 4:
let months = diff.weeks div 4
elif diff.inWeeks > 4:
let months = diff.inWeeks div 4
diffStr =
(if months == 1: tmpl[3] else: tmpl[4]) % $months
else:
@ -315,15 +362,20 @@ when defined(js):
if postId.isSome():
params.add(("anchor", $postId.get()))
let uri = makeUri("posts.json", params)
ajaxGet(uri, @[], (s: int, r: kstring) => onPostList(s, r, postId))
if not state.loading:
state.loading = true
ajaxGet(uri, @[], (s: int, r: kstring) => onPostList(s, r, postId))
return buildHtml(tdiv(class="loading loading-lg"))
let list = state.list.get()
result = buildHtml():
section(class="container grid-xl"):
tdiv(class="title"):
p(): text list.thread.topic
tdiv(id="thread-title", class="title"):
if state.error.isSome():
span(class="text-error"):
text state.error.get().message
p(class="title-text"): text list.thread.topic
if list.thread.isLocked:
italic(class="fas fa-lock fa-xs",
title="Thread cannot be replied to")
@ -336,7 +388,7 @@ when defined(js):
italic(class="fas fa-check-square fa-xs",
title="Thread has a solution")
text "Solved"
render(list.thread.category)
genCategories(list.thread, currentUser)
tdiv(class="posts"):
var prevPost: Option[Post] = none[Post]()
for i, post in list.posts:
@ -361,7 +413,8 @@ 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)
render(state.deleteModal)
render(state.deleteModal)

View file

@ -1,11 +1,12 @@
import options, httpcore, json, sugar, times, strformat, strutils
import options, httpcore, json, sugar, times, strutils
import threadlist, post, category, error, user
import threadlist, post, error, user
when defined(js):
from dom import document
include karax/prelude
import karax/[kajax, kdom]
import karaxutils, postbutton, delete, profilesettings
import karaxutils, profilesettings
type
ProfileTab* = enum
@ -37,6 +38,8 @@ when defined(js):
state.profile = some(profile)
state.settings = some(newProfileSettings(profile))
dom.document.title = profile.user.name & " - " & dom.document.title
proc genPostLink(link: PostLink): VNode =
let url = renderPostUrl(link)
result = buildHtml():
@ -53,9 +56,10 @@ when defined(js):
proc render*(
state: ProfileState,
username: string,
username: kstring,
currentUser: Option[User]
): VNode =
if state.profile.isSome() and state.profile.get().user.name != username:
state.profile = none[Profile]()
state.status = Http200
@ -64,8 +68,10 @@ when defined(js):
return renderError("Couldn't retrieve profile.", state.status)
if state.profile.isNone:
let uri = makeUri("profile.json", ("username", username))
ajaxGet(uri, @[], (s: int, r: kstring) => onProfile(s, r, state))
if not state.loading:
state.loading = true
let uri = makeUri("profile.json", ("username", $username))
ajaxGet(uri, @[], (s: int, r: kstring) => onProfile(s, r, state))
return buildHtml(tdiv(class="loading loading-lg"))
@ -113,7 +119,7 @@ when defined(js):
),
onClick=(e: Event, n: VNode) => (state.currentTab = Overview)
):
a(class="c-hand"):
a(id="overview-tab", class="c-hand"):
text "Overview"
li(class=class(
{"active": state.currentTab == Settings},
@ -121,7 +127,7 @@ when defined(js):
),
onClick=(e: Event, n: VNode) => (state.currentTab = Settings)
):
a(class="c-hand"):
a(id="settings-tab", class="c-hand"):
italic(class="fas fa-cog")
text " Settings"
@ -141,4 +147,4 @@ when defined(js):
genPostLink(thread)
of Settings:
if state.settings.isSome():
render(state.settings.get(), currentUser)
render(state.settings.get(), currentUser)

View file

@ -1,10 +1,11 @@
when defined(js):
import httpcore, options, sugar, json, strutils, strformat
import jsffi except `&`
include karax/prelude
import karax/[kajax, kdom]
import replybox, post, karaxutils, postbutton, error, delete, user
import post, karaxutils, postbutton, error, delete, user
type
ProfileSettings* = ref object
@ -68,7 +69,7 @@ when defined(js):
formData.append("rank", $state.rank)
formData.append("username", $state.profile.user.name)
let uri = makeUri("/saveProfile")
ajaxPost(uri, @[], cast[cstring](formData),
ajaxPost(uri, @[], formData.to(cstring),
(s: int, r: kstring) => onProfilePost(s, r, state))
proc needsSave(state: ProfileSettings): bool =
@ -88,7 +89,7 @@ when defined(js):
class="form-select", value = $state.rank,
onchange=(e: Event, n: VNode) => onRankChange(e, n, state)):
for r in Rank:
option(text $r)
option(text $r, id="rank-" & toLowerAscii($r))
p(class="form-input-hint text-warning"):
text "You can modify anyone's rank. Remember: with " &
"great power comes great responsibility."
@ -165,7 +166,8 @@ when defined(js):
label(class="form-label"):
text "Account"
tdiv(class="col-9 col-sm-12"):
button(class="btn btn-secondary", `type`="button",
button(id="delete-account-btn",
class="btn btn-secondary", `type`="button",
onClick=(e: Event, n: VNode) =>
(state.deleteModal.show(state.profile.user))):
italic(class="fas fa-times")
@ -176,16 +178,19 @@ when defined(js):
span(class="text-error"):
text state.error.get().message
button(class=class(
button(id="cancel-btn",
class=class(
{"disabled": not needsSave(state)}, "btn btn-link"
),
onClick=(e: Event, n: VNode) => (resetSettings(state))):
text "Cancel"
button(class=class(
button(id="save-btn",
class=class(
{"disabled": not needsSave(state)}, "btn btn-primary"
),
onClick=(e: Event, n: VNode) => save(state)):
onClick=(e: Event, n: VNode) => save(state),
id="save-btn"):
italic(class="fas fa-save")
text " Save"
@ -198,4 +203,4 @@ when defined(js):
rankField.setInputText($state.rank)
let emailField = getVNodeById("email-field")
if not emailField.isNil:
emailField.setInputText($state.email)
emailField.setInputText($state.email)

View file

@ -1,5 +1,6 @@
when defined(js):
import strformat, options, httpcore, json, sugar
import jsffi except `&`
from dom import getElementById, scrollIntoView, setTimeout
@ -26,7 +27,7 @@ when defined(js):
proc performScroll() =
let replyBox = dom.document.getElementById("reply-box")
replyBox.scrollIntoView(false)
replyBox.scrollIntoView()
proc show*(state: ReplyBox) =
# Scroll to the reply box.
@ -44,7 +45,7 @@ when defined(js):
proc onPreviewPost(httpStatus: int, response: kstring, state: ReplyBox) =
postFinished:
kout(response)
echo response
state.rendering = some[kstring](response)
proc onPreviewClick(e: Event, n: VNode, state: ReplyBox) =
@ -56,7 +57,7 @@ when defined(js):
let formData = newFormData()
formData.append("msg", state.text)
let uri = makeUri("/preview")
ajaxPost(uri, @[], cast[cstring](formData),
ajaxPost(uri, @[], formData.to(cstring),
(s: int, r: kstring) => onPreviewPost(s, r, state))
proc onMessageClick(e: Event, n: VNode, state: ReplyBox) =
@ -80,7 +81,7 @@ when defined(js):
if replyingTo.isSome:
formData.append("replyingTo", $replyingTo.get().id)
let uri = makeUri("/createPost")
ajaxPost(uri, @[], cast[cstring](formData),
ajaxPost(uri, @[], formData.to(cstring),
(s: int, r: kstring) => onReplyPost(s, r, state))
proc onCancelClick(e: Event, n: VNode, state: ReplyBox) =
@ -88,9 +89,8 @@ when defined(js):
state.shown = false
proc onChange(e: Event, n: VNode, state: ReplyBox) =
# TODO: There should be a karax-way to do this. I guess I can just call
# `value` on the node? We need to document this better :)
state.text = cast[dom.TextAreaElement](n.dom).value
# TODO: Please document this better in Karax.
state.text = n.value
proc renderContent*(state: ReplyBox, thread: Option[Thread],
post: Option[Post]): VNode =
@ -115,10 +115,13 @@ when defined(js):
elif state.rendering.isSome():
verbatim(state.rendering.get())
else:
textarea(class="form-input post-text-area", rows="5",
textarea(id="reply-textarea",
class="form-input post-text-area", rows="5",
onChange=(e: Event, n: VNode) =>
onChange(e, n, state),
value=state.text)
a(href=makeUri("/about/rst"), target="blank_"):
text "Styling with RST is supported"
if state.error.isSome():
span(class="text-error",
@ -161,4 +164,4 @@ when defined(js):
button(class="btn"):
italic(class="fas fa-arrow-up")
tdiv(class="information-content"):
renderContent(state, some(thread), post)
renderContent(state, some(thread), post)

View file

@ -1,11 +1,12 @@
when defined(js):
import sugar, httpcore, options, json
import dom except Event
import dom except Event, KeyboardEvent
import jsffi except `&`
include karax/prelude
import karax / [kajax, kdom]
import error, replybox, threadlist, post
import error
import karaxutils
type
@ -86,9 +87,11 @@ when defined(js):
let form = dom.document.getElementById("resetpassword-form")
# TODO: This is a hack, karax should support this.
let formData = newFormData(form)
ajaxPost(uri, @[], cast[cstring](formData),
ajaxPost(uri, @[], formData.to(cstring),
(s: int, r: kstring) => onPost(s, r, state))
ev.preventDefault()
proc onClose(ev: Event, n: VNode, state: ResetPasswordModal) =
state.shown = false
ev.preventDefault()
@ -106,7 +109,8 @@ when defined(js):
if event.key == "Enter":
onClick(e, n, state)
proc render*(state: ResetPasswordModal): VNode =
proc render*(state: ResetPasswordModal,
recaptchaSiteKey: Option[string]): VNode =
result = buildHtml():
tdiv(class=class({"active": state.shown}, "modal"),
id="resetpassword-modal"):
@ -132,6 +136,11 @@ when defined(js):
true,
placeholder="Username or email"
)
if recaptchaSiteKey.isSome:
tdiv(id="recaptcha"):
tdiv(class="g-recaptcha",
"data-sitekey"=recaptchaSiteKey.get())
script(src="https://www.google.com/recaptcha/api.js")
tdiv(class="modal-footer"):
if state.sent:
span(class="text-success"):
@ -142,5 +151,6 @@ when defined(js):
{"loading": state.loading},
"btn btn-primary"
),
`type`="button",
onClick=(ev: Event, n: VNode) => onClick(ev, n, state)):
text "Reset password"
text "Reset password"

View file

@ -20,7 +20,7 @@ when defined(js):
from dom import nil
include karax/prelude
import karax / [vstyles, kajax, kdom]
import karax / [kajax]
import karaxutils, error, threadlist, sugar

View file

@ -1,6 +1,7 @@
when defined(js):
import sugar, httpcore, options, json
import dom except Event
import jsffi except `&`
include karax/prelude
import karax / [kajax, kdom]
@ -28,7 +29,7 @@ when defined(js):
let form = dom.document.getElementById("signup-form")
# TODO: This is a hack, karax should support this.
let formData = newFormData(form)
ajaxPost(uri, @[], cast[cstring](formData),
ajaxPost(uri, @[], formData.to(cstring),
(s: int, r: kstring) => onSignUpPost(s, r, state))
proc onClose(ev: Event, n: VNode, state: SignupModal) =
@ -78,18 +79,19 @@ when defined(js):
"data-sitekey"=recaptchaSiteKey.get())
script(src="https://www.google.com/recaptcha/api.js")
tdiv(class="modal-footer"):
button(class=class({"loading": state.loading}, "btn btn-primary"),
onClick=(ev: Event, n: VNode) => onSignUpClick(ev, n, state)):
button(class=class({"loading": state.loading},
"btn btn-primary create-account-btn"),
onClick=(ev: Event, n: VNode) => onSignUpClick(ev, n, state)):
text "Create account"
button(class="btn",
button(class="btn login-btn",
onClick=(ev: Event, n: VNode) =>
(state.onLogIn(); state.shown = false)):
text "Log in"
p(class="license-text text-gray"):
text "By registering, you agree to the "
a(href=makeUri("/about/license"),
a(id="license", href=makeUri("/about/license"),
onClick=(ev: Event, n: VNode) =>
(state.shown = false; anchorCB(ev, n))):
text "content license"
text "."
text "."

View file

@ -15,6 +15,7 @@ type
creation*: int64 ## Unix timestamp
isLocked*: bool
isSolved*: bool
isPinned*: bool
ThreadList* = ref object
threads*: seq[Thread]
@ -22,29 +23,38 @@ type
proc isModerated*(thread: Thread): bool =
## Determines whether the specified thread is under moderation.
## (i.e. whether the specified thread is invisible to ordinary users).
thread.author.rank <= Moderated
when defined(js):
import sugar
include karax/prelude
import karax / [vstyles, kajax, kdom]
import karaxutils, error, user
import karaxutils, error, user, mainbuttons
type
State = ref object
list: Option[ThreadList]
refreshList: bool
loading: bool
status: HttpCode
mainButtons: MainButtons
var state: State
proc newState(): State =
State(
list: none[ThreadList](),
loading: false,
status: Http200
status: Http200,
mainButtons: newMainButtons(
onCategoryChange =
(oldCategory: Category, newCategory: Category) => (state.list = none[ThreadList]())
)
)
var
state = newState()
state = newState()
proc visibleTo*[T](thread: T, user: Option[User]): bool =
## Determines whether the specified thread (or post) should be
@ -53,38 +63,19 @@ when defined(js):
##
## The rules for this are determined by the rank of the user, their
## settings (TODO), and whether the thread's creator is moderated or not.
##
## The ``user`` argument refers to the currently logged in user.
mixin isModerated
if user.isNone(): return not thread.isModerated
let rank = user.get().rank
if rank < Moderator and thread.isModerated:
if rank < Rank.Moderator and thread.isModerated:
return thread.author == user.get()
return true
proc genTopButtons(currentUser: Option[User]): VNode =
result = buildHtml():
section(class="navbar container grid-xl", id="main-buttons"):
section(class="navbar-section"):
tdiv(class="dropdown"):
a(href="#", class="btn dropdown-toggle"):
text "Filter "
italic(class="fas fa-caret-down")
ul(class="menu"):
li: text "community"
li: text "dev"
button(class="btn btn-primary"): text "Latest"
button(class="btn btn-link"): text "Most Active"
button(class="btn btn-link"): text "Categories"
section(class="navbar-section"):
if currentUser.isSome():
a(href=makeUri("/newthread"), onClick=anchorCB):
button(class="btn btn-secondary"):
italic(class="fas fa-plus")
text " New Thread"
proc genUserAvatars(users: seq[User]): VNode =
result = buildHtml(td):
result = buildHtml(td(class="thread-users")):
for user in users:
render(user, "avatar avatar-sm", showStatus=true)
text " "
@ -95,44 +86,48 @@ when defined(js):
let duration = currentTime - activityTime
if currentTime.local().year != activityTime.local().year:
return activityTime.local().format("MMM yyyy")
elif duration.days > 30 and duration.days < 300:
elif duration.inDays > 30 and duration.inDays < 300:
return activityTime.local().format("MMM dd")
elif duration.days != 0:
return $duration.days & "d"
elif duration.hours != 0:
return $duration.hours & "h"
elif duration.minutes != 0:
return $duration.minutes & "m"
elif duration.inDays != 0:
return $duration.inDays & "d"
elif duration.inHours != 0:
return $duration.inHours & "h"
elif duration.inMinutes != 0:
return $duration.inMinutes & "m"
else:
return $duration.seconds & "s"
return $duration.inSeconds & "s"
proc genThread(thread: Thread, isNew: bool, noBorder: bool): VNode =
let isOld = (getTime() - thread.creation.fromUnix).weeks > 2
let isBanned = thread.author.rank < Moderated
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")
if thread.isModerated:
if isBanned:
italic(class="fas fa-ban fa-xs",
title="Thread author is banned")
else:
italic(class="fas fa-eye-slash fa-xs",
title="Thread is moderated")
italic(class="fas fa-eye-slash fa-xs",
title="Thread is moderated")
if thread.isSolved:
italic(class="fas fa-check-square fa-xs",
title="Thread has a solution")
a(href=makeUri("/t/" & $thread.id),
onClick=anchorCB):
a(href=makeUri("/t/" & $thread.id), onClick=anchorCB):
text thread.topic
td():
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(): text $thread.replies
td(class=class({
td(class="thread-replies"): text $thread.replies
td(class="hide-sm" & class({
"views-text": thread.views < 999,
"popular-text": thread.views > 999 and thread.views < 5000,
"super-popular-text": thread.views > 5000
@ -166,10 +161,13 @@ when defined(js):
else:
state.list = some(list)
proc onLoadMore(ev: Event, n: VNode) =
proc onLoadMore(ev: Event, n: VNode, categoryId: Option[int]) =
state.loading = true
let start = state.list.get().threads.len
ajaxGet(makeUri("threads.json?start=" & $start), @[], onThreadList)
if categoryId.isSome:
ajaxGet(makeUri("threads.json?start=" & $start & "&categoryId=" & $categoryId.get()), @[], onThreadList)
else:
ajaxGet(makeUri("threads.json?start=" & $start), @[], onThreadList)
proc getInfo(
list: seq[Thread], i: int, currentUser: Option[User]
@ -194,29 +192,34 @@ when defined(js):
isNew: thread.creation > previousVisitAt
)
proc genThreadList(currentUser: Option[User]): VNode =
proc genThreadList(currentUser: Option[User], categoryId: Option[int]): VNode =
if state.status != Http200:
return renderError("Couldn't retrieve threads.", state.status)
if state.list.isNone:
if not state.loading:
state.loading = true
ajaxGet(makeUri("threads.json"), @[], onThreadList)
if categoryId.isSome:
ajaxGet(makeUri("threads.json?categoryId=" & $categoryId.get()), @[], onThreadList)
else:
ajaxGet(makeUri("threads.json"), @[], onThreadList)
return buildHtml(tdiv(class="loading loading-lg"))
let displayCategory = categoryId.isNone
let list = state.list.get()
result = buildHtml():
section(class="container grid-xl"): # TODO: Rename to `.thread-list`.
section(class="thread-list"):
table(class="table", id="threads-list"):
thead():
tr:
th(text "Topic")
th(text "Category")
th(style=style((StyleAttr.width, kstring"8rem"))): text "Users"
th(text "Replies")
th(text "Views")
th(text "Activity")
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"
tbody():
for i in 0 ..< list.threads.len:
let thread = list.threads[i]
@ -224,9 +227,10 @@ when defined(js):
let isLastThread = i+1 == list.threads.len
let (isLastUnseen, isNew) = getInfo(list.threads, i, currentUser)
genThread(thread, isNew,
noBorder=isLastUnseen or isLastThread)
if isLastUnseen:
genThread(i+1, thread, isNew,
noBorder=isLastUnseen or isLastThread,
displayCategory=displayCategory)
if isLastUnseen and (not isLastThread):
tr(class="last-visit-separator"):
td(colspan="6"):
span(text "last visit")
@ -237,10 +241,11 @@ when defined(js):
td(colspan="6"):
tdiv(class="loading loading-lg")
else:
td(colspan="6", onClick=onLoadMore):
td(colspan="6",
onClick = (ev: Event, n: VNode) => (onLoadMore(ev, n, categoryId))):
span(text "load more threads")
proc renderThreadList*(currentUser: Option[User]): VNode =
proc renderThreadList*(currentUser: Option[User], categoryId = none(int)): VNode =
result = buildHtml(tdiv):
genTopButtons(currentUser)
genThreadList(currentUser)
state.mainButtons.render(currentUser, categoryId=categoryId)
genThreadList(currentUser, categoryId)

View file

@ -1,12 +1,13 @@
import times
import times, options
type
# If you add more "Banned" states, be sure to modify forum's threadsQuery too.
Rank* {.pure.} = enum ## serialized as 'status'
Spammer ## spammer: every post is invisible
Troll ## troll: cannot write new posts
Banned ## A non-specific ban
Moderated ## new member: posts manually reviewed before everybody
## can see them
Troll ## troll: cannot write new posts
Banned ## A non-specific ban
EmailUnconfirmed ## member with unconfirmed email address. Their posts
## are visible, but cannot make new posts. This is so that
## when a user with existing posts changes their email,
@ -16,6 +17,7 @@ type
Admin ## Admin: can do everything
User* = object
id*: string
name*: string
avatarUrl*: string
lastOnline*: int64
@ -26,6 +28,9 @@ type
proc isOnline*(user: User): bool =
return getTime().toUnix() - user.lastOnline < (60*5)
proc isAdmin*(user: Option[User]): bool =
return user.isSome and user.get().rank == Admin
proc `==`*(u1, u2: User): bool =
u1.name == u2.name
@ -33,6 +38,9 @@ proc canPost*(rank: Rank): bool =
## Determines whether the specified rank can make new posts.
rank >= Rank.User or rank == Moderated
proc isBanned*(rank: Rank): bool =
rank in {Spammer, Troll, Banned}
when defined(js):
include karax/prelude
import karaxutils
@ -68,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

@ -24,7 +24,7 @@ when defined(js):
proc render*(state: UserMenu, user: User): VNode =
result = buildHtml():
tdiv():
tdiv(id="profile-btn"):
figure(class="avatar c-hand",
onClick=(e: Event, n: VNode) => onClick(e, n, state)):
img(src=user.avatarUrl, title=user.name)
@ -52,13 +52,15 @@ when defined(js):
tdiv(class="tile-icon"):
img(class="avatar", src=user.avatarUrl,
title=user.name)
tdiv(class="tile-content"):
tdiv(id="profile-name", class="tile-content"):
text user.name
li(class="divider")
li(class="menu-item"):
a(href=makeUri("/profile/" & user.name)):
a(id="myprofile-btn",
href=makeUri("/profile/" & user.name)):
text "My profile"
li(class="menu-item c-hand"):
a(onClick = (e: Event, n: VNode) =>
a(id="logout-btn",
onClick = (e: Event, n: VNode) =>
(state.shown=false; state.onLogout())):
text "Logout"
text "Logout"

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

@ -7,7 +7,7 @@
#
# Script to initialise the nimforum.
import strutils, db_sqlite, os, times, json, options
import strutils, db_sqlite, os, times, json, options, terminal
import auth, frontend/user
@ -22,10 +22,25 @@ proc backup(path: string, contents: Option[string]=none[string]()) =
echo(path, " already exists. Moving to ", backupPath)
moveFile(path, backupPath)
proc createUser(db: DbConn, user: tuple[username, password, email: string],
rank: Rank) =
assert user.username.len != 0
let salt = makeSalt()
let password = makePassword(user.password, salt)
exec(db, sql"""
INSERT INTO person(name, password, email, salt, status, lastOnline)
VALUES (?, ?, ?, ?, ?, DATETIME('now'))
""", user.username, password, user.email, salt, $rank)
proc initialiseDb(admin: tuple[username, password, email: string],
filename="nimforum.db") =
let path = getCurrentDir() / filename
if "-dev" notin filename and "-test" notin filename:
let
path = getCurrentDir() / filename
isTest = "-test" in filename
isDev = "-dev" in filename
if not isDev and not isTest:
backup(path)
removeFile(path)
@ -51,7 +66,7 @@ proc initialiseDb(admin: tuple[username, password, email: string],
db.exec(sql"""
insert into category (id, name, description, color)
values (0, 'Default', '', '');
values (0, 'Unsorted', 'No category has been chosen yet.', '');
""")
# -- Thread
@ -66,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)
@ -98,13 +114,24 @@ proc initialiseDb(admin: tuple[username, password, email: string],
db.exec sql"create index PersonStatusIdx on person(status);"
# Create default user.
if admin.username.len != 0:
let salt = makeSalt()
let password = makePassword(admin.password, salt)
db.createUser(admin, Admin)
# Create some test data for development
if isTest or isDev:
for rank in Spammer..Moderator:
let rankLower = toLowerAscii($rank)
let user = (username: $rankLower,
password: $rankLower,
email: $rankLower & "@localhost.local")
db.createUser(user, rank)
db.exec(sql"""
insert into person (id, name, password, email, salt, status)
values (1, ?, ?, ?, ?, ?);
""", admin.username, password, admin.email, salt, $Admin)
insert into category (name, description, color)
values ('Libraries', 'Libraries and library development', '0198E1'),
('Announcements', 'Announcements by Nim core devs', 'FFEB3B'),
('Fun', 'Posts that are just for fun', '00897B'),
('Potential Issues', 'Potential Nim compiler issues', 'E53935');
""")
# -- Post
@ -208,9 +235,10 @@ proc initialiseDb(admin: tuple[username, password, email: string],
proc initialiseConfig(
name, title, hostname: string,
recaptcha: tuple[siteKey, secretKey: string],
smtp: tuple[address, user, password: string],
smtp: tuple[address, user, password, fromAddr: string, tls: bool],
isDev: bool,
dbPath: string
dbPath: string,
ga: string=""
) =
let path = getCurrentDir() / "forum.json"
@ -223,9 +251,13 @@ proc initialiseConfig(
"smtpAddress": %smtp.address,
"smtpUser": %smtp.user,
"smtpPassword": %smtp.password,
"smtpFromAddr": %smtp.fromAddr,
"smtpTls": %smtp.tls,
"isDev": %isDev,
"dbPath": %dbPath
}
if ga.len > 0:
j["ga"] = %ga
backup(path, some(pretty(j)))
writeFile(path, pretty(j))
@ -235,6 +267,69 @@ proc question(q: string): string =
stdout.write(q)
result = stdin.readLine()
proc setup() =
echo("""
Welcome to the NimForum setup script. Please answer the following questions.
These can be changed later in the generated forum.json file.
""")
let name = question("Forum full name: ")
let title = question("Forum short name: ")
let hostname = question("Forum hostname: ")
let adminUser = question("Admin username: ")
let adminPass = readPasswordFromStdin("Admin password: ")
let adminEmail = question("Admin email: ")
echo("")
echo("The following question are related to recaptcha. \nYou must set up a " &
"recaptcha v2 for your forum before answering them. \nPlease do so now " &
"and then answer these questions: https://www.google.com/recaptcha/admin")
let recaptchaSiteKey = question("Recaptcha site key: ")
let recaptchaSecretKey = question("Recaptcha secret key: ")
echo("The following questions are related to smtp. You must set up a \n" &
"mailing server for your forum or use an external service.")
let smtpAddress = question("SMTP address (eg: mail.hostname.com): ")
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.")
stdout.write("Google Analytics (eg: UA-12345678-1): ")
let ga = stdin.readLine().strip()
let dbPath = "nimforum.db"
initialiseConfig(
name, title, hostname, (recaptchaSiteKey, recaptchaSecretKey),
(smtpAddress, smtpUser, smtpPassword, smtpFromAddr, smtpTls), isDev=false,
dbPath, ga
)
initialiseDb(
admin=(adminUser, adminPass, adminEmail),
dbPath
)
echo("Setup complete!")
proc echoHelp() =
quit("""
Usage: setup_nimforum opts
Options:
--setup Performs first time setup for end users.
Development options:
--dev Creates a new development DB and config.
--test Creates a new test DB and config.
--blank Creates a new blank DB.
""")
when isMainModule:
if paramCount() > 0:
case paramStr(1)
@ -246,7 +341,7 @@ when isMainModule:
"Development Forum",
"localhost",
recaptcha=("", ""),
smtp=("", "", ""),
smtp=("", "", "", "", false),
isDev=true,
dbPath
)
@ -263,7 +358,7 @@ when isMainModule:
"Test Forum",
"localhost",
recaptcha=("", ""),
smtp=("", "", ""),
smtp=("", "", "", "", false),
isDev=true,
dbPath
)
@ -279,5 +374,11 @@ when isMainModule:
admin=("", "", ""),
dbPath
)
of "--setup":
setup()
else:
quit("--dev|--test|--prod")
echoHelp()
else:
echoHelp()

View file

@ -1,26 +1,24 @@
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
proc `%`*[T](opt: Option[T]): JsonNode =
## Generic constructor for JSON data. Creates a new ``JNull JsonNode``
## if ``opt`` is empty, otherwise it delegates to the underlying value.
if opt.isSome: %opt.get else: newJNull()
type
Config* = object
smtpAddress*: string
smtpPort*: int
smtpUser*: string
smtpPassword*: string
smtpFromAddr*: string
smtpTls*: bool
smtpSsl*: bool
mlistAddress*: string
recaptchaSecretKey*: string
recaptchaSiteKey*: string
@ -28,7 +26,8 @@ type
dbPath*: string
hostname*: string
name*, title*: string
scripts*: string
ga*: string
port*: int
ForumError* = object of Exception
data*: PostError
@ -54,9 +53,12 @@ proc loadConfig*(filename = getCurrentDir() / "forum.json"): Config =
smtpPassword: "", mlistAddress: "")
let root = parseFile(filename)
result.smtpAddress = root{"smtpAddress"}.getStr("")
result.smtpPort = root{"smtpPort"}.getNum(25).int
result.smtpPort = root{"smtpPort"}.getInt(25)
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("")
@ -65,7 +67,8 @@ proc loadConfig*(filename = getCurrentDir() / "forum.json"): Config =
result.hostname = root["hostname"].getStr()
result.name = root["name"].getStr()
result.title = root["title"].getStr()
result.scripts = root{"scripts"}.getStr()
result.ga = root{"ga"}.getStr()
result.port = root{"port"}.getInt(5000)
proc processGT(n: XmlNode, tag: string): (int, XmlNode, string) =
result = (0, newElement(tag), tag)
@ -160,7 +163,7 @@ proc processMentions(node: XmlNode): XmlNode =
result.add(child)
of xnElement:
case node.tag
of "pre", "code", "tt":
of "pre", "code", "tt", "a":
return node
else:
result = newElement(node.tag)

View file

@ -23,7 +23,7 @@ const baseUrl = "http://localhost:" & $port & "/"
template withBackend(body: untyped): untyped =
## Starts a new backend instance.
spawn runProcess("nimble -y runbackend")
spawn runProcess("nimble -y testbackend")
defer:
discard execCmd("killall " & backend)
@ -43,9 +43,11 @@ template withBackend(body: untyped): untyped =
body
import browsertests/scenario1
import browsertests/[scenario1, threads, issue181, categories]
when isMainModule:
proc main() =
# Kill any already running instances
discard execCmd("killall geckodriver")
spawn runProcess("geckodriver -p 4444 --log config")
defer:
discard execCmd("killall geckodriver")
@ -63,8 +65,14 @@ when isMainModule:
withBackend:
scenario1.test(session, baseUrl)
threads.test(session, baseUrl)
categories.test(session, baseUrl)
issue181.test(session, baseUrl)
session.close()
except:
sleep(10000) # See if we can grab any more output.
raise
raise
when isMainModule:
main()

View file

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

View file

@ -0,0 +1,214 @@
import unittest, common
import webdriver
import karaxutils
proc selectCategory(session: Session, name: string) =
with session:
click "#category-selection .dropdown-toggle"
click "#category-selection ." & name
proc createCategory(session: Session, baseUrl, name, color, description: string) =
with session:
navigate baseUrl
click "#categories-btn"
ensureExists "#add-category"
click "#add-category .plus-btn"
clear "#add-category input[name='name']"
clear "#add-category input[name='description']"
sendKeys "#add-category input[name='name']", name
setColor "#add-category input[name='color']", color
sendKeys "#add-category input[name='description']", description
click "#add-category #add-category-btn"
checkText "#category-" & name.slug(), name
proc categoriesUserTests(session: Session, baseUrl: string) =
let
title = "Category Test"
content = "Choosing category test"
suite "user tests":
with session:
navigate baseUrl
login "user", "user"
setup:
with session:
navigate baseUrl
test "no category add available":
with session:
click "#new-thread-btn"
checkIsNone "#add-category"
test "no category add available category page":
with session:
click "#categories-btn"
checkIsNone "#add-category"
test "can create category thread":
with session:
click "#new-thread-btn"
sendKeys "#thread-title", title
selectCategory "fun"
sendKeys "#reply-textarea", content
click "#create-thread-btn"
checkText "#thread-title .category", "Fun"
navigate baseUrl
ensureExists title, LinkTextSelector
test "can create category thread and change category":
with session:
let newTitle = title & " Selection"
click "#new-thread-btn"
sendKeys "#thread-title", newTitle
selectCategory "fun"
sendKeys "#reply-textarea", content
click "#create-thread-btn"
checkText "#thread-title .category", "Fun"
selectCategory "announcements"
checkText "#thread-title .category", "Announcements"
# Make sure there is no error
checkIsNone "#thread-title .text-error"
navigate baseUrl
ensureExists newTitle, LinkTextSelector
test "can navigate to categories page":
with session:
click "#categories-btn"
ensureExists "#categories-list"
test "can view post under category":
with session:
# create a few threads
click "#new-thread-btn"
sendKeys "#thread-title", "Post 1"
selectCategory "fun"
sendKeys "#reply-textarea", "Post 1"
click "#create-thread-btn"
navigate baseUrl
click "#new-thread-btn"
sendKeys "#thread-title", "Post 2"
selectCategory "announcements"
sendKeys "#reply-textarea", "Post 2"
click "#create-thread-btn"
navigate baseUrl
click "#new-thread-btn"
sendKeys "#thread-title", "Post 3"
selectCategory "unsorted"
sendKeys "#reply-textarea", "Post 3"
click "#create-thread-btn"
navigate baseUrl
click "#categories-btn"
ensureExists "#categories-list"
click "#category-unsorted"
checkText "#threads-list .thread-title a", "Post 3"
for element in session.waitForElements("#threads-list .category-name"):
# Have to user "innerText" because elements are hidden on this page
assert element.getProperty("innerText") == "Unsorted"
selectCategory "announcements"
checkText "#threads-list .thread-title a", "Post 2"
for element in session.waitForElements("#threads-list .category-name"):
assert element.getProperty("innerText") == "Announcements"
selectCategory "fun"
checkText "#threads-list .thread-title a", "Post 1"
for element in session.waitForElements("#threads-list .category-name"):
assert element.getProperty("innerText") == "Fun"
session.logout()
proc categoriesAdminTests(session: Session, baseUrl: string) =
suite "admin tests":
with session:
navigate baseUrl
login "admin", "admin"
test "can create category via dropdown":
let
name = "Category Test"
color = "#720904"
description = "This is a description"
with session:
click "#new-thread-btn"
ensureExists "#add-category"
click "#add-category .plus-btn"
clear "#add-category input[name='name']"
clear "#add-category input[name='description']"
sendKeys "#add-category input[name='name']", name
setColor "#add-category input[name='color']", color
sendKeys "#add-category input[name='description']", description
click "#add-category #add-category-btn"
checkText "#category-selection .selected-category", name
test "can create category on category page":
let
name = "Category Test Page"
color = "#70B4D4"
description = "This is a description on category page"
with session:
createCategory baseUrl, name, color, description
test "category adding disabled on admin logout":
with session:
navigate(baseUrl & "c/0")
ensureExists "#add-category"
logout()
checkIsNone "#add-category"
navigate baseUrl
login "admin", "admin"
session.logout()
proc test*(session: Session, baseUrl: string) =
session.navigate(baseUrl)
categoriesUserTests(session, baseUrl)
categoriesAdminTests(session, baseUrl)
session.navigate(baseUrl)

View file

@ -0,0 +1,189 @@
import os, options, unittest, strutils
import webdriver
import macros
const actionDelayMs {.intdefine.} = 0
## Inserts a delay in milliseconds between automated actions. Useful for debugging tests
macro with*(obj: typed, code: untyped): untyped =
## Execute a set of statements with an object
expectKind code, nnkStmtList
template checkCompiles(res, default) =
when compiles(res):
res
else:
default
result = code.copy
# Simply inject obj into call
for i in 0 ..< result.len:
if result[i].kind in {nnkCommand, nnkCall}:
result[i].insert(1, obj)
result = getAst(checkCompiles(result, code))
proc elementIsSome(element: Option[Element]): bool =
return element.isSome
proc elementIsNone(element: Option[Element]): bool =
return element.isNone
proc waitForElement*(session: Session, selector: string, strategy=CssSelector, timeout=20000, pollTime=50,
waitCondition: proc(element: Option[Element]): bool = elementIsSome): Option[Element]
proc click*(session: Session, element: string, strategy=CssSelector) =
let el = session.waitForElement(element, strategy)
el.get().click()
proc sendKeys*(session: Session, element, keys: string) =
let el = session.waitForElement(element)
el.get().sendKeys(keys)
proc clear*(session: Session, element: string) =
let el = session.waitForElement(element)
el.get().clear()
proc sendKeys*(session: Session, element: string, keys: varargs[Key]) =
let el = session.waitForElement(element)
# focus
el.get().click()
for key in keys:
session.press(key)
proc ensureExists*(session: Session, element: string, strategy=CssSelector) =
discard session.waitForElement(element, strategy)
template check*(session: Session, element: string, function: untyped) =
let el = session.waitForElement(element)
check function(el)
template check*(session: Session, element: string,
strategy: LocationStrategy, function: untyped) =
let el = session.waitForElement(element, strategy)
check function(el)
proc setColor*(session: Session, element, color: string, strategy=CssSelector) =
let el = session.waitForElement(element, strategy)
discard session.execute("arguments[0].setAttribute('value', '" & color & "')", el.get())
proc checkIsNone*(session: Session, element: string, strategy=CssSelector) =
discard session.waitForElement(element, strategy, waitCondition=elementIsNone)
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: proc(element: Option[Element]): bool = elementIsSome
): Option[Element] =
var waitTime = 0
when actionDelayMs > 0:
sleep(actionDelayMs)
while true:
try:
let loading = session.findElement(selector, strategy)
if waitCondition(loading):
return loading
finally:
discard
sleep(pollTime)
waitTime += pollTime
if waitTime > timeout:
doAssert false, "Wait for load time exceeded"
proc waitForElements*(
session: Session, selector: string, strategy=CssSelector,
timeout=20000, pollTime=50
): seq[Element] =
var waitTime = 0
when actionDelayMs > 0:
sleep(actionDelayMs)
while true:
let loading = session.findElements(selector, strategy)
if loading.len > 0:
return loading
sleep(pollTime)
waitTime += pollTime
if waitTime > timeout:
doAssert false, "Wait for load time exceeded"
proc setUserRank*(session: Session, baseUrl, user, rank: string) =
with session:
navigate(baseUrl & "profile/" & user)
click "#settings-tab"
click "#rank-field"
click("#rank-field option#rank-" & rank.toLowerAscii)
click "#save-btn"
proc logout*(session: Session) =
with session:
click "#profile-btn"
click "#profile-btn #logout-btn"
# Verify we have logged out by looking for the log in button.
ensureExists "#login-btn"
proc login*(session: Session, user, password: string) =
with session:
click "#login-btn"
clear "#login-form input[name='username']"
clear "#login-form input[name='password']"
sendKeys "#login-form input[name='username']", user
sendKeys "#login-form input[name='password']", password
sendKeys "#login-form input[name='password']", Key.Enter
# Verify that the user menu has been initialised properly.
click "#profile-btn"
checkText "#profile-btn #profile-name", user
click "#profile-btn"
proc register*(session: Session, user, password: string, verify = true) =
with session:
click "#signup-btn"
clear "#signup-form input[name='email']"
clear "#signup-form input[name='username']"
clear "#signup-form input[name='password']"
sendKeys "#signup-form input[name='email']", user & "@" & user & ".com"
sendKeys "#signup-form input[name='username']", user
sendKeys "#signup-form input[name='password']", password
click "#signup-modal .create-account-btn"
if verify:
with session:
# Verify that the user menu has been initialised properly.
click "#profile-btn"
checkText "#profile-btn #profile-name", user
# close menu
click "#profile-btn"
proc createThread*(session: Session, title, content: string) =
with session:
click "#new-thread-btn"
sendKeys "#thread-title", title
sendKeys "#reply-textarea", content
click "#create-thread-btn"
checkText "#thread-title .title-text", title
checkText ".original-post div.post-content", content

View file

@ -0,0 +1,36 @@
import unittest, common
import webdriver
proc test*(session: Session, baseUrl: string) =
session.navigate(baseUrl)
test "can see banned posts":
with session:
register("issue181", "issue181")
logout()
# Change rank to `user` so they can post.
login("admin", "admin")
setUserRank(baseUrl, "issue181", "user")
logout()
login("issue181", "issue181")
navigate(baseUrl)
const title = "Testing issue 181."
createThread(title, "Test for issue #181")
logout()
login("admin", "admin")
# Ban our user.
setUserRank(baseUrl, "issue181", "banned")
# Make sure the banned user's thread is still visible.
navigate(baseUrl)
ensureExists("tr.banned")
checkText("tr.banned .thread-title > a", title)
logout()
checkText("tr.banned .thread-title > a", title)

View file

@ -1,115 +1,43 @@
import unittest, options, os
import unittest, common
import webdriver
proc waitForLoad(session: Session, timeout=20000) =
var waitTime = 0
sleep(2000)
while true:
let loading = session.findElement(".loading")
if loading.isNone: return
sleep(1000)
waitTime += 1000
if waitTime > timeout:
doAssert false, "Wait for load time exceeded"
proc test*(session: Session, baseUrl: string) =
session.navigate(baseUrl)
waitForLoad(session)
# Sanity checks
test "shows sign up":
let signUp = session.findElement("#signup-btn")
check signUp.get().getText() == "Sign up"
session.checkText("#signup-btn", "Sign up")
test "shows log in":
let logIn = session.findElement("#login-btn")
check logIn.get().getText() == "Log in"
session.checkText("#login-btn", "Log in")
test "is empty":
let thread = session.findElement("tr > td.thread-title")
check thread.isNone()
session.checkIsNone("tr > td.thread-title")
# Logging in
test "can login/logout":
let logIn = session.findElement("#login-btn").get()
logIn.click()
with session:
login("admin", "admin")
let usernameField = session.findElement(
"#login-form input[name='username']"
)
check usernameField.isSome()
let passwordField = session.findElement(
"#login-form input[name='password']"
)
check passwordField.isSome()
usernameField.get().sendKeys("admin")
passwordField.get().sendKeys("admin")
passwordField.get().click() # Focus field.
session.press(Key.Enter)
waitForLoad(session, 5000)
# Verify that the user menu has been initialised properly.
let profileButton = session.findElement(
"#main-navbar figure.avatar"
).get()
profileButton.click()
let profileName = session.findElement(
"#main-navbar .menu-right div.tile-content"
).get()
check profileName.getText() == "admin"
# Check whether we can log out.
let logoutLink = session.findElement(
"Logout",
LinkTextSelector
).get()
logoutLink.click()
# Verify we have logged out by looking for the log in button.
check session.findElement("#login-btn").isSome()
# Check whether we can log out.
logout()
# Verify we have logged out by looking for the log in button.
ensureExists "#login-btn"
test "can register":
let signup = session.findElement("#signup-btn").get()
signup.click()
with session:
register("test", "test")
logout()
let emailField = session.findElement(
"#signup-form input[name='email']"
).get()
let usernameField = session.findElement(
"#signup-form input[name='username']"
).get()
let passwordField = session.findElement(
"#signup-form input[name='password']"
).get()
test "can't register same username with different case":
with session:
register "test1", "test1", verify = false
logout()
emailField.sendKeys("test@test.com")
usernameField.sendKeys("test")
passwordField.sendKeys("test")
navigate baseUrl
let createAccount = session.findElement(
"#signup-modal .modal-footer .btn-primary"
).get()
register "TEst1", "test1", verify = false
createAccount.click()
waitForLoad(session, 5000)
# Verify that the user menu has been initialised properly.
let profileButton = session.findElement(
"#main-navbar figure.avatar"
).get()
profileButton.click()
let profileName = session.findElement(
"#main-navbar .menu-right div.tile-content"
).get()
check profileName.getText() == "test"
ensureExists "#signup-form .has-error"
navigate baseUrl

View file

@ -0,0 +1,267 @@
import unittest, common
import webdriver
let
userTitleStr = "This is a user thread!"
userContentStr = "A user has filled this out"
adminTitleStr = "This is a thread title!"
adminContentStr = "This is content"
proc banUser(session: Session, baseUrl: string) =
with session:
login "admin", "admin"
setUserRank baseUrl, "user", "banned"
logout()
proc unBanUser(session: Session, baseUrl: string) =
with session:
login "admin", "admin"
setUserRank baseUrl, "user", "user"
logout()
proc userTests(session: Session, baseUrl: string) =
suite "user thread tests":
session.login("user", "user")
setup:
session.navigate(baseUrl)
test "can create thread":
with session:
click "#new-thread-btn"
sendKeys "#thread-title", userTitleStr
sendKeys "#reply-textarea", userContentStr
click "#create-thread-btn"
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
test "can view banned thread":
with session:
ensureExists userTitleStr, LinkTextSelector
with session:
navigate baseUrl
proc bannedTests(session: Session, baseUrl: string) =
suite "banned user thread tests":
with session:
navigate baseUrl
login "banned", "banned"
test "can't start thread":
with session:
click "#new-thread-btn"
sendKeys "#thread-title", "test"
sendKeys "#reply-textarea", "test"
click "#create-thread-btn"
ensureExists "#new-thread p.text-error"
session.logout()
proc adminTests(session: Session, baseUrl: string) =
suite "admin thread tests":
session.login("admin", "admin")
setup:
session.navigate(baseUrl)
test "can view banned thread":
with session:
ensureExists userTitleStr, LinkTextSelector
test "can create thread":
with session:
click "#new-thread-btn"
sendKeys "#thread-title", adminTitleStr
sendKeys "#reply-textarea", adminContentStr
click "#create-thread-btn"
checkText "#thread-title .title-text", adminTitleStr
checkText ".original-post div.post-content", adminContentStr
test "try create duplicate thread":
with session:
click "#new-thread-btn"
ensureExists "#new-thread"
sendKeys "#thread-title", adminTitleStr
sendKeys "#reply-textarea", adminContentStr
click "#create-thread-btn"
ensureExists "#new-thread p.text-error"
test "can edit post":
let modificationText = " and I edited it!"
with session:
click adminTitleStr, LinkTextSelector
click ".post-buttons .edit-button"
sendKeys ".original-post #reply-textarea", modificationText
click ".edit-buttons .save-button"
checkText ".original-post div.post-content", adminContentStr & modificationText
test "can like thread":
# Try to like the user thread above
with session:
click userTitleStr, LinkTextSelector
click ".post-buttons .like-button"
checkText ".post-buttons .like-button .like-count", "1"
test "can delete thread":
with session:
click adminTitleStr, LinkTextSelector
click ".post-buttons .delete-button"
# click delete confirmation
click "#delete-modal .delete-btn"
# 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) =
session.navigate(baseUrl)
userTests(session, baseUrl)
banUser(session, baseUrl)
bannedTests(session, baseUrl)
anonymousTests(session, baseUrl)
adminTests(session, baseUrl)
unBanUser(session, baseUrl)
session.navigate(baseUrl)