Compare commits

...

274 commits
v3.1.2 ... main

Author SHA1 Message Date
36cd802b1b 5.7.1 2025-04-13 04:21:53 +03:00
2721259a2a 2 bug çözüldü 2025-04-13 04:21:37 +03:00
8c20c70874 5.7.0 2024-02-10 23:51:48 +03:00
9b5bb9b782 Themes folder update
- discord better auth system with email
- module updates
- Cache is working now
- avatar.js extruted
- pagination++
- ban and password change button on interface
2024-02-10 23:51:42 +03:00
0438e4fdb4 5.6.1 2023-08-27 20:22:49 +03:00
00ea899fb4 Clear online guest, new dc username 2023-08-27 20:22:28 +03:00
aace4c6fd4 5.6.0 2023-08-25 13:46:04 +03:00
d4021e0e4a Merge branch 'main' of https://github.com/Akif9748/akf-forum 2023-08-25 13:45:59 +03:00
29f8af1394 Added me support for ID's 2023-08-25 13:45:55 +03:00
Akif9748
bda7988000
Update README.md 2023-08-22 22:42:45 +03:00
f6cafd17b9 fix typo 2023-08-10 15:33:40 +03:00
Akif9748
9ea41d8372
Update README.md 2023-08-10 15:23:11 +03:00
f2da949114 5.5.2 2023-05-26 21:08:22 +03:00
ef8cb5e819 Better user.ejs 2023-05-26 21:08:14 +03:00
6b983f9e55 5.5.1 2023-05-26 19:55:51 +03:00
4811c7a0e3 Delete -forum from forum name 2023-05-26 19:55:45 +03:00
fed6ea0668 update to do list 2023-05-26 19:47:31 +03:00
fe550051a7 5.5.0 2023-05-26 19:40:19 +03:00
31b9249cf8 change pass & email 2023-05-26 19:40:14 +03:00
138b585482 email & pass change 2023-05-26 19:39:57 +03:00
f31003b3f2 author url 2023-05-26 19:20:51 +03:00
0bdc21ff35 Fix for user pages 2023-05-26 19:11:14 +03:00
9598813e66 fix for avatars 2023-05-26 19:08:53 +03:00
ccd04139ba fix for cropper 2023-05-26 19:06:02 +03:00
8923293198 5.4.0 2023-05-26 18:57:35 +03:00
9c183eb05e New threads for homepage, and XENFORO theme 2023-05-26 18:57:29 +03:00
0311f363e2 5.3.0 2023-05-26 12:02:57 +03:00
8fe083ded0 Online users support and new messages on mainpage 2023-05-26 12:02:46 +03:00
922b8e5732 5.2.6 2023-05-25 19:28:15 +03:00
9ee1d589eb Better pagination and rewiretten threads etc. 2023-05-25 19:28:05 +03:00
Akif9748
83ae8507c0
Update README.md 2023-05-25 18:07:18 +03:00
e3b6345196 fix 2023-05-25 18:03:22 +03:00
17486e4288 5.2.5 2023-05-25 18:00:58 +03:00
8e1eff30f5 usermenu not needs to templates 2023-05-25 18:00:53 +03:00
a77bee5fde 5.2.4 2023-05-25 17:58:53 +03:00
31b0e86d09 better discord auth 2023-05-25 17:58:47 +03:00
dced1b0a02 5.2.3 2023-05-25 17:48:07 +03:00
2a8e26b7b2 better folder system 2023-05-25 17:47:54 +03:00
78e78c0694 5.2.2 2023-05-25 17:23:41 +03:00
edf9fc83dc full support for multiple themes, secure templates 2023-05-25 17:23:31 +03:00
080e1e6ced 5.2.1 2023-05-25 16:15:57 +03:00
adb19e2e34 setup and index is now differnt 2023-05-25 16:15:50 +03:00
667c0222e9 5.2.0 2023-05-24 15:43:45 +03:00
30c701c09e login with email 2023-05-24 15:43:40 +03:00
10b021c16f 5.1.1 2023-05-24 15:40:15 +03:00
8c78ecaed1 page support for categories, and one css file 2023-05-24 15:40:09 +03:00
27198d3131 5.1.0 2023-05-23 19:54:04 +03:00
14fe5d6dbb New uploader with cropper.
Fuck it taked my 4 hours.
2023-05-23 19:53:56 +03:00
16a2504665 5.0.0 2023-05-09 13:28:55 +03:00
93bb17981f Added themes api way, and bootstrap theme,useredit 2023-05-09 13:28:45 +03:00
bd722f1651 4.22.1 2023-05-08 18:17:53 +03:00
3ac99be051 Theme change available 2023-05-08 18:17:46 +03:00
fa222ad68d 4.22.0 2023-05-08 17:41:23 +03:00
7f655de129 added gravatar support 2023-05-08 17:41:16 +03:00
250b525e5a 4.21.0 2023-05-08 17:16:35 +03:00
c71f18668f 4.20.0 2023-05-08 17:16:23 +03:00
03d84a2564 rollback to v4.x, and mini fixes 2023-05-08 17:16:12 +03:00
da214db047 eslint 2023-03-19 17:14:33 +03:00
24cdd86e34 hmmmmm 2022-10-11 23:24:47 +03:00
791af87906 hmmm 2022-10-10 00:12:21 +03:00
05ac32277f 5.0.0 2022-10-10 00:06:14 +03:00
dc19974506 Packages are updated 2022-10-10 00:06:07 +03:00
388835b85f Enchanted theme support! 2022-10-10 00:04:25 +03:00
4e90431a3d 4.20.0 2022-10-09 22:57:57 +03:00
9304548847 Added edit config to web 2022-10-09 22:57:46 +03:00
06632137c0 4.19.4 2022-10-09 22:25:15 +03:00
47cda470e2 Added usermenu & hide last seen 2022-10-09 22:25:03 +03:00
02364c91ed 4.19.3 2022-10-09 21:33:55 +03:00
0663f968d2 caching 2022-10-09 21:33:47 +03:00
c3b6ad4d5f 4.19.2 2022-10-09 21:23:52 +03:00
410859fbe3 Added state for users. 2022-10-09 21:23:31 +03:00
a471f19f04 message&thread search user profile 2022-10-09 20:58:55 +03:00
1de709c632 Added virtuals for delete 2022-10-09 20:10:19 +03:00
e824b844f6 fix for not login 2022-10-09 19:44:19 +03:00
cc8d64af5d hmm 2022-10-06 22:11:18 +03:00
846ebbe5c9 4.19.1 2022-09-27 21:34:23 +03:00
318e95ec2e Modal.js 2022-09-27 21:34:13 +03:00
abeaddc24b 4.19.0 2022-09-24 01:39:19 +03:00
764dcc93f0 HTTP codes, approval system for user, email 2022-09-24 01:39:06 +03:00
32c2d3d1ca 4.18.0 2022-09-24 00:15:25 +03:00
cf8de73e7c Added unauth w/discord, and better auth 2022-09-24 00:15:17 +03:00
43720f0ca0 Better markdown editor for create_thread 2022-09-23 23:20:00 +03:00
b8b10a71c5 4.17.0 2022-09-23 23:10:22 +03:00
db61361a95 Major update named as minor!
Forum Setup page,
edit forum config w/api
ban user's all ips,
secretmodel deleted,
/undelete is disabled
and better themes
if user reacted a message, view it
fixs for reactions of messages
discord auth in config.json
2022-09-23 23:10:13 +03:00
98d379d82c 4.16.0 2022-09-21 23:54:58 +03:00
6b66974a86 /undelete depr, added thread.state 2022-09-21 23:54:48 +03:00
87b7faa0ff 4.15.0 2022-09-21 23:06:22 +03:00
4d39433fe1 Added old titles, and content for API. 2022-09-21 23:06:14 +03:00
984ac0e621 4.14.1 2022-09-21 22:42:16 +03:00
fd64ac8693 Better auth for API 2022-09-21 22:42:08 +03:00
0d356239e7 4.14.0 2022-09-17 22:22:14 +03:00
df14d08cc3 Sessions are stored now 2022-09-17 22:21:35 +03:00
c32db6dc28 Better 2022-09-17 21:43:04 +03:00
3671ecc863 4.13.2 2022-09-17 21:36:41 +03:00
3010efc00a Added markdown to editors 2022-09-17 21:36:33 +03:00
38042faaf5 4.13.1 2022-09-17 20:25:26 +03:00
87be28c71f Better limits 2022-09-17 20:25:19 +03:00
5a05737f8b Fix for mobility 2022-09-17 20:17:18 +03:00
a1af3f2d4e Better host input 2022-09-17 19:53:00 +03:00
b048d6a685 Added host to config.json 2022-09-17 19:48:27 +03:00
Akif Yüce
e4a93cbf52
Delete config.json 2022-09-17 19:45:31 +03:00
82ec45a786 4.13.0 2022-09-17 19:34:02 +03:00
598e48e25d Added discord auth support 2022-09-17 19:33:51 +03:00
e1aeb8fd14 4.12.6 2022-09-17 16:56:27 +03:00
d46e2362a4 Added admins to admin page 2022-09-17 16:56:19 +03:00
abf5c14475 Better APIDOCS 2022-09-17 16:44:04 +03:00
52386a58e4 4.12.5 2022-09-17 16:37:44 +03:00
67e12c2c26 Added view user ips! 2022-09-17 16:36:48 +03:00
cf02f85eab 4.12.4 2022-09-17 16:27:12 +03:00
e341e8d2e9 Added limits to config.json 2022-09-17 16:27:01 +03:00
b3735ce606 README 2022-09-17 15:51:38 +03:00
e0ce7dc577 4.12.3 2022-09-17 15:19:50 +03:00
49dbe2d73d Better reset 2022-09-17 15:19:41 +03:00
Akif Yüce
3bf0b75a7f
Update README.md 2022-09-17 13:59:31 +03:00
62e7579043 4.12.2 2022-09-17 01:15:53 +03:00
16f72585a0 Added user ips to user page 2022-09-17 01:15:44 +03:00
6c5f36836b Footer position is finally fixed, and ips for user 2022-09-17 01:15:04 +03:00
a6098a1f34 DOCTYPE html 2022-09-17 00:56:18 +03:00
8af04ee580 4.12.1 2022-09-17 00:52:00 +03:00
b7b7de5efd Added footer everywhere 2022-09-17 00:51:52 +03:00
412a7c1a50 Not required 2022-09-17 00:32:15 +03:00
8864d56659 A test script? 2022-09-17 00:30:26 +03:00
817f1c2e13 4.12.0 2022-09-17 00:27:45 +03:00
1cbcd16954 Added char limits 2022-09-17 00:27:38 +03:00
171f83f4cf 4.11.7 2022-09-16 23:51:25 +03:00
ccbc1b8e45 Fix for threads page, black theme 2022-09-16 23:51:16 +03:00
7ecf0a51de Better scroll and better category page 2022-09-16 23:38:06 +03:00
daa5c36281 4.11.6 2022-09-16 23:22:39 +03:00
2064d0c0a6 Better location for theme replacer 2022-09-16 23:22:29 +03:00
109b83e7cd 4.11.5 2022-09-16 23:12:19 +03:00
868068d80f hmm 2022-09-16 23:12:06 +03:00
2db20ce132 User about MD support and LIB, user avatar DEP 2022-09-16 23:11:53 +03:00
3527da03d0 4.11.4 2022-09-16 22:53:03 +03:00
657e18773b Deleted a not required module 2022-09-16 22:52:55 +03:00
f138276abf 4.11.3 2022-09-16 22:46:18 +03:00
0d0ef1d6c1 Add app.ips, bans 2022-09-16 22:46:08 +03:00
214dd16515 4.11.2 2022-09-16 22:40:50 +03:00
a816aab7eb Better index.js 2022-09-16 22:40:43 +03:00
ba0d4cbb8d 4.11.1 2022-09-16 22:26:11 +03:00
34444c65db Added IP list to forum internal 2022-09-16 22:26:03 +03:00
e1cca3966e 4.11.0 2022-09-16 21:57:37 +03:00
00c509d0ec Added markdown editor 2022-09-16 21:57:27 +03:00
c06af31f26 Fix for req session 2022-09-10 21:02:24 +03:00
aa0bd09efd Added apidocs for /me path 2022-09-09 21:40:02 +03:00
980deb7db2 fix 2022-09-09 21:37:48 +03:00
763204b01c 4.10.0 2022-09-09 21:37:36 +03:00
4fc7fac631 4.9.4 2022-09-09 21:37:04 +03:00
70386f4088 Added me path 2022-09-09 21:36:55 +03:00
40d5f57162 packages are updated 2022-09-09 21:16:24 +03:00
f7f66d74fe 4.9.3 2022-09-09 20:55:18 +03:00
b660217da4 Added category 2022-09-09 20:55:05 +03:00
ef1b5bff37 edited 2022-09-09 20:48:12 +03:00
1c207bd08b 4.9.2 2022-09-09 20:47:44 +03:00
035b2b7184 Added categories to front end 2022-09-09 20:47:28 +03:00
0ed4a34c8c front end fix 2022-09-09 18:33:22 +03:00
80a83d0f14 4.9.1 2022-09-09 18:27:53 +03:00
3c153ae64f Search now has limits and pages! 2022-09-09 18:27:41 +03:00
feb64e2a21 4.9.0 2022-09-09 18:00:53 +03:00
3a78fd4f6c Added last seen info 2022-09-09 18:00:32 +03:00
e0e6dadc14 4.8.1 2022-09-09 17:38:20 +03:00
3f36f7761a Black Theme Fixes 2022-09-09 17:38:13 +03:00
9ce1c1bfef 4.8.0 2022-09-09 17:13:50 +03:00
09ac68ee70 Added category to API 2022-09-09 17:13:37 +03:00
03f52fed3b 4.7.1 2022-09-09 16:31:06 +03:00
bfac0b16af Major bug fix 2022-09-09 16:30:57 +03:00
74b6b73b72 4.7.0 2022-09-09 16:29:50 +03:00
69697b54b1 Added avatar upload 2022-09-09 16:29:36 +03:00
df8bf7eadc 4.6.0 2022-09-09 15:34:20 +03:00
0c74cac622 Added custom forum name and meta desp 2022-09-09 15:34:12 +03:00
b9ce9c149b 4.5.0 2022-09-09 15:10:56 +03:00
f11711ac76 Better user edit. Added footer. better API way. 2022-09-09 15:10:44 +03:00
tokmak0
4e12c2d325
Create modal.js 2022-09-08 15:10:08 +03:00
tokmak0
2efb974c31
Create modal.css 2022-09-08 15:09:13 +03:00
tokmak0
eef5c85217
Update user.ejs 2022-09-08 15:08:34 +03:00
tokmak0
9831524a00
Update search.ejs 2022-09-08 15:07:48 +03:00
tokmak0
18974753e0
Update footer.ejs 2022-09-08 15:07:26 +03:00
tokmak0
5e64d2d6e1
Update user.css 2022-09-08 15:06:31 +03:00
tokmak0
56e3c0d41d
Update search.css 2022-09-08 15:05:51 +03:00
538108da94 abs 2022-09-05 23:44:38 +03:00
0fba3dca03 Fixes
Co-authored-by: kayraegek18 <kayraegek18@users.noreply.github.com>
2022-09-04 22:19:19 +03:00
1d4e04035f fix 2022-09-01 20:11:12 +03:00
Akif Yüce
d63fe79e35
Create node.js.yml 2022-09-01 16:34:41 +03:00
eefa58b585 Added search to APIDOCS 2022-09-01 16:28:15 +03:00
2a3ab27ba6 4.4.0 2022-09-01 16:23:55 +03:00
5b2399bec5 Added search 2022-09-01 16:23:46 +03:00
0358e9e5a2 4.3.1 2022-09-01 15:19:19 +03:00
ca66d44825 Pages for threads and users pages, and lib/ 2022-09-01 15:19:12 +03:00
Akif Yüce
3a4390074e
Update README.md 2022-09-01 14:15:48 +03:00
ad5603c3b7 4.3.0 2022-09-01 14:02:54 +03:00
c110b195a3 Better edits, already deleted, global ratelimit 2022-09-01 14:02:47 +03:00
cd801c94c7 edited 2022-08-31 19:17:22 +03:00
3b2de7b646 4.2.0 2022-08-31 19:16:52 +03:00
43408f8a90 Added blacktheme 2022-08-31 19:16:43 +03:00
9e9c7be917 Added important color 2022-08-31 18:14:21 +03:00
a5cb75fbc5 4.1.0 2022-08-31 18:12:02 +03:00
f2e4b174d0 variables for css 2022-08-31 18:11:54 +03:00
4b75334072 delete not required packages 2022-08-31 16:57:09 +03:00
eb61bf82cd Added footer demo 2022-08-31 16:47:31 +03:00
596f979fbb 4.0.2 2022-08-31 16:25:44 +03:00
c268a5bed8 Delete thread button for threads page 2022-08-31 16:25:35 +03:00
tokmak0
3fef090992
Update navbar.css 2022-08-31 16:12:15 +03:00
6c3fb4c93c Merge branch 'main' of https://github.com/Akif9748/akf-forum 2022-08-31 16:06:36 +03:00
a95015a4b5 HOTFIX FOR MESSAGE API 2022-08-31 16:05:23 +03:00
tokmak0
46dbbcd343
Update threads.ejs 2022-08-31 16:04:37 +03:00
tokmak0
61ff3951bd
Update thread.ejs 2022-08-31 16:04:20 +03:00
tokmak0
6ed388cc05
Update create_thread.css 2022-08-31 16:03:21 +03:00
tokmak0
66e3e37b1c
Update thread.css 2022-08-31 16:02:28 +03:00
tokmak0
a09a74d370
Update navbar.css 2022-08-31 15:32:12 +03:00
2789428dfd Merge branch 'main' of https://github.com/Akif9748/akf-forum 2022-08-31 15:31:33 +03:00
460a870784 Fix about me in server 2022-08-31 15:31:23 +03:00
tokmak0
13951a1330
Update navbar.css 2022-08-31 15:31:14 +03:00
tokmak0
c980dc40de
Update thread.ejs 2022-08-31 15:28:52 +03:00
tokmak0
51a641e5da
Update login.css 2022-08-31 15:28:15 +03:00
tokmak0
fec93df6c6
Update thread.css 2022-08-31 15:27:49 +03:00
e2e2b5bf59 fixed scroll 2022-08-31 15:21:04 +03:00
71f820160d 4.0.1 2022-08-31 15:14:20 +03:00
46d5eac8d8 When thread deleted, delete its messages 2022-08-31 15:14:14 +03:00
98675863bb Edited readme 2022-08-31 14:48:30 +03:00
0f436912d3 Merge branch 'main' of https://github.com/Akif9748/akf-forum 2022-08-31 14:47:52 +03:00
d2753c2070 4.0.0 2022-08-31 14:44:39 +03:00
b0a7ac7605 Caching for users, and rename is fixed 2022-08-31 14:44:28 +03:00
Akif Yüce
86d6c64a7b
Update README.md 2022-08-30 03:08:09 +03:00
256b70c611 ratelimit fix 2022-08-29 22:33:22 +03:00
06f15548b5 Edited information for thread 2022-08-29 22:10:03 +03:00
01a07697aa Thread edited info 2022-08-29 22:07:00 +03:00
e9ad89b269 3.4.3 2022-08-29 22:02:40 +03:00
dd300d06ba Added ban button to admin panel 2022-08-29 22:02:27 +03:00
f10b222380 3.4.2 2022-08-29 21:33:08 +03:00
ab1f062d9d http fixs, added ip ban to api, and admin panel 2022-08-29 21:32:57 +03:00
e01ef642c3 Edit user added 2022-08-29 19:35:07 +03:00
5dc9570809 3.4.1 2022-08-29 19:34:30 +03:00
0a09da789c Added avatar edit support 2022-08-29 19:34:21 +03:00
22028eeb3d 3.4.0 2022-08-29 19:32:15 +03:00
5c259f02f3 Page is OK! Edits, About me, ip ban 2022-08-29 19:31:59 +03:00
5b020ff548 3.3.1 2022-08-29 16:44:04 +03:00
75c36abd75 fix for pages 2022-08-29 16:43:57 +03:00
0ee3d3be8b better front end js 2022-08-29 16:26:34 +03:00
52eda8ddad 3.3.0 2022-08-29 16:17:45 +03:00
facaf105eb new theme is fixed in everypage 2022-08-29 16:16:44 +03:00
9ad7c03162 thread converted to new theme, page support 2022-08-29 00:58:34 +03:00
c8474694da Merge branch 'main' of https://github.com/Akif9748/akf-forum 2022-08-28 21:14:07 +03:00
3378388e8c reactions are fixed 2022-08-28 21:14:02 +03:00
tokmak0
9154b3c246
Update thread.ejs 2022-08-28 21:09:15 +03:00
tokmak0
cbe03616b6
Update thread.css 2022-08-28 21:08:33 +03:00
tokmak0
c43011d83d
Update thread.ejs 2022-08-28 20:36:39 +03:00
68e4a92b8a user key-value fix 2022-08-28 20:32:52 +03:00
b80f7ac52d Main page is ok 2022-08-28 20:04:54 +03:00
20b00af256 main page fixed 2022-08-28 19:03:32 +03:00
d6f301da72 thread rendering is updated, guest can view msg 2022-08-28 18:19:03 +03:00
tokmak0
c5e62a300c
Update navbar.css 2022-08-28 18:11:41 +03:00
tokmak0
a9fe56bb6b
Create user.css 2022-08-28 18:11:12 +03:00
tokmak0
8b91d8d182
Update user.ejs 2022-08-28 18:10:41 +03:00
14bf6d45a4 fix for mobile and support for pages 2022-08-28 17:34:29 +03:00
7b4dc19cb7 3.2.1 2022-08-28 17:14:12 +03:00
b1afa3e9a9 Ratelimit fix + replace html content in messages 2022-08-28 17:14:05 +03:00
Akif Yüce
afc55b4dcb
Server side license 2022-08-28 15:45:19 +03:00
108bf2ddff theme fix 2022-08-28 15:23:19 +03:00
6999e0ee9b 3.2.0 2022-08-28 15:01:02 +03:00
98356a2a90 view count for threads 2022-08-28 15:00:53 +03:00
1f737277b2 regex for avatars 2022-08-27 14:20:14 +03:00
39bb72d73b 3.1.6 2022-08-27 14:08:45 +03:00
b26314d4a2 bcrypt fix + forms to a 2022-08-27 14:08:28 +03:00
67b5f4c2ad 3.1.5 2022-08-27 10:31:34 +03:00
6eeca272af profile photo fix 2022-08-27 10:31:16 +03:00
87cf4f3274 API-DOCS-FIX
Co-authored-by: Serkan9748 <Serkan9748@users.noreply.github.com>
2022-08-27 09:53:10 +03:00
0e659a8aa4 [WIP] Threads page is converting to new theme 2022-08-26 22:09:02 +03:00
7aa459e402 admin page to new theme
Co-authored-by: Zeynep <zeynepkotan50@gmail.com>
2022-08-26 21:16:28 +03:00
009888d3aa Merge branch 'main' of https://github.com/Akif9748/akf-forum 2022-08-26 21:04:44 +03:00
57e5e15265 3.1.4 2022-08-26 21:04:40 +03:00
258578e790 Undelete user
Co-authored-by: Zeynep <zeynepkotan50@gmail.com>
2022-08-26 21:04:30 +03:00
tokmak0
a1f9589bf2
Create create_thread.css 2022-08-26 20:58:35 +03:00
tokmak0
42f4cace98
Update create_thread.ejs 2022-08-26 20:57:55 +03:00
1624430551 3.1.3 2022-08-26 20:39:33 +03:00
c94abce4f9 FIX! login redirecting register... 2022-08-26 20:39:25 +03:00
155 changed files with 14390 additions and 3785 deletions

View file

@ -1 +1,7 @@
MONGO_DB_URL = mongodb://localhost:27017/akf-forum
SECRET = secret
DISCORD_SECRET = yourDiscordSecret
DISCORD_ID = yourDiscordId
EMAIL_USER =
EMAIL_PASS =
EMAIL_SERVICE =

27
.eslintrc.json Normal file
View file

@ -0,0 +1,27 @@
{
"ignorePatterns": [
"test.js"
],
"env": {
"node": true,
"commonjs": true,
"es2021": true
},
"extends": "eslint:recommended",
"parserOptions": {
"ecmaVersion": "latest"
},
"rules": {
"no-use-before-define": "error"
},
"overrides": [
{
"env": {
"browser": true
},
"files": [
"src/public/js/*"
]
}
]
}

14
.gitignore vendored
View file

@ -1,9 +1,15 @@
# Dependency directories
node_modules/
# env
# Environment variables
.env
# Test files:
a.js
db.js
# Config files
config.json
# Test files
test.js
# avatars folder
src/public/images/avatars/*
!src/public/images/avatars/default.jpg

105
APIDOCS.md Normal file
View file

@ -0,0 +1,105 @@
# API documentation of Akf-forum
Akf-forum has got an API for AJAX, other clients etc.
## Authorization
You need this header for send request to API:
```json
{
"authorization": "Basic <base64 encoded username:password>"
}
```
But in front end, the API will works with session.
## Default Limits:
- 3 - 25 char for username, password and category name
- 256 char for user about and desp of category
- 5 - 128 char for thread titles.
- 5 - 1024 char for messages.
You can change them in config.json.
## How to request?
### Request types:
#### `/api/me`
- GET `/api/me` to get your account.
#### `/api/config`
- GET `/` to reach config file.
- PUT `/` to edit config file.
#### `/api/bans`
- GET `/` fetch all bans.
- GET `/:ip` fetch a ban.
- DELETE `/:ip` for unban an IP adress.
- POST `/?reason=flood` for ban an IP adress.
#### `/api/categories`
- GET `/` fetch all categories.
- GET `/:id` fetch a category
- PATCH `/:id` for update a category.
- DELETE `/:id` for delete a category.
- POST `/` for create a category.
#### `/api/messages`
- GET `/:id` for fetch message.
- DELETE `/:id` for delete message.
- PATCH `/:id` for edit message.
- POST `/:id/react/:type` for react to a message.
- POST `/` for create message.
#### `/api/search` use `?limit=&skip=` for skip and limit
- GET `/users?q=query` find users.
- GET `/threads?q=query&authorID=not_required` find threads.
- GET `/messages?q=query&authorID=not_required` find messages.
#### `/api/threads`
- GET `/:id` for fetch thread.
- DELETE `/:id` for delete thread.
- PATCH `/:id` for edit thread.
- GET `/:id/messages?skip=0&limit=10` for fetch messages in thread.
- POST `/` for create thread.
#### `/api/users`
- GET `/:id` for fetch user.
- DELETE `/:id` for delete user.
- PATCH `/:id` for edit user.
- PUT `/:id` for add profile photo to user.
- POST `/:id/ban` for ban all ips of user.
### Example request:
GET ```/api/messages/0```
#### Example API Output:
```js
{
"react": {
"like": [],
"dislike": ["0"]
},
"_id": "6325c216faa938c4cfc43075",
"author": {
"_id": "632e028ca4ba362ebbb75a43",
"name": "Akif9748",
"avatar": "/images/avatars/0.jpg",
"deleted": false,
"edited": true,
"about": "# Owner",
"admin": true,
"theme": "black",
"hideLastSeen": false,
"time": "2022-09-23T19:01:32.610Z",
"id": "0",
"__v": 0,
"discordID": "539506680140922890"
},
"threadID": "0",
"content": "This is a thread opened via API, yes",
"deleted": false,
"edited": true,
"time": "2022-09-17T12:48:22.378Z",
"id": "0",
"__v": 4,
"oldContents": []
}
```

143
LICENSE
View file

@ -1,5 +1,5 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
@ -7,17 +7,15 @@
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
software for all its users.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
@ -26,44 +24,34 @@ them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
The precise terms and conditions for copying, distribution and
modification follow.
@ -72,7 +60,7 @@ modification follow.
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"This License" refers to version 3 of the GNU Affero General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
@ -549,35 +537,45 @@ to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
the GNU Affero General Public License from time to time. Such new versions
will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Program specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
GNU Affero General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
versions of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
@ -635,40 +633,29 @@ the "copyright" line and a pointer to where the full notice is found.
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
GNU Affero General Public License for more details.
You should have received a copy of the GNU General Public License
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
For more information on this, and how to apply and follow the GNU AGPL, see
<https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<https://www.gnu.org/licenses/why-not-lgpl.html>.

157
README.md
View file

@ -1,22 +1,37 @@
# akf-forum
<img src="https://raw.githubusercontent.com/Akif9748/akf-forum/main/public/images/logo.jpg" align="right" width="300px" />
A forum software written in Node.js.
A Node.js based forum software.
## Installation
- Clone or download this repo.
- Run `npm i` to install **dependencies**.
- Enter your database credentials in `.env`.
- Run `npm start` for run it.
- Go `/setup` page for setup your forum.
### Extra
Run `node util/reset` to **reset the database**, and run `node util/admin` for give admin perms to first member.
### Extra (If you are not use `setup` page)
Run `node util/reset` to **reset the database** for duplicate key errors, and run `node util/admin` for give admin perms to first member.
Edit `config.json` for default theme for users, forum name, meta description, character limits, discord auth enabler, global ratelimit etc.
### How to install theme:
- Copy your theme to `src/themes` folder.
Additional note for themes: If a theme has not got any .ejs file, it will use default theme's .ejs files. default theme is in themes folder, named as `common`.
### DISCORD AUTH:
`"discord_auth": true` in config.json.
Add your app secret and app id to `.env` as `DISCORD_SECRET` and `DISCORD_ID`.
Create a redirect url in discord developer portal:
`https://forum_url.com/auth/discord`
### EMAIL AUTH:
You can configure it. Just edit `config.json` and `.env` files.
`"email_auth": true, "default_user_state": "APPROVAL"` in config.json.
Add your email credentials to `.env` as `EMAIL_USER` and `EMAIL_PASS`.
Add your email domain to `.env` as `EMAIL_SERVICE`.
## API
Akf-forum has got an API for other clients etc. You can test api with python files in `test` folder.
Akf-forum has got an API for AJAX (fetch), other clients etc. And, you can learn about API in `APIDOCS.md`.
Python is only for testing 😭
And, you can learn about API in `util/APIDOCS.md`.
**And you can use [offical API wrapper](https://github.com/Akif9748/akf-forum-api).**
## Credits
* [Akif9748](https://github.com/Akif9748) - Project mainteiner, main developer, made **old** frontend
@ -24,80 +39,66 @@ And, you can learn about API in `util/APIDOCS.md`.
* [Camroku](https://github.com/Camroku) - Made **old** stylesheets
## Screenshot
### Old frontend
![akf-forum](https://user-images.githubusercontent.com/70021050/160255959-ef216cba-1348-4d4b-9347-fe67e21348e7.png)
### New frontend
![image](https://user-images.githubusercontent.com/70021050/186941146-f9a8fbf8-9b2b-4028-afc8-81cff559d9fb.png)
### Thread Page w/Bootstrap theme
![image](https://github.com/Akif9748/akf-forum/assets/70021050/1ad4ad8e-d000-46a6-834e-7d76cdddda60)
## TO-DO list
### Backend:
#### Feature:
- Profile Message or DM
- Upload other photos, model for it
- Edit & download template
- Banner
- Add @me support for ids, <%= member.id %>
- Roles & Permissions
```
role: "moderator",
permissions: ["see_deleted_message"]
```
#### Fixes:
- Admin deleting other admins.
- theme.js, change theme js code to a file.
- same email discord???? direct err
- IMPORTANT: add user/member id to file so scripts can access
#### ETC:
- Rewrite apidocs
- Add a feature list to README.md
## Roadmap
### TO-DO:
- If thread deleted, not show its messages in API.
- If redirect => /register, return
### Frontend
### User
| To do | Is done? | Priority |
| ----- | -------- | -------- |
| Login via redirect query | 🟢 | HIGH |
| Register | 🟢 | HIGH |
| Logout | 🟢 | HIGH |
| Admin | 🟢 | HIGH |
| Message count | 🟢 | MEDIUM |
| Delete user | 🟢 | HIGH |
| Undelete | 🔴 | MEDIUM |
| About me | 🔴 | LOW |
| Edit user | 🔴 | HIGH |
| IP ban | 🔴 | MEDIUM |
#### Features:
- change category name
- Add approval threads page.
- add support for switch around gravatar and upload photo
- old contents / titles add to forum interface
- who liked a message
### Messages
| To do | Is done? | Priority |
| ----- | -------- | -------- |
| Ratelimit | 🟢 | HIGH |
| Send | 🟢 | HIGH |
| Delete | 🟢 | HIGH |
| Regex for scripts | 🔴 | HIGH |
| Undelete | 🔴 | MEDIUM |
| React | 🟢 | MEDIUM |
| Edit | 🔴 | MEDIUM |
#### Fixes:
- BETTER SETUP PAGE: use setup everytime
- add threads, messages etc. to "extra" folder
- add category to thread list page
- working reset button
- text alling center body
- thread.js unfuction only listener
- show error on modal
- send delete, ban to user settings (edit user) menu and fix edit user menu
### Threads
| To do | Is done? | Priority |
| ----- | -------- | -------- |
| Ratelimit | 🟢 | HIGH |
| Create | 🟢 | HIGH |
| Delete | 🟢 | HIGH |
| Undelete | 🔴 | MEDIUM |
| Edit | 🔴 | MEDIUM |
## Special Thanks:
https://github.com/akashgiricse/Online-Forum for bootstrap theme.
### API
| To do | Is done?
| ----- | --------
| RATELIMITS | 🟢
| Get message**s** | 🟢
| Create message & thread & user | 🟢
| Get message & thread & user | 🟢
| Delete message & thread & user | 🟢
| Undelete message & thread & user | 🟢
| Edit message & thread & user | 🔴
@Tokmak for old frontend.
### Other
| To do | Is done? | Priority |
| ----- | -------- | -------- |
| Footer | 🟢 | LOW |
| auto-scroll | 🟢 | LOW |
| Multi-theme support | 🟡 | LOW |
| Search | 🟡 | MEDIUM |
| Better view, page support, support message limit correct | 🔴 | MEDIUM |
| Sending message etc. will use fetch API | 🟢 | HIGH |
https://fengyuanchen.github.io/cropperjs/examples/crop-a-round-image.html for avatar upload panel.
### New Theme
- [x] Login
- [x] Register
- [x] Main page
- [x] Error
- [x] Users
- [x] Threads
- [x] Create Thread
- [ ] Thread
- [ ] User
- [ ] Admin
https://github.com/mdbootstrap/bootstrap-profiles for profile page of bootstrap theme.
## Major Version History
- V5: Enchanted Themes
- V4: Caching
- V3: New Theme
- V2: Backend fix, mongoose is fixed. Really big fix.
- V1: Mongoose added.
- V0: Birth with quick.db

24
config.json.example Normal file
View file

@ -0,0 +1,24 @@
{
"def_theme": {
"codename": "bootstrap_black",
"language": "en"
},
"forum_name": "akf-forum",
"description": "Akf-forum offical site!",
"limits": {
"title": 128,
"message": 1024,
"names": 25,
"desp": 256
},
"global_ratelimit": {
"enabled": true,
"max": 30,
"windowMs": 60000
},
"discord_auth": false,
"default_thread_state": "OPEN",
"default_user_state": "ACTIVE",
"email_auth": false,
"host": "https://akf-forum.glitch.me"
}

View file

@ -1,33 +0,0 @@
const { UserModel } = require("./models"),
session = require('express-session'),
bodyParser = require('body-parser'),
port = process.env.PORT || 3000,
mongoose = require("mongoose"),
express = require('express'),
fs = require("fs"),
app = express();
require("dotenv").config();
mongoose.connect(process.env.MONGO_DB_URL, () => console.log("Database is connected"));
app.use(session({ secret: 'secret', resave: true, saveUninitialized: true }));
app.use(bodyParser.urlencoded({ extended: true }));
app.use(express.static("public"));
app.set("view engine", "ejs");
app.use(express.json());
app.use(async (req, res, next) => {
req.user = await UserModel.get(req.session.userid);
res.error = (type, error) => res.status(type).render("error", {user: req.user, type, error });
if (req.user?.deleted) {
req.session.destroy();
return res.error(403, "Your account has been deleted.");
}
next();
});
for (const file of fs.readdirSync("./routes"))
app.use("/" + file.replace(".js", ""), require(`./routes/${file}`));
app.all("*", (req, res) => res.error(404, "We have not got this page."));
app.listen(port, () => console.log("akf-forum on port:", port));

View file

@ -1,38 +0,0 @@
const mongoose = require("mongoose")
const UserModel = require("./User");
const schema = new mongoose.Schema({
id: { type: String, unique: true },
threadID: String,
author: UserModel.schema, // user-model
content: String,
time: { type: Date, default: Date.now },
deleted: { type: Boolean, default: false },
edited: { type: Boolean, default: false },
react: { type:Object, default: {} }
})
schema.virtual('authorID').get(function() { return this.author?.id; });
schema.virtual('reactCount').get(function() {
const arr = Object.values(this.react)
return arr.filter(Boolean).length - arr.filter(x => !x).length;
});
schema.methods.takeId = async function () {
this.id = String(await model.count() || 0);
return this;
}
schema.methods.getLink = function (id = this.id) {
return "/messages/" + id;
}
const model = mongoose.model('message', schema);
model.get = id => model.findOne({ id });
module.exports = model;

View file

@ -1,12 +0,0 @@
const { Schema, model } = require("mongoose")
const schema = new Schema({
username: { type: String, unique: true },
password: String,
id: { type:String, unique: true }
});
module.exports = model('secret', schema);

View file

@ -1,36 +0,0 @@
const mongoose = require("mongoose")
const UserModel = require("./User");
const schema = new mongoose.Schema({
id: { type: String, unique: true },
author: UserModel.schema,
title: String,
time: { type: Date, default: Date.now },
deleted: { type: Boolean, default: false },
messages: [String]
});
schema.virtual('authorID').get(function() { return this.author?.id; });
schema.methods.push = function (messageID) {
this.messages.push(messageID);
return this;
}
schema.methods.takeId = async function () {
this.id = await model.count() || 0;
return this;
}
schema.methods.getLink = function (id = this.id) {
return "/threads/" + id;
}
const model = mongoose.model('thread', schema);
model.get = id => model.findOne({ id });
module.exports = model;

View file

@ -1,28 +0,0 @@
const mongoose = require("mongoose")
const schema = new mongoose.Schema({
id: { type: String },
name: String,
avatar: { type: String, default: "/images/guest.png" },
time: { type: Date, default: Date.now },
deleted: { type: Boolean, default: false },
admin: { type: Boolean, default: false }
});
schema.methods.takeId = async function () {
this.id = String(await model.count() || 0);
return this;
}
schema.methods.getLink = function (id = this.id) {
return "/users/" + id;
}
const model = mongoose.model('user', schema);
model.get = id => model.findOne({ id });
module.exports = model;

View file

@ -1,6 +0,0 @@
const UserModel = require("./User"),
MessageModel = require("./Message"),
ThreadModel = require("./Thread"),
SecretModel = require("./Secret");
module.exports = { UserModel, MessageModel, ThreadModel, SecretModel };

4355
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,10 +1,13 @@
{
"name": "akf-forum",
"version": "3.1.2",
"version": "5.7.1",
"description": "A Node.js based forum software",
"main": "index.js",
"main": "src/index.js",
"scripts": {
"start": "node ."
"start": "node .",
"reset": "node util/reset",
"admin": "node util/admin",
"test": "echo \"Error: no test specified\" && exit 1"
},
"repository": {
"type": "git",
@ -19,15 +22,24 @@
"bugs": {
"url": "https://github.com/Akif9748/akf-forum/issues"
},
"homepage": "https://akf-forum.herokuapp.com/",
"engines": {
"node": ">=18 >=17.5 >=16.15"
},
"homepage": "https://akf-forum.glitch.me/",
"dependencies": {
"bcrypt": "^5.0.1",
"body-parser": "^1.19.2",
"dotenv": "^16.0.1",
"ejs": "^3.1.6",
"express": "^4.17.3",
"express-rate-limit": "^6.5.1",
"express-session": "^1.17.2",
"mongoose": "^6.5.1"
"bcrypt": "^5.1.1",
"connect-mongo": "^5.1.0",
"dotenv": "^16.4.2",
"ejs": "^3.1.9",
"express": "^4.18.2",
"express-rate-limit": "^7.1.5",
"express-session": "^1.18.0",
"mongoose": "^8.1.1",
"multer": "^1.4.5-lts.1",
"nodemailer": "^6.9.9",
"request-ip": "^3.3.0"
},
"devDependencies": {
"eslint": "^8.56.0"
}
}

2114
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,24 +0,0 @@
.title {
color: #4d18e6;
font-weight: 700;
}
form {
display: flex;
align-items: center;
flex-direction: column;
max-width: 1000px;
margin: 0 auto;
}
.input {
padding: 8px 10px;
font-family: inherit;
display: block;
font-weight: 600;
color: #4c4c4c;
width: 500px;
margin-bottom: 10px;
border: 2px solid #d9d9d9;
}

View file

@ -1,168 +0,0 @@
@import url('https://fonts.googleapis.com/css2?family=Poppins:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&display=swap');
* {
box-sizing: border-box;
}
body {
margin: 0;
}
a {
text-decoration: none;
color: initial;
}
.header {
width: 100%;
padding: 20px;
display: flex;
justify-content: space-between;
align-self: center;
}
.logo {
padding: 10px;
font-size: 28px;
color: #4d18e6;
font-weight: 900;
}
.logo>span {
color: #606060;
}
.buttons {
padding: 0;
display: flex;
align-items: center;
}
.btn-primary {
color: #e8e8e8;
background-color: #4d18e6;
padding: 10px 20px 10px 20px;
border-radius: 4px;
font-weight: 700;
margin: 10px;
cursor: pointer;
border: 2px solid #4d18e6;
}
.btn-outline-primary {
color: #4d18e6;
padding: 10px 20px 10px 20px;
border-radius: 4px;
font-weight: 700;
margin: 10px;
cursor: pointer;
border: 2px solid #e2e2e2;
}
.menu {
width: 100%;
padding: 20px;
display: flex;
justify-content: space-around;
}
.btn-primary:hover {
color: #4d18e6;
background-color: rgba(0, 0, 0, 0);
border: 2px solid #4d18e6;
}
.menu-item {
padding: 10px;
font-weight: 700;
background-color: #606060;
color: #ffffff;
border-radius: 5px;
width: 100%;
text-align: center;
margin: 0px 10px 0px 0px;
cursor: pointer;
transition: background-color 0.3s ease;
}
.active-menu {
background-color: #4d18e6;
}
body {
font-family: Poppins;
display: flex;
flex-direction: column;
height: 100%;
}
.btn-outline-primary:hover {
border: 2px solid #4d18e6;
}
.menu-item:hover {
background-color: #4d18e6;
}
.admin-bar {
font-size: 15px;
color: #ffffff;
background: #4d18e6;
text-align: center;
}
div.avatar {
padding-left: 10px;
}
.avatar>img {
width: 30px;
height: 30px;
border-radius: 50%;
margin: auto;
}
.box-username {
display: flex;
align-items: center;
font-size: 18px;
font-weight: 700;
color: #4d18e6;
}
@media (max-width: 992px) {
.header {
flex-direction: column;
align-items: center;
}
.menu {
flex-direction: column;
}
.menu-item {
margin: 0px 10px 10px 0px;
}
}
@media (max-width: 480px) {
.btn-outline-primary {
width: 100%;
text-align: center;
}
.buttons {
flex-direction: column;
width: 100%;
}
.btn-primary {
width: 100%;
text-align: center;
}
}

View file

@ -1,262 +0,0 @@
:root {
/* Apprentice Scheme */
/*
* use `var(--col-x)` instead of directly typing the color.
*/
--col-0: #1C1C1C;
--col-1: #AF5F5F;
--col-2: #5F875F;
--col-3: #87875F;
--col-4: #5F87AF;
--col-5: #5F5F87;
--col-6: #5F8787;
--col-7: #6C6C6C;
--col-8: #444444;
--col-9: #FF8700;
--col-10: #87AF87;
--col-11: #FFFFAF;
--col-12: #8FAFD7;
--col-13: #8787AF;
--col-14: #5FAFAF;
--col-15: #FFFFFF;
--col-fg: #BCBCBC;
--col-bg: #262626;
}
textarea {
font-family: monospace;
background-color: var(--col-bg);
border: 2px solid var(--col-8);
color: var(--col-fg);
width: auto;
height: auto;
cursor: pointer;
resize: none;
}
p {
font-size: 25px;
}
hr {
border-color: var(--col-8);
border: 0;
border-top: 1px solid var(--col-fg);
margin: 20px 0;
}
body,
h1,
h2,
h3,
h4,
h5,
h6 {
font-family: monospace;
background: var(--col-bg);
color: var(--col-fg);
max-width: 69rem;
margin-top: 10px;
margin: auto;
/* funny number */
}
a:link {
color: var(--col-4);
}
a:hover {
color: var(--col-1);
}
a:visited {
color: var(--col-13);
}
a:active {
color: var(--col-1);
}
pre {
background-color: var(--col-0);
padding: 1em;
border: 0;
}
h1,
h2,
h3,
h4,
h5 {
margin-bottom: 0.1rem;
}
hr {
border-color: var(--col-8);
}
button,
input {
font-family: monospace;
background-color: var(--col-bg);
border: 2px solid var(--col-8);
color: var(--col-fg);
width: auto;
height: 30px;
}
button.big {
width: 150px;
height: 50px;
}
input {
width: 75%;
cursor: pointer;
color: var(--col-fg);
}
button:hover {
background-color: var(--col-fg);
color: var(--col-bg);
}
/* NAVBAR: */
.navbar {
background-color: #333;
overflow: hidden;
}
.navbar a {
float: left;
color: var(--col-fg);
text-align: center;
padding: 14px 16px;
text-decoration: none;
font-size: 17px;
border-right: 3px solid var(--col-8);
}
.navbar a:hover {
background-color: var(--col-bg);
color: var(--col-fg);
}
.navbar h1:hover {
background-color: var(--col-bg);
color: var(--col-fg);
}
.navbar a.active {
background-color: var(--col-14);
color: var(--col-15);
}
.user {
margin: 0;
border: 1px solid var(--col-8);
}
.user h1 {
font-family: monospace;
background: #333;
color: var(--col-fg);
margin: auto;
text-align: center;
line-height: 1;
color: var(--col-13);
font-size: 27px;
}
.user img {
padding: 6px 10px;
}
.user a {
color: var(--col-13);
padding: 0px 5px;
}
div.message {
border: 2px solid var(--col-8);
padding: 10px;
}
/*
*****************************
IMAGES
*****************************
*/
img.circle {
border-radius: 50%;
height: 30px;
width: 30px;
}
img.logo {
width: 266px;
height: 75px;
}
/*
*****************************
FOOTER
*****************************
*/
.footer {
position: fixed;
left: 0;
bottom: 0;
width: 100%;
background-color: var(--col-8);
color: white;
text-align: center;
}
.footer p {
font-size: 16px;
color: white;
}
/*
*****************************
ADMIN TEXT
*****************************
*/
.admin {
position: fixed;
left: 0;
top: 0;
height: 20px;
width: 100%;
text-align: center;
}
.admin p {
font-size: 16px;
margin: auto;
color: var(--col-15);
}
.admin a {
background-color: var(--col-13);
border: 2px solid var(--col-8);
height: 20px;
color: var(--col-15);
}
p {font-size: 18px;}

View file

@ -1,36 +0,0 @@
.threads {
width: 100%;
padding: 20px;
}
.threads-box {
width: 100%;
padding: 8px;
box-shadow: 0 0 5px 0 #cbcbcb;
display: flex;
justify-content: space-between;
margin: 0px 0px 8px 0px;
cursor: pointer;
align-items: center;
}
.threads-box:hover {
background-color: #e2e2e2;
}
.thread-box-title {
padding: 10px;
font-size: 18px;
font-weight: 700;
}
.thread-box-title>span {
color: #ff0000;
}
@media (max-width: 480px) {
.threads-box {
flex-direction: column;
}
}

View file

@ -1,41 +0,0 @@
.users {
width: 100%;
padding: 20px;
display: grid;
grid-template-columns: 1fr 1fr 1fr 1fr;
gap: 10px;
}
.user-box {
width: 100%;
padding: 20px;
display: flex;
flex-direction: column;
align-items: center;
box-shadow: 0 0 5px 0 #beb9b9;
}
.user-box-title {
padding: 10px;
margin: 8px;
font-weight: 500;
}
.user-box-title>span {
color: #ff0000;
}
.user-box-img {
width: 80px;
height: 80px;
}
@media (max-width: 992px) {
.users {
display: block;
}
.user-box {
margin-bottom: 10px;
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

View file

@ -1,117 +0,0 @@
import request from "./request.js";
const messages = document.getElementById("messages");
let messages_raw = [];
function renderMessage(message) {
const messageElement = document.createElement("div");
messageElement.classList.add("message");
messageElement.setAttribute("id", "message-" + message.id);
messageElement.innerHTML = `
<h3 style="float:right;">${new Date(message.time).toLocaleString()}</h3>
<h2>
<img class="circle" src=${message.author.avatar} alt=${message.author.name}>
<a href=${"/users/" + message.author.id}> ${message.author.name}</a>:
</h2>
<p>${message.content.replaceAll("\n", "<br>")}</p><br>
<div id="message-delete-${message.id}">
${!message.deleted ?
`<form style="display:inline;">
<button id="delete_message" value="${message.id}" type="submit">DELETE</button>
</form>` :
"<h3 style=\"display:inline;\">This message has been deleted</h3>"}
</div>
<div style="float: right;">
<h3 id="count${message.id}" style="display:inline;">${message.reactCount}</h3>
<button style="display:inline;" id="like" value="${message.id}">+🔼</button>
<button style="display:inline;" id="dislike" value="${message.id}" >-🔽</button>
</div>
`;
messages.appendChild(messageElement);
messages.innerHTML += "<br>";
};
/**
* Main Renderer
*/
(async () => {
messages_raw = await fetch(`/api/threads/${messages.getAttribute("value")}/messages/`).then(res => res.json());
if (messages_raw?.error) {
document.getElementById("messages").innerHTML
+= '<div class="message"><h1>THIS THREAD HAS NOT GOT ANY MESSAGE</h1></div>';
} else
for (const message of messages_raw)
renderMessage(message);
window.scrollTo(0, document.body.scrollHeight);
})();
/**
* Message Sender
*/
document.getElementById("send").addEventListener("submit", async e => {
e.preventDefault();
const form = e.target;
const data = new FormData(form);
request("/api/messages", "POST", { threadID: data.get("threadID"), content: data.get("content") })
.then(res => {
if (!res) return;
form.reset();
res.reactCount = 0;
renderMessage(res);
});
});
/**
* Button Listener
*/
document.addEventListener("click", async e => {
let page = 1;
// e.preventDefault();
if (e.target.id === "delete_thread") {
const response = await request("/api/threads/" + e.target.value + "/delete");
if (response.deleted) {
alert("Thread deleted");
window.location.href = "/threads";
}
} else if (e.target.id === "delete_message") {
e.preventDefault();
const response = await request(`/api/messages/${e.target.value}/delete`);
if (response.deleted) {
alert("Message deleted");
document.getElementById("message-delete-" + e.target.value).innerHTML = "<h3 style=\"display:inline;\">This message has been deleted</h3>";
}
} else if (e.target.id === "left_page") {
e.preventDefault();
}else if (e.target.id === "right_page") {
e.preventDefault();
const response = await request(`/api/messages/${e.target.value}/delete`);
if (response.deleted) {
alert("Message deleted");
document.getElementById("message-delete-" + e.target.value).innerHTML = "<h3 style=\"display:inline;\">This message has been deleted</h3>";
}
}/*else if (e.target.id === "edit_thread") {
window.location.href = "/threads/<%= thread.id }/edit";
} */
if (!e.target.id.includes("like")) return;
const res = await request("/api/messages/" + e.target.value + "/react/" + e.target.id)
document.getElementById("count" + e.target.value).innerHTML = res.reactCount;
});

View file

@ -1,19 +0,0 @@
const { UserModel, ThreadModel, MessageModel } = require("../models")
const { Router } = require("express");
const app = Router();
app.get("/", async (req, res) => {
const
mem = process.memoryUsage().heapUsed / Math.pow(2, 20),
users = await UserModel.count({deleted:false}),
threads = await ThreadModel.count({deleted:false}),
messages = await MessageModel.count({deleted:false}),
user = req.user;
res.render("index", { mem, user, users, threads, messages })
})
module.exports = app;

View file

@ -1,17 +0,0 @@
const { Router } = require("express")
const app = Router();
app.get("/", async (req, res) => {
if (!req.session.userid) return res.redirect('/login');
const user = req.user;
if (!user?.admin) return res.error( 403, "You have not got permissions for view to this page.");
res.render("admin", { user, user2: false })
});
module.exports = app;

View file

@ -1,45 +0,0 @@
const { Router } = require("express")
const app = Router();
const bcrypt = require("bcrypt");
const { request, response } = require("express");
const { SecretModel, UserModel } = require("../../models")
/**
* Auth checker
* @param {request} req
* @param {response} res
*/
app.use(async (req, res, next) => {
res.error = (status, error) => res.status(status).json({error});
res.complate = result => res.status(200).json(result);
if (req.user) return next();
const { username = null, password = null } = req.headers;
if (!username || !password)
return res.error(401, "Authorise headers are missing")
const user = await SecretModel.findOne({ username });
if (!user)
return res.error(401, "We have not got any user has got this name")
const validPassword = await bcrypt.compare(password, user.password);
if (!validPassword)
return res.error(401, 'Incorrect Password!')
req.user = await UserModel.findOne({ name: req.headers.username });
next();
});
/* will add for loop */
app.use("/messages", require("./routes/messages"))
app.use("/users", require("./routes/users"))
app.use("/threads", require("./routes/threads"))
app.all("*", (req, res) => res.error(400, "Bad request"));
module.exports = app;

View file

@ -1,90 +0,0 @@
const { MessageModel, ThreadModel } = require("../../../models");
const rateLimit = require('express-rate-limit')
const { Router } = require("express")
const app = Router();
app.get("/:id", async (req, res) => {
const message = await MessageModel.get(req.params.id);
if (!message || (message.deleted && req.user && !req.user.admin)) return res.error(404, `We don't have any message with id ${req.params.id}.`);
res.complate(message.toObject({ virtuals: true }));
})
app.post("/", rateLimit({
windowMs: 60_000, max: 1, standardHeaders: true, legacyHeaders: false,
handler: (request, response, next, options) =>
!request.user.admin ?
response.error(options.statusCode, "You are begin ratelimited")
: next()
}), async (req, res) => {
const { threadID = null, content = null } = req.body;
if (!content) return res.error(400, "Missing message content in request body.");
const thread = await ThreadModel.get(threadID);
if (!thread) return res.error(404, `We don't have any thread with id ${threadID}.`);
const message = await new MessageModel({ content, author: req.user, threadID: thread.id }).takeId();
await message.save();
await thread.push(message.id).save();
res.complate(message.toObject({ virtuals: true }));
})
app.post("/:id/react/:type", async (req, res) => {
const message = await MessageModel.get(req.params.id);
if (message) {
if (req.user.id in message.react)
delete message.react[req.session.userid];
else
message.react[req.session.userid] = req.params.type === "like";
message.markModified("react");
await message.save();
res.complate(message.toObject({ virtuals: true }));
} else error(res, 404, `We don't have any message with id ${req.params.id}.`);
});
app.post("/:id/delete", async (req, res) => {
const message = await MessageModel.get(req.params.id);
if (!message || (message.deleted && req.user && !req.user.admin))
return res.error(404, `We don't have any message with id ${req.params.id}.`);
const user = req.user;
if (user.id != message.authorID && !user.admin)
return res.error(403, "You have not got permission for this.");
message.deleted = true;
await message.save();
res.complate(message.toObject({ virtuals: true }));
})
app.post("/:id/undelete", async (req, res) => {
if (!req.user.admin) return res.error(403, "You have not got permission for this.");
const message = await MessageModel.get(req.params.id);
if (!message ) return res.error(404, `We don't have any message with id ${req.params.id}.`);
if (!message.deleted) return res.error(404, "This message is not deleted, first, delete it.");
message.deleted = false;
await message.save();
res.complate(message.toObject({ virtuals: true }));
})
module.exports = app;

View file

@ -1,83 +0,0 @@
const { MessageModel, ThreadModel } = require("../../../models");
const { Router } = require("express")
const app = Router();
app.get("/:id", async (req, res) => {
const { id } = req.params;
const thread = await ThreadModel.get(id);
if (thread && (req.user?.admin || !thread.deleted))
res.complate(thread.toObject({ virtuals: true }));
else
return res.error(404, `We don't have any thread with id ${id}.`);
});
app.get("/:id/messages/", async (req, res) => {
const { id } = req.params;
const limit = Number(req.query.limit);
const query = { threadID: id };
if (!req.user.admin) query.deleted = false;
const options = { sort: { date: -1 } };
if (limit) options.limit = limit;
const messages = await MessageModel.find(query, null, options)
if (!messages.length) return res.error(404, "We don't have any messages in this thread.");
res.complate(messages.map(x => x.toObject({ virtuals: true })));
})
app.post("/", async (req, res) => {
const { title = null, content = null } = req.body;
if (!content || !title) return res.error(400, "Missing content/title in request body.");
const user = req.user;
const thread = await new ThreadModel({ title, author: user }).takeId()
const message = await new MessageModel({ content, author: user, threadID: thread.id }).takeId()
await thread.push(message.id).save();
await message.save();
res.complate(thread.toObject({ virtuals: true }));
});
app.post("/:id/delete", async (req, res) => {
const thread = await ThreadModel.get(req.params.id);
if (!thread || thread.deleted) return res.error(404, `We don't have any thread with id ${req.params.id}.`);
const user = req.user;
if (user.id != thread.authorID && !user.admin)
return res.error(403, "You have not got permission for this.");
thread.deleted = true;
await thread.save();
res.complate(thread.toObject({ virtuals: true }));
})
app.post("/:id/undelete", async (req, res) => {
if (!req.user.admin) return res.error(403, "You have not got permission for this.");
const thread = await ThreadModel.get(req.params.id);
if (!thread ) return res.error(404, `We don't have any thread with id ${req.params.id}.`);
if (!thread.deleted) return res.error(404, "This thread is not deleted, first, delete it.");
thread.deleted = false;
await thread.save();
res.complate(thread.toObject({ virtuals: true }));
})
module.exports = app;

View file

@ -1,65 +0,0 @@
const { UserModel } = require("../../../models");
const { Router } = require("express")
const app = Router();
app.get("/:id", async (req, res) => {
const { id = null } = req.params;
const member = await UserModel.get(id);
if (!member || (member.deleted && !req.user.admin)) return res.error(404, `We don't have any user with id ${id}.`);
res.complate(member);
});
app.post("/:id/delete/", async (req, res) => {
const user = req.user;
if (!user.admin)
return res.error(403, "You have not got permission for this.");
const { id = null } = req.params;
const member = await UserModel.get(id);
if (!member || member.deleted) return res.error(404, `We don't have any user with id ${id}.`);
member.deleted = true;
await member.save();
res.complate(member);
});
app.post("/:id/undelete/", async (req, res) => {
if (!req.user.admin) return res.error(403, "You have not got permission for this.");
const member = await UserModel.get(id);
if (!member ) return res.error(404, `We don't have any user with id ${req.params.id}.`);
if (!member.deleted) return res.error(404, "This user is not deleted, first, delete it.");
member.deleted = false;
await member.save();
res.complate(member.toObject({ virtuals: true }));
})
app.post("/:id/admin/", async (req, res) => {
const user = req.user;
if (!user.admin) return res.error(403, "You have not got permission for this.");
const user2 = await UserModel.get(req.params.id);
if (!user2)
return res.error(404, `We don't have any user with id ${id}.`);
else {
user2.admin = true;
await user2.save()
}
res.complate(user2);
});
module.exports = app;

View file

@ -1,38 +0,0 @@
const { UserModel, SecretModel } = require("../models");
const { Router } = require("express");
const app = Router();
const bcrypt = require("bcrypt");
app.get("/", (req, res) => res.render("login",{redirect: req.query.redirect,user:null}));
app.post("/", async (req, res) => {
req.session.userid = null;
const { username = null, password = null } = req.body;
if (username && password) {
const user = await SecretModel.findOne({ username });
if (user) {
const validPassword = await bcrypt.compare(password, user.password);
if (!validPassword) return res.error( 403, 'Incorrect Password!')
const member = await UserModel.findOne({ name: username });
if (!member || member.deleted) return res.error( 403, 'Incorrect Username and/or Password!')
req.session.userid = user.id;
res.redirect( req.query.redirect || '/');
} else
res.error( 403, 'Incorrect Username and/or Password!')
} else
res.error( 400, "You forgot entering some values")
})
module.exports = app;

View file

@ -1,49 +0,0 @@
const { UserModel, SecretModel } = require("../models");
const { Router } = require("express")
const bcrypt = require("bcrypt");
const rateLimit = require('express-rate-limit')
const app = Router();
app.get("/", (req, res) => res.render("register",{user:null}));
app.post("/", rateLimit({
windowMs: 24*60*60_000, max: 1, standardHeaders: true, legacyHeaders: false,
handler: (request, response, next, options) =>
response.error(options.statusCode, "You are begin ratelimited")
}), async (req, res) => {
req.session.userid = null;
let { username = null, password = null, avatar } = req.body;
if (username && password) {
const user = await SecretModel.findOne({ username });
if (user)
res.error(res, 400, `We have got an user named ${username}!`)
else {
const user2 = new UserModel({ name: req.body.username, avatar })
await user2.takeId()
await user2.save();
const salt = await bcrypt.genSalt(10);
password = await bcrypt.hash(password, salt);
await SecretModel.create({ username, password, id: user2.id })
req.session.userid = user2.id;
res.redirect('/');
}
} else
res.error(res, 400, "You forgot entering some values")
})
module.exports = app;

View file

@ -1,44 +0,0 @@
const { Router } = require("express");
const app = Router();
const { ThreadModel, MessageModel } = require("../models")
app.get("/", async (req, res) => {
const user = req.user;
const threads = await ThreadModel.find(user?.admin ? {} : { deleted: false }).limit(10);
return res.render("threads", { threads, user });
});
app.get("/create*", async (req, res) => {
const user = req.user
res.render("create_thread", { user })
});
app.get("/:id", async (req, res) => {
const { id } = req.params;
const thread = await ThreadModel.get(id);
const user = req.user;
if (thread && (user?.admin || !thread.deleted)) {
const messages = await Promise.all(thread.messages.map(async id => {
const message = await MessageModel.get(id)
return user?.admin || !message?.deleted ? message.toObject({ virtuals: true }) : null;
}));
res.render("thread", { thread, messages, user,scroll:req.query.scroll || false });
} else
res.error( 404, "We have not got this thread.");
});
module.exports = app;

View file

@ -1,28 +0,0 @@
const { Router } = require("express");
const app = Router();
const { UserModel, MessageModel, ThreadModel } = require("../models");
app.get("/", async ({ user }, res) => {
const users = await UserModel.find(user?.admin ? {} : { deleted: false });
return res.render("users", { users, user })
});
app.get("/:id", async (req, res) => {
const user = req.user
const { id = null } = req.params;
const member = await UserModel.get(id);
if (member && (user?.admin || !member.deleted)) {
const message = await MessageModel.count({ "author.id": id });// this place was having problem. fixed
const thread = await ThreadModel.count({ "author.id": id });
res.render("user", { user, member, counts: { message, thread } })
}
else res.error(404, "We have not got this user.");
});
module.exports = app;

92
src/index.js Normal file
View file

@ -0,0 +1,92 @@
require("dotenv").config();
const
{ def_theme, forum_name, description, limits, global_ratelimit: RLS, discord_auth, host } = require("../config.json"),
{ UserModel, BanModel } = require("./models"),
port = process.env.PORT || 3000,
mongoose = require("mongoose"),
express = require('express'),
fs = require("fs"),
{ join } = require("path"),
app = express(),
{ mw: IP } = require('request-ip'),
{ RL, themes } = require('./lib'),
SES = require('express-session'),
MS = require("connect-mongo"),
DB = mongoose.connect(process.env.MONGO_DB_URL)
.then(async m => {
console.log("Database is connected with", (app.ips = await BanModel.find({})).length, "banned IPs");
return m.connection.getClient()
});
app.ips = [];
app.onlines = new Map();
setInterval(() => {
const now = Date.now();
for (const [ip, lastSeen] of app.onlines.entries())
if (now - lastSeen > 1000 * 60 * 5)
app.onlines.delete(ip);
}, 1000 * 60 * 5);
app.set("view engine", "ejs");
app.set("limits", limits);
for (const theme of themes)
app.use(`/themes/${theme.codename}`, express.static(join(__dirname, "themes", theme.codename, "public")));
app.use(express.static(join(__dirname, "public")), express.json(), express.urlencoded({ extended: true }), IP(),
SES({ secret: process.env.SECRET, store: MS.create({ clientPromise: DB, stringify: false }), resave: false, saveUninitialized: false }),
async (req, res, next) => {
if (app.ips.includes(req.clientIp)) return res.status(403).send("You are banned from this forum.");
const lastSeen = Date.now();
req.user = req.session.userID ? await UserModel.findOneAndUpdate({ id: req.session.userID }, {
lastSeen, $addToSet: { ips: req.clientIp }
}) : null;
app.onlines.set(req.clientIp, lastSeen);
let theme = req.user?.theme || def_theme;
if (!themes.some(t => t.codename === theme.codename))
theme = def_theme;
res.reply = (page, options = {}, status = 200) => {
const road = join(__dirname, "themes", theme.codename, "views", `${page}.ejs`);
const renderpage = fs.existsSync(road) ? road : join(__dirname, "themes", def_theme, "views", `${page}.ejs`);
return res.status(status).render(renderpage, {
dataset: {
themes, theme, forum_name, description,
getFile: file => join(__dirname, "themes", file),
},
user: req.user,
...options
});
}
res.error = (type, error) => res.reply("error", { type, error }, type);
if (req.user?.deleted) {
req.session.destroy();
return res.error(403, "Your account has been deleted.");
}
if (req.user && req.user.state == "APPROVAL" && !req.user.admin && !req.url.startsWith("/auth/email")) return res.error(403, "Your account is not approved yet.");
next();
}
);
if (RLS.enabled) app.use(RL(RLS.windowMs, RLS.max));
if (discord_auth)
app.set("DISCORD_AUTH_URL", `https://discord.com/api/oauth2/authorize?client_id=${process.env.DISCORD_ID}&redirect_uri=${host}%2Fauth%2Fdiscord&response_type=code&scope=identify`);
for (const file of fs.readdirSync(join(__dirname, "routes")))
app.use("/" + file.replace(".js", ""), require(`./routes/${file}`));
app.all("*", (req, res) => res.error(404, "This page does not exist on this forum."));
app.listen(port, () => console.log(`${forum_name} on port:`, port));

34
src/lib.js Normal file
View file

@ -0,0 +1,34 @@
const RL = require('express-rate-limit');
const nodemailer = require("nodemailer");
const config = require("../config.json");
const crypto = require("crypto");
const { readdirSync } = require('fs');
const { join } = require('path');
require("dotenv").config();
module.exports = {
themes: readdirSync(join(__dirname, "themes")).map(f => require(`./themes/${f}`)),
threadEnum: ["OPEN", "APPROVAL", "DELETED"],
userEnum: ["ACTIVE", "APPROVAL", "DELETED", "BANNED"],
RL(windowMs = 60_000, max = 1) {
return RL({
windowMs, max, standardHeaders: true, legacyHeaders: false,
handler: (req, res, next, opts) => !req.user?.admin ? res.error(opts.statusCode, "You are begin ratelimited") : next()
})
},
getGravatar(email, size) {
return `https://www.gravatar.com/avatar/${crypto.createHash('md5').update(email).digest("hex")}?d=mp${size ? `&size=${size}` : ''}`;
},
// eslint-disable-next-line no-useless-escape
emailRegEx: /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/,
}
if (config.email_auth)
module.exports.transporter = nodemailer.createTransport({
service: process.env.EMAIL_SERVICE, direct: true, secure: true,
auth: {
user: process.env.EMAIL_USER,
pass: process.env.EMAIL_PASS
}
});

10
src/models/Ban.js Normal file
View file

@ -0,0 +1,10 @@
const mongoose = require("mongoose")
const schema = new mongoose.Schema({
ip: { type: String, unique: true },
reason: { type: String, default: "No reason given" },
authorID: { type: String }
});
const model = mongoose.model('ban', schema);
module.exports = model;

23
src/models/Category.js Normal file
View file

@ -0,0 +1,23 @@
const mongoose = require("mongoose")
const schema = new mongoose.Schema({
name: { type: String, unique: true },
desp: String, position: Number,
id: { type: String, unique: true },
authorID: { type: String }
});
schema.methods.takeId = async function () {
// eslint-disable-next-line no-use-before-define
this.id = await model.countDocuments() || 0;
return this;
}
schema.methods.getLink = function (id = this.id) {
return "/categories/" + id;
}
const model = mongoose.model('category', schema);
module.exports = model;

47
src/models/Message.js Normal file
View file

@ -0,0 +1,47 @@
const mongoose = require("mongoose");
const cache = require("./cache");
const { limits } = require("../../config.json");
const schema = new mongoose.Schema({
id: { type: String, unique: true },
authorID: {
type: String, get(v) { return v || this.author?.id }
},
author: {
type: Object, set(v) {
this.authorID = v.id;
return v;
}
},
threadID: String,
content: { type: String, maxlength: limits.message },
oldContents: [String],
time: { type: Date, default: Date.now },
deleted: { type: Boolean, default: false },
edited: { type: Boolean, default: false },
react: {
like: [String],
dislike: [String]
}
})
schema.methods.get_author = cache.getAuthor
schema.methods.takeId = async function () {
// eslint-disable-next-line no-use-before-define
this.id = await model.countDocuments() || 0;
return this;
}
schema.methods.getLink = function (id = this.id) {
return "/messages/" + id;
}
const model = mongoose.model('message', schema);
model.get = async id => {
const message = await model.findOne({ id })
return await message.get_author();
};
module.exports = model;

68
src/models/Thread.js Normal file
View file

@ -0,0 +1,68 @@
const mongoose = require("mongoose");
const cache = require("./cache")
const MessageModel = require("./Message");
const { limits, default_thread_state } = require("../../config.json");
const { threadEnum } = require("../lib");
const schema = new mongoose.Schema({
id: { type: String, unique: true },
categoryID: String,
authorID: {
type: String, get(v) { return v || this.author?.id }
},
author: {
type: Object, set(v) {
this.authorID = v.id;
return v;
}
},
title: { type: String, maxlength: limits.title },
oldTitles: [String],
time: { type: Date, default: Date.now },
edited: { type: Boolean, default: false },
state: { type: String, default: default_thread_state, enum: threadEnum, uppercase: true },
messages: [String],
views: { type: Number, default: 0 }
});
schema.methods.get_author = cache.getAuthor;
schema.virtual("deleted").get(function () {
return this.state === "DELETED";
}).set(function (value) {
this.set({ state: value ? "DELETED" : "OPEN" });
});
schema.methods.get_category = async function () {
return await require("./Category").findOne({ id: this.categoryID }) || { id: this.categoryID, name: "Unknown" };
}
schema.methods.messageCount = async function (admin = false) {
const query = { threadID: this.id };
if (!admin) query.deleted = false;
return await MessageModel.countDocuments(query) || 0;
};
schema.methods.push = function (messageID) {
this.messages.push(messageID);
return this;
}
schema.methods.takeId = async function () {
// eslint-disable-next-line no-use-before-define
this.id = await model.countDocuments() || 0;
return this;
}
schema.methods.getLink = function (id = this.id) {
return "/threads/" + id;
}
const model = mongoose.model('thread', schema);
model.get = id => model.findOne({ id }).then(thread => thread.get_author());
module.exports = model;

51
src/models/User.js Normal file
View file

@ -0,0 +1,51 @@
const mongoose = require("mongoose")
const { def_theme, limits, default_user_state } = require("../../config.json");
const { userEnum } = require("../lib");
const schema = new mongoose.Schema({
id: { type: String, unique: true },
discordID: { type: String },
name: { type: String, maxlength: limits.names },
avatar: { type: String, default: "/images/avatars/default.jpg" },
time: { type: Date, default: Date.now },
edited: { type: Boolean, default: false },
about: { type: String, default: "", maxlength: limits.desp },
admin: { type: Boolean, default: false },
theme: {
codename: { type: String, default: def_theme.codename },
language: { type: String, default: def_theme.language }
},
lastSeen: { type: Date, default: Date.now, select: false },
hideLastSeen: { type: Boolean, default: false },
ips: { type: [String], default: [], select: false },
password: { type: String, select: false },
discord_code: { type: String, select: false },
state: { type: String, default: default_user_state, enum: userEnum, uppercase: true },
email: { type: String, select: false },
email_code: { type: String, select: false },
});
schema.virtual("deleted").get(function () {
return this.state === "DELETED";
}).set(function (value) {
this.set({ state: value ? "DELETED" : "ACTIVE" });
});
schema.virtual("active").get(function () {
return this.state === "ACTIVE";
})
schema.methods.takeId = async function () {
// eslint-disable-next-line no-use-before-define
this.id = String(await model.countDocuments() || 0);
return this;
}
schema.methods.getLink = function (id = this.id) {
return "/users/" + id;
}
const model = mongoose.model('user', schema);
model.get = (id, select = "") => model.findOne({ id }, select);
module.exports = model;

17
src/models/cache.js Normal file
View file

@ -0,0 +1,17 @@
const UserModel = require("./User");
const UserCache = new Map();
module.exports.getAuthor = async function () {
const id = this.authorID || this.author?.id;
let user = UserCache.get(id);
if (!user)
UserCache.set(id, user = await UserModel.findOne({ id }));
if (!this.get('authorID', null, { getters: false })) {
this.authorID = user.id;
await this.save();
}
this.author = user;
return this;
}

7
src/models/index.js Normal file
View file

@ -0,0 +1,7 @@
module.exports = {
CategoryModel: require("./Category"),
MessageModel: require("./Message"),
ThreadModel: require("./Thread"),
UserModel: require("./User"),
BanModel: require("./Ban")
};

BIN
src/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

View file

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

63
src/public/js/avatar.js Normal file
View file

@ -0,0 +1,63 @@
function b64toBlob(b64Data, contentType = "", sliceSize = 512) {
const byteCharacters = atob(b64Data);
const byteArrays = [];
for (let offset = 0; offset < byteCharacters.length; offset += sliceSize) {
const slice = byteCharacters.slice(offset, offset + sliceSize);
const byteNumbers = new Array(slice.length);
for (let i = 0; i < slice.length; i++)
byteNumbers[i] = slice.charCodeAt(i);
const byteArray = new Uint8Array(byteNumbers);
byteArrays.push(byteArray);
}
const blob = new Blob(byteArrays, {
type: contentType
});
return blob;
}
const image = document.getElementById('image');
const button = document.getElementById('button');
const reader = new FileReader();
document.getElementById("file-input")
.addEventListener("change", function () {
reader.onload = () => {
image.src = reader.result;
let croppable = false;
// eslint-disable-next-line no-undef
const cropper = new Cropper(image, {
aspectRatio: 1,
viewMode: 1,
ready: function () {
croppable = true;
},
});
button.onclick = async () => {
if (!croppable) return;
const croppedCanvas = cropper.getCroppedCanvas();
// const croppedImage = document.getElementById('cropped-image');
const body = new FormData(document.createElement('form'))
const block = croppedCanvas.toDataURL().split(";");
const contentType = block[0].split(":")[1];
const realData = block[1].split(",")[1];
body.append('avatar', b64toBlob(realData, contentType));
// eslint-disable-next-line no-undef
const res = await fetch('/api/users/'+MEM_ID+'/avatar', {
method: 'POST',
body
}).then(res => res.json());
if (res.error) return alert(res.error);
alert('Success!');
location.reload();
};
}
reader.readAsDataURL(event.target.files[0]);
});

15
src/public/js/editor.js Normal file
View file

@ -0,0 +1,15 @@
function editor( uniqueId, textarea = document.getElementById("textarea")) {
return new SimpleMDE({
autosave: {
enabled: true,
uniqueId,
delay: 1000,
},// width: 100%;
element: textarea,
spellChecker: false
});
}

3
src/public/js/modal.js Normal file
View file

@ -0,0 +1,3 @@
for (const modal of document.querySelectorAll("[modal]"))
modal.onclick = () => {
document.querySelector(modal.getAttribute("modal")).classList.toggle('active')};

84
src/public/js/thread.js Normal file
View file

@ -0,0 +1,84 @@
import request from "./request.js";
let editing;
// THREAD:
window.edit_thread = async function (id) {
const title = prompt("Enter new title!");
const res = await request(`/api/threads/${id}/`, "PATCH", { title });
if (res.error) return;
alert(`Thread updated`);
document.getElementById("title").innerHTML = title;
}
window.delete_thread = async function (id) {
const res = await request(`/api/threads/${id}/`, "DELETE");
if (res.error) return;
alert(`Thread deleted`);
location.reload();
}
window.undelete_thread = async function (id) {
const res = await request(`/api/threads/${id}/`, "PATCH", { state: "OPEN" });
if (res.error) return;
alert(`Thread undeleted`);
location.reload();
}
// MESSAGES:
window.send_edit = async function (id) {
const message = document.getElementById(`message-${id}`);
const content = editing.value();
const res = await request(`/api/messages/${id}/`, "PATCH", { content });
if (res.error) return;
alert(`Message updated`);
message.querySelector(".content").innerHTML = converter.makeHtml(res.content);
}
window.edit_message = async function (id) {
const message = document.getElementById(`message-${id}`)
const content = message.querySelector(".content");
content.innerHTML = `
<textarea rows="4" cols="40" id="content"></textarea>
<button onclick="send_edit(${id});" class="btn-primary">Edit!</button>`;
const cnt = message.querySelector("#content");
editing = new SimpleMDE({
element: cnt,
spellChecker: false,
height: "200px"
});
editing.value(content.rawText)
}
window.undelete_message = async function (id) {
const response = await request(`/api/messages/${id}/`, "PATCH", { deleted: false });
if (response.deleted) return;
const message = document.getElementById("message-" + id);
message.querySelector("#deleted").remove();
message.querySelector(".dots-menu").innerHTML = `
<a onclick="delete_message('${id}');">DELETE</a>
<a onclick="edit_message('${id}');">EDIT</a>`
}
window.delete_message = async function (id) {
const response = await request(`/api/messages/${id}/`, "DELETE");
if (!response.deleted) return
const message = document.getElementById(`message-${id}`);
alert("Message deleted");
message.querySelector(".dots-menu").innerHTML = `<a onclick="undelete_message('${id}');">UNDELETE</a>`;
let dots = message.querySelector(".dots");
dots.innerHTML = "<i class='bx bx-trash bx-sm' id='deleted' style='color: var(--important)'></i>" + dots.innerHTML;
}
window.react = async function (id, type) {
const res = await request(`/api/messages/${id}/react/${type}`)
const message = document.getElementById(`message-${id}`);
for (const react in res.react)
message.querySelector("#" + react).innerHTML = res.react[react].length;
}

View file

@ -0,0 +1,304 @@
/*!
* Cropper.js v1.5.12
* https://fengyuanchen.github.io/cropperjs
*
* Copyright 2015-present Chen Fengyuan
* Released under the MIT license
*
* Date: 2021-06-12T08:00:11.623Z
*/
.cropper-container {
direction: ltr;
font-size: 0;
line-height: 0;
position: relative;
-ms-touch-action: none;
touch-action: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
.cropper-container img {
display: block;
height: 100%;
image-orientation: 0deg;
max-height: none !important;
max-width: none !important;
min-height: 0 !important;
min-width: 0 !important;
width: 100%;
}
.cropper-wrap-box,
.cropper-canvas,
.cropper-drag-box,
.cropper-crop-box,
.cropper-modal {
bottom: 0;
left: 0;
position: absolute;
right: 0;
top: 0;
}
.cropper-wrap-box,
.cropper-canvas {
overflow: hidden;
}
.cropper-drag-box {
background-color: #fff;
opacity: 0;
}
.cropper-modal {
background-color: #000;
opacity: 0.5;
}
.cropper-view-box {
display: block;
height: 100%;
outline: 1px solid #39f;
outline-color: rgba(51, 153, 255, 0.75);
overflow: hidden;
width: 100%;
}
.cropper-dashed {
border: 0 dashed #eee;
display: block;
opacity: 0.5;
position: absolute;
}
.cropper-dashed.dashed-h {
border-bottom-width: 1px;
border-top-width: 1px;
height: calc(100% / 3);
left: 0;
top: calc(100% / 3);
width: 100%;
}
.cropper-dashed.dashed-v {
border-left-width: 1px;
border-right-width: 1px;
height: 100%;
left: calc(100% / 3);
top: 0;
width: calc(100% / 3);
}
.cropper-center {
display: block;
height: 0;
left: 50%;
opacity: 0.75;
position: absolute;
top: 50%;
width: 0;
}
.cropper-center::before,
.cropper-center::after {
background-color: #eee;
content: ' ';
display: block;
position: absolute;
}
.cropper-center::before {
height: 1px;
left: -3px;
top: 0;
width: 7px;
}
.cropper-center::after {
height: 7px;
left: 0;
top: -3px;
width: 1px;
}
.cropper-face,
.cropper-line,
.cropper-point {
display: block;
height: 100%;
opacity: 0.1;
position: absolute;
width: 100%;
}
.cropper-face {
background-color: #fff;
left: 0;
top: 0;
}
.cropper-line {
background-color: #39f;
}
.cropper-line.line-e {
cursor: ew-resize;
right: -3px;
top: 0;
width: 5px;
}
.cropper-line.line-n {
cursor: ns-resize;
height: 5px;
left: 0;
top: -3px;
}
.cropper-line.line-w {
cursor: ew-resize;
left: -3px;
top: 0;
width: 5px;
}
.cropper-line.line-s {
bottom: -3px;
cursor: ns-resize;
height: 5px;
left: 0;
}
.cropper-point {
background-color: #39f;
height: 5px;
opacity: 0.75;
width: 5px;
}
.cropper-point.point-e {
cursor: ew-resize;
margin-top: -3px;
right: -3px;
top: 50%;
}
.cropper-point.point-n {
cursor: ns-resize;
left: 50%;
margin-left: -3px;
top: -3px;
}
.cropper-point.point-w {
cursor: ew-resize;
left: -3px;
margin-top: -3px;
top: 50%;
}
.cropper-point.point-s {
bottom: -3px;
cursor: s-resize;
left: 50%;
margin-left: -3px;
}
.cropper-point.point-ne {
cursor: nesw-resize;
right: -3px;
top: -3px;
}
.cropper-point.point-nw {
cursor: nwse-resize;
left: -3px;
top: -3px;
}
.cropper-point.point-sw {
bottom: -3px;
cursor: nesw-resize;
left: -3px;
}
.cropper-point.point-se {
bottom: -3px;
cursor: nwse-resize;
height: 20px;
opacity: 1;
right: -3px;
width: 20px;
}
@media (min-width: 768px) {
.cropper-point.point-se {
height: 15px;
width: 15px;
}
}
@media (min-width: 992px) {
.cropper-point.point-se {
height: 10px;
width: 10px;
}
}
@media (min-width: 1200px) {
.cropper-point.point-se {
height: 5px;
opacity: 0.75;
width: 5px;
}
}
.cropper-point.point-se::before {
background-color: #39f;
bottom: -50%;
content: ' ';
display: block;
height: 200%;
opacity: 0;
position: absolute;
right: -50%;
width: 200%;
}
.cropper-invisible {
opacity: 0;
}
.cropper-bg {
background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQAQMAAAAlPW0iAAAAA3NCSVQICAjb4U/gAAAABlBMVEXMzMz////TjRV2AAAACXBIWXMAAArrAAAK6wGCiw1aAAAAHHRFWHRTb2Z0d2FyZQBBZG9iZSBGaXJld29ya3MgQ1M26LyyjAAAABFJREFUCJlj+M/AgBVhF/0PAH6/D/HkDxOGAAAAAElFTkSuQmCC');
}
.cropper-hide {
display: block;
height: 0;
position: absolute;
width: 0;
}
.cropper-hidden {
display: none !important;
}
.cropper-move {
cursor: move;
}
.cropper-crop {
cursor: crosshair;
}
.cropper-disabled .cropper-drag-box,
.cropper-disabled .cropper-face,
.cropper-disabled .cropper-line,
.cropper-disabled .cropper-point {
cursor: not-allowed;
}

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

31
src/routes/.js Normal file
View file

@ -0,0 +1,31 @@
const { UserModel, ThreadModel, MessageModel, CategoryModel } = require("../models");
const { Router } = require("express");
const app = Router();
app.get("/", async (req, res) => {
const [
categories, users, threads, messages, newestMember, newestMessages, newestThreads, onlineMemberCount, onlineMembers
] = await Promise.all([
CategoryModel.countDocuments(),
UserModel.countDocuments({ deleted: false }),
ThreadModel.countDocuments({ state: "OPEN" }),
MessageModel.countDocuments({ deleted: false }),
UserModel.findOne({ deleted: false }, "name id").sort({ time: -1 }),
MessageModel.find({ deleted: false }).sort({ time: -1 }).limit(10),
ThreadModel.find({ state: "OPEN" }).sort({ time: -1 }).limit(10),
UserModel.countDocuments({ deleted: false, lastSeen: { $gt: Date.now() - 1000 * 60 * 5 } }),
UserModel.find({ deleted: false, hideLastSeen: false, lastSeen: { $gt: Date.now() - 1000 * 60 * 5 } }, "name id")
]),
onlineTotal = req.app.onlines.size,
onlineGuests = onlineTotal - onlineMemberCount;
res.reply("index", {
categories, users, threads, messages,
newestMember, newestMessages, newestThreads,
onlineMemberCount, onlineMembers, onlineGuests, onlineTotal
});
});
module.exports = app;

15
src/routes/admin.js Normal file
View file

@ -0,0 +1,15 @@
const { Router } = require("express");
const fs = require("fs");
const { BanModel, UserModel } = require("../models");
const app = Router();
app.use(async (req, res, next) => {
if (!req.user?.admin) return res.error(403, "You are not admin");
next();
});
app.get("/", async (req, res) => {
res.reply("admin", { bans: await BanModel.find({}), admins: await UserModel.find({ admin: true }) });
});
app.get("/config", async (req, res) => {
res.reply("config", { config: fs.readFileSync("./config.json", "utf8") });
});
module.exports = app;

39
src/routes/api/index.js Normal file
View file

@ -0,0 +1,39 @@
const { Router } = require("express");
const app = Router();
const fs = require("fs");
const bcrypt = require("bcrypt");
const { UserModel } = require("../../models");
const{join}=require("path");
app.use(async (req, res, next) => {
res.error = (status, error) => res.status(status).json({ error });
res.complate = result => res.status(200).json(result);
if (req.user) return next();
const authHeader = req.headers.authorization;
if (!authHeader) return res.error(401, "No authorization header");
const [name, password] = Buffer.from(authHeader.split(' ')[1], "base64").toString().split(":");
if (!name || !password)
return res.error(400, "Authorise headers are not well formed");
const user = await UserModel.findOne({ name });
if (!user || user.deleted) return res.error(401, `We don't have any user with name ${name}.`)
if (!user.active) return res.error(401, "Your account is not approved yet.");
if (!await bcrypt.compare(password, user.password)) return res.error(401, 'Incorrect Password!');
req.user = user;
next();
});
app.get("/me", (req, res) => res.complate(req.user))
for (const file of fs.readdirSync(join(__dirname, "routes")))
app.use("/" + file.replace(".js", ""), require(`./routes/${file}`));
app.all("*", (req, res) => res.error(400, "Bad request"));
module.exports = app;

View file

@ -0,0 +1,38 @@
const { BanModel } = require("../../../models");
const { Router } = require("express")
const app = Router();
app.use((req, res, next) => {
if (!req.user.admin) return res.error(403, "You have not got permission for this.");
next();
});
app.get("/", async (req, res) => {
const bans = await BanModel.find({});
res.complate(bans);
});
app.get("/:ip", async (req, res) => {
const ban = await BanModel.findOne({ ip: req.params.ip });
if (!ban) return res.error(400, "This ip is not banned.");
res.complate(ban);
});
app.post("/:ip", async (req, res) => {
if (await BanModel.exists({ ip: req.params.ip })) return res.error(400, "This ip is already banned.");
const ban = await BanModel.create({ ip: req.params.ip, reason: req.query.reason || "No reason given", authorID: req.user.id });
req.app.ips.push(req.params.ip);
res.complate(ban);
});
app.delete("/:ip/", async (req, res) => {
if (!await BanModel.exists({ ip: req.params.ip })) return res.error(400, "This ip is already not banned.");
res.complate(await BanModel.deleteOne({ ip: req.params.ip }));
});
module.exports = app;

View file

@ -0,0 +1,44 @@
const { CategoryModel } = require("../../../models");
const { Router } = require("express")
const app = Router();
app.use((req, res, next) => {
if (!req.user.admin) return res.error(403, "You have not got permission for this.");
next();
});
app.param("id", async (req, res, next, id) => {
req.category = await CategoryModel.findOne({ id });
if (!req.category) return res.error(404, `We don't have any category with id ${id}.`);
next();
});
app.get("/", async (req, res) => {
const categories = await CategoryModel.find({});
res.complate(categories);
});
app.get("/:id", async (req, res) => res.complate(req.category));
app.patch("/:id", async (req, res) => {
const { category } = req;
if (req.body.name) category.name = req.body.name;
if (req.body.desp) category.name = req.body.name;
res.complate(await category.save());
});
app.delete("/:id", async (req, res) => res.complate(await CategoryModel.deleteOne({ id: req.params.id })));
app.post("/", async (req, res) => {
const { name, desp } = req.body;
if (!name) return res.error(400, "You have to give a name for the category.");
if (await CategoryModel.exists({ name })) return res.error(400, "This category is already opened.");
const category = await new CategoryModel({ name, desp, authorID: req.user.id }).takeId();
res.complate(await category.save());
});
module.exports = app;

View file

@ -0,0 +1,25 @@
const { Router } = require("express")
const fs = require("fs");
const app = Router();
app.use((req, res, next) => {
if (!req.user.admin)
return res.error(403, "You have not got permission for this.");
next();
});
app.get("/", (req, res) => {
try {
return res.reply(JSON.parse(fs.readFileSync("./config.json", "utf8")));
} catch (e) {
res.error(500, e.message);
}
});
app.put("/", (req, res) => {
const write = req.query.text ? req.body : JSON.stringify(req.body, null, 4)
fs.writeFileSync("./config.json", write);
require.cache[require.resolve("../../../../config.json")] = require("../../../../config.json");
res.complate(require("../../../../config.json"));
});
module.exports = app;

View file

@ -0,0 +1,113 @@
const { MessageModel, ThreadModel } = require("../../../models");
const { RL } = require('../../../lib');
const { Router } = require("express")
const app = Router();
app.param("id", async (req, res, next, id) => {
req.message = await MessageModel.get(id);
if (!req.message) return res.error(404, `We don't have any message with id ${id}.`);
if (req.message.deleted && !req.user?.admin)
return res.error(404, `You do not have permissions to view this message with id ${id}.`)
next();
});
app.get("/:id", async (req, res) => res.complate(req.message));
app.delete("/:id/", async (req, res) => {
const { message, user } = req;
if (user.id != message.authorID && !user.admin)
return res.error(403, "You have not got permission for this.");
if (message.deleted) return res.error(404, "This message is already deleted.");
message.deleted = true;
await message.save()
res.complate(message);
});
app.patch("/:id/", async (req, res) => {
const { message, user } = req;
if (user.id !== message.authorID && !user.admin) return res.error(403, "You have not got permission for this.");
if (!Object.keys(req.body).some(Boolean)) return res.error(400, "Missing message informations for update in request body.");
const { content, deleted } = req.body;
const limits = req.app.get("limits");
if (content.length < 5 || content.length > limits.message) return res.error(400, `content must be between 5 - ${limits.message} characters`);
if (deleted === false) message.deleted = false;
message.content = content;
if (!message.oldContents.includes(content))
message.oldContents.push(content);
message.edited = true;
await message.save();
res.complate(message);
})
app.post("/", RL(), async (req, res) => {
const { threadID, content } = req.body;
if (!content) return res.error(400, "Missing message content in request body.");
const limits = req.app.get("limits");
if (content.length < 5 || content.length > limits.message) return res.error(400, `content must be between ${limits.message} characters`);
const thread = await ThreadModel.get(threadID);
if (!thread) return res.error(404, `We don't have any thread with id ${threadID}.`);
const message = await new MessageModel({ content, author: req.user, threadID: thread.id }).takeId();
await message.save();
await thread.push(message.id).save();
res.complate(message);
})
app.post("/:id/react/:type", async (req, res) => {
const { message } = req;
if (req.params.type == "like") {
if (message.react.like.includes(req.user.id))
message.react.like.pull(req.user.id);
else {
message.react.like.push(req.user.id);
message.react.dislike.pull(req.user.id);
}
} else if (req.params.type == "dislike") {
if (message.react.dislike.includes(req.user.id))
message.react.dislike.pull(req.user.id);
else {
message.react.dislike.push(req.user.id);
message.react.like.pull(req.user.id);
}
} else
return res.error(400, `We don't have any react type with name ${req.params.type}.`);
await message.save();
res.complate(message);
});
module.exports = app;

View file

@ -0,0 +1,40 @@
const { Router } = require("express")
const { MessageModel, ThreadModel, UserModel } = require("../../../models");
const app = Router();
app.use((req, res, next) => {
req.sq = {}
req.so = {}
const limit = Number(req.query.limit);
const skip = Number(req.query.skip);
if (!req.user.admin) req.sq.deleted = false;
if (limit) req.so.limit = limit;
if (skip) req.so.skip = skip;
next();
})
app.get("/users", async (req, res) => {
if (!req.query.q) return res.error(400, "Missing query parameter 'q' in request body.");
const results = await UserModel.find({ ...req.sq, name: { $regex: req.query.q, $options: "i" } }, null, req.so);
res.complate(results);
});
app.get("/messages", async (req, res) => {
if (!Object.values(req.query).length) return res.error(400, "Missing query parameters in request body.");
const query = { ...req.sq };
if (req.query.q) query.content = { $regex: req.query.q, $options: "i" };
if (req.query.authorID) query.authorID = req.query.authorID;
const results = await MessageModel.find(query, null, req.so)
res.complate(results);
});
app.get("/threads", async (req, res) => {
if (!Object.values(req.query).length) return res.error(400, "Missing query parameters in request body.");
const query = {};
if (!req.user.admin) query.state = "OPEN";
if (req.query.q) query.title = { $regex: req.query.q, $options: "i" };
if (req.query.authorID) query.authorID = req.query.authorID;
const results = await ThreadModel.find(query, null, req.so)
res.complate(results);
});
module.exports = app;

View file

@ -0,0 +1,15 @@
const {themes}= require("../../../lib");
const { Router } = require("express")
const app = Router();
app.get("/", async (req, res) => res.complate(themes));
app.get("/:codename", async (req, res) => {
const theme = themes.find(t => t.codename === req.params.codename);
if (!theme) return res.error(404, "Theme not found.");
res.complate(theme);
});
module.exports = app;

View file

@ -0,0 +1,111 @@
const { MessageModel, ThreadModel } = require("../../../models");
const { Router } = require("express")
const { RL, threadEnum } = require('../../../lib');
const app = Router();
app.param("id", async (req, res, next, id) => {
req.thread = await ThreadModel.get(id);
if (!req.thread) return res.error(404, `We don't have any thread with id ${id}.`);
if (req.thread.state !== "OPEN" && !req.user?.admin)
return res.error(404, `You do not have permissions to view this thread with id ${id}.`)
next();
});
app.get("/:id", async (req, res) => res.complate(req.thread));
app.get("/:id/messages/", async (req, res) => {
const { id } = req.params;
const limit = Number(req.query.limit);
const skip = Number(req.query.skip);
const query = { threadID: id };
if (!req.user.admin) query.deleted = false;
const options = { sort: { time: -1 } };
if (limit) options.limit = limit;
if (skip) options.skip = skip;
const messages = await MessageModel.find(query, null, options)
if (!messages.length) return res.error(404, "We don't have any messages in this with your query thread.");
res.complate(messages);
})
app.post("/", RL(5 * 60_000, 1), async (req, res) => {
const { title, content, category } = req.body;
if (!content || !title) return res.error(400, "Missing content/title in request body.");
const limits = req.app.get("limits");
if (title.length < 5 || title.length > limits.title) return res.error(400, `title must be between 5 - ${limits.title} characters`);
if (content.length < 5 || content.length > limits.message) return res.error(400, `content must be between 5 - ${limits.message} characters`);
const { user } = req;
const thread = await new ThreadModel({ title, author: user }).takeId()
if (category)
thread.categoryID = category;
const message = await new MessageModel({ content, author: user, threadID: thread.id }).takeId()
await thread.push(message.id).save();
await message.save();
res.complate(thread);
});
app.patch("/:id/", async (req, res) => {
const { user, thread } = req;
if (user.id !== thread.authorID && !user.admin) return res.error(403, "You have not got permission for this.");
if (!Object.values(req.body).some(Boolean)) return res.error(400, "Missing thread informations for update in request body.");
const { title, state } = req.body;
if (title) {
const limits = req.app.get("limits");
if (title.length < 5 || title.length > limits.title) return res.error(400, `title must be between 5 - ${limits.title} characters`);
if (thread.oldTitles.at(-1) == title) return res.error(400, "You can't use the same title as the previous one.");
thread.oldTitles.push(thread.title = title);
}
if (state) {
if (!user.admin)
return res.error(403, "You have not got permission for change state.");
if (thread.state === state) return res.error(400, "You can't change thread state to same state.");
if (!threadEnum.includes(state)) return res.error(400, "Invalid thread state.");
if (thread.deleted)
await MessageModel.updateMany({ threadID: thread.id }, { deleted: false });
thread.state = state;
}
await thread.save();
res.complate(thread);
})
app.delete("/:id/", async (req, res) => {
const { user, thread } = req;
if (user.id != thread.authorID && !user.admin)
return res.error(403, "You have not got permission for this.");
if (thread.deleted) return res.error(404, "This thread is already deleted.");
thread.deleted= true;
await thread.save();
await MessageModel.updateMany({ threadID: thread.id }, { deleted: true });
res.complate(thread);
})
module.exports = app;

View file

@ -0,0 +1,112 @@
const { UserModel, BanModel } = require("../../../models");
const { Router } = require("express");
const multer = require("multer");
const { themes, emailRegEx } = require("../../../lib")
const app = Router();
const { join } = require("path");
app.param("id", async (req, res, next, id) => {
if (id === "me") //
id = req.user.id;
req.member = await UserModel.get(id, req.user.admin ? "+lastSeen +ips" : "");
if (!req.member) return res.error(404, `We don't have any user with id ${id}.`);
if (req.member.deleted && !req.user?.admin)
return res.error(404, `You do not have permissions to view this user with id ${id}.`);
next();
});
app.get("/:id", async (req, res) => res.complate(req.member));
app.delete("/:id", async (req, res) => {
const { user, member } = req;
if (!user.admin)
return res.error(403, "You have not got permission for this.");
if (member.deleted) return res.error(404, `This user is with id ${member.id} already deleted.`);
member.deleted = true;
await member.save();
res.complate(member);
});
app.patch("/:id", async (req, res) => {
const { user, member } = req;
if (req.user.id !== member.id && !user.admin) return res.error(403, "You have not got permission for this.");
if (!Object.keys(req.body).some(Boolean)) return res.error(400, "Missing member informations in request body.");
const { name, about, admin, deleted, hideLastSeen, theme, email } = req.body;
if ((admin?.length || "deleted" in req.body) && !req.user.admin) return res.error(403, "You have not got permission for edit 'admin' and 'deleted' information, or bad request.");
const { names, desp } = req.app.get("limits");
if (name) {
if (name.length < 3 || names > 25) return res.error(400, `Username must be between 3 - ${names} characters`);
member.name = name;
}
if (about) {
if (about.length > desp) return res.error(400, `About must be under ${desp} characters`);
member.about = about;
}
if (theme && themes.some(t => t.codename === theme.codename))
member.theme = theme;
if (email) {
if (!emailRegEx.test(email)) return res.error(400, "E-mail is not valid");
if (await UserModel.exists({ email })) return res.error(400, "E-mail is already in use");
member.email = email;
}
if (typeof admin === "boolean" || ["false", "true"].includes(admin)) member.admin = admin;
if (deleted === false) member.deleted = false;
if (typeof hideLastSeen === "boolean" || ["false", "true"].includes(admin)) member.hideLastSeen = hideLastSeen;
member.edited = true;
res.complate(await member.save());
})
app.post("/:id/ban", async (req, res) => {
if (!req.user.admin) return res.error(403, "You have not got permission for this.");
const { member } = req;
for (const ip of member.ips)
try {
await BanModel.create({ ip, reason: `Ban for ${member.name}`, authorID: req.user.id });
req.app.ips.push(ip);
} catch {
continue;
}
res.complate(member);
});
const storage = multer.diskStorage({
destination: join(__dirname, "..", "..", "..", "public", "images", "avatars"),
filename: function (req, _file, cb) {
cb(null, req.member.id + ".jpg")
}
})
const upload = multer({ storage })
app.post("/:id/avatar", upload.single('avatar'), async (req, res) => {
const { member } = req;
if (req.user.id !== member.id && !req.user.admin) return res.error(403, "You have not got permission for this.");
if (!req.file) return res.error(400, "Missing avatar in request body.");
member.avatar = "/images/avatars/" + req.file.filename;
res.complate(await member.save());
});
module.exports = app;

98
src/routes/auth.js Normal file
View file

@ -0,0 +1,98 @@
const { Router } = require("express")
const { UserModel } = require("../models");
const app = Router();
const { host, email_auth } = require("../../config.json")
app.get("/discord", async (req, res) => {
const client_id = process.env.DISCORD_ID;
if (!client_id) return res.error(404, "Discord auth is disabled")
const { code } = req.query;
if (!code) return res.error(400, "No code provided");
try {
const response = await fetch('https://discord.com/api/v10/oauth2/token', {
method: 'POST',
body: new URLSearchParams({
client_id, code,
client_secret: process.env.DISCORD_SECRET,
grant_type: 'authorization_code',
redirect_uri: host + "/auth/discord",
scope: 'identify+email',
}).toString(),
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
});
if (!response.ok) return res.error(500, "Bad request to discord");
const { access_token, token_type } = await response.json();
const discord = await fetch('https://discord.com/api/users/@me', {
headers: { authorization: `${token_type} ${access_token}` }
}).then(res => res.json());
const forum = await UserModel.findOne({ discordID: discord.id });
if (req.user) {
if (req.user.discordID)
return res.error(403, "Your forum account is already linked to a discord account.");
if (forum)
return res.error(409, "This discord account is already linked to a forum account.");
req.user.discordID = discord.id;
req.user.discord_code = code;
await req.user.save();
return res.redirect(`/users/${req.user.id}`)
}
if (forum) {
req.session.userID = forum.id;
return res.redirect("/");
}
let name = discord.username;
while (await UserModel.exists({ name }))
name += Math.floor(Math.random() * 2);
const user2 = new UserModel({
name, email: discord.email,
discordID: discord.id, discord_code: code,
avatar: `https://cdn.discordapp.com/avatars/${discord.id}/${discord.avatar}.png?size=256`,
});
await user2.takeId();
await user2.save();
req.session.userID = user2.id;
res.redirect("/");
} catch (error) {
res.error(500, "Something went wrong");
console.error(error);
}
});
app.delete("/discord", async (req, res) => {
if (!req.user) return res.error(401, "You are not logged in");
if (!req.user.discordID) return res.error(403, "You don't have a discord account linked to your forum account.");
req.user.discordID = undefined;
req.user.discord_code = undefined;
await req.user.save();
res.send("Your discord account has been unlinked from your forum account.");
});
app.get("/email", async (req, res) => {
if (!email_auth) return res.error(404, "Email auth is disabled");
if (!req.user) return res.error(401, "You are not logged in");
if (req.user.email) return res.error(403, "You already have an email linked to your account.");
const { code } = req.query;
if (!code) return res.error(400, "No code provided");
if (code !== req.user.email_code) return res.error(403, "Invalid code");
req.user.state = "ACTIVE";
await req.user.save();
res.send("Your email has been linked to your forum account.");
});
module.exports = app;

30
src/routes/categories.js Normal file
View file

@ -0,0 +1,30 @@
const { CategoryModel, ThreadModel } = require("../models");
const { Router } = require("express");
const app = Router();
app.get("/", async (req, res) => {
const page = Number(req.query.page) || 0;
const categories = await CategoryModel.find({}).limit(10).skip(page * 10).sort({ time: -1 });
res.reply("categories", {
categories, page,
pages: Math.ceil(await CategoryModel.countDocuments({}) / 10)
});
});
app.get("/create", (req, res) => res.reply("create_category"));
app.get("/:id", async (req, res) => {
const category = await CategoryModel.findOne({ id: req.params.id });
if (!category) return res.error(404, "Category not found.");
const page = Number(req.query.page) || 0;
const query = { categoryID: category.id };
if (!req.user?.admin) query.deleted = false;
let threads = await ThreadModel.find(query).limit(10).skip(page * 10).sort({ time: -1 });
threads = await Promise.all(threads.map(thread => thread.get_author()));
res.reply("threads", { threads, page, title: `Threads in ${category.name}`, desp: category.desp, pages: Math.ceil(await ThreadModel.countDocuments(query) / 10) });
});
module.exports = app;

27
src/routes/login.js Normal file
View file

@ -0,0 +1,27 @@
const { UserModel } = require("../models");
const { Router } = require("express");
const app = Router();
const bcrypt = require("bcrypt");
app.get("/", (req, res) => res.reply("login", { redirect: req.query.redirect, user: null, discord: req.app.get("DISCORD_AUTH_URL") }));
app.post("/", async (req, res) => {
req.session.userID = null;
const { name, password } = req.body;
if (!name || !password) return res.error(400, "You forgot entering some values")
const member = await UserModel.findOne({
$or: [{ name }, { email: name }]
}, "+password");
if (!member || member.deleted) return res.error(401, 'Incorrect username or email!');
if (!await bcrypt.compare(password, member.password)) return res.error(401, 'Incorrect password!');
req.session.userID = member.id;
res.redirect(req.query.redirect || '/');
});
module.exports = app;

View file

@ -7,8 +7,9 @@ const app = Router();
app.get("/:id", async (req, res) => {
const message = await MessageModel.get(req.params.id);
if (!message || (message.deleted && req.user && !req.user.admin)) return res.error( 404, "We have not got any message declared as this id.");
res.redirect("/threads/" + message.threadID+"?scroll="+req.params.id);
if (!message || (message.deleted && !req.user?.admin)) return res.error( 404,
`We don't have any message with id ${req.params.id}.`);
res.redirect(`/threads/${message.threadID}#message-${message.id}`);
});

64
src/routes/register.js Normal file
View file

@ -0,0 +1,64 @@
const { UserModel } = require("../models");
const { Router } = require("express")
const bcrypt = require("bcrypt");
const { RL, transporter, emailRegEx, getGravatar } = require('../lib');
const app = Router();
const { email_auth, forum_name, host } = require("../../config.json");
app.get("/", (req, res) => res.reply("register", { user: null, discord: req.app.get("DISCORD_AUTH_URL"), mail: email_auth }));
app.post("/", RL(24 * 60 * 60_000, 5), async (req, res) => {
req.session.userID = null;
let { name, password, about, email } = req.body;
if (!name || !password || !email) return res.error(400, "You forgot entering some values");
if (!emailRegEx.test(email)) return res.error(400, "E-mail is not valid");
const { names } = req.app.get("limits");
if (name.length < 3 || name.length > names) return res.error(400, "Name must be between 3 - 25 characters");
if (password.length < 3 || password.length > names) return res.error(400, "Password must be between 3 - 25 characters");
if (await UserModel.exists({ name })) return res.error(400, `We have got an user named ${name}!`)
if (await UserModel.exists({ email })) return res.error(400, "E-mail is already in use");
const user = new UserModel({ name, email });
user.avatar = getGravatar(email, 128);
if (about) {
if (about.length > 256) return res.error(400, "about must be under 256 characters");
user.about = about;
}
await user.takeId();
if (user.id === "0")
user.admin = true;
else if (email_auth) {
user.email_code = await bcrypt.hash(`${Date.now()}-${Math.floor(Math.random() * 1e20)}`, 10)
transporter.sendMail({
from: transporter.options.auth.user,
to: email,
subject: name + ", please verify your email",
html: `
<h1>Verify your email in ${forum_name}</h1>
<a href="${host}/auth/email?code=${user.email_code}">Click here to verify your email</a>
`
}, (err) => {
if (err) return res.error(500, "Failed to send email");
});
}
user.password = await bcrypt.hash(password, 10);
await user.save();
req.session.userID = user.id;
res.redirect('/');
});
module.exports = app;

53
src/routes/search.js Normal file
View file

@ -0,0 +1,53 @@
const { UserModel, ThreadModel, MessageModel } = require("../models")
const { Router } = require("express");
const app = Router();
app.get("/", (req, res) => res.reply("search"));
app.use(async (req, res, next) => {
req.sq = {}
req.page = Number(req.query.page) || 0;
req.so = { limit: 10, skip: req.page * 10 }
if (!req.user?.admin) req.sq.deleted = false;
next();
});
app.get("/users", async (req, res) => {
if (!req.query.q) return res.error(400, "Missing query parameter 'q' in request body.");
const users = await UserModel.find({ ...req.sq, name: { $regex: req.query.q, $options: "i" } }, null, req.so)
res.reply("users", {
users, page: req.page,
pages: Math.ceil(await UserModel.countDocuments(req.sq) / 10)
});
});
app.get("/messages", async (req, res) => {
if (!Object.values(req.query).length) return res.error(400, "Missing query parameters in request body.");
const query = { ...req.sq };
if (req.query.q) query.content = { $regex: req.query.q, $options: "i" };
if (req.query.authorID) query.authorID = req.query.authorID;
const messages = await MessageModel.find(query, null, req.so)
res.reply("messages", {
messages, page: req.page,
pages: Math.ceil(await MessageModel.countDocuments(query) / 10)
});
});
app.get("/threads", async (req, res) => {
if (!Object.values(req.query).length) return res.error(400, "Missing query parameters in request body.");
const query = {};
if (!req.user?.admin) query.state = "OPEN";
if (req.query.q) query.title = { $regex: req.query.q, $options: "i" };
if (req.query.authorID) query.authorID = req.query.authorID;
const threads = await ThreadModel.find(query, null, req.so)
res.reply("threads", {
threads, page: req.page, title: `Threads with query '${req.query.q}'`,
pages: Math.ceil(await ThreadModel.countDocuments(query) / 10), desp: `${threads.length} threads are listed`
});
});
module.exports = app;

28
src/routes/security.js Normal file
View file

@ -0,0 +1,28 @@
const { UserModel } = require("../models");
const { Router } = require("express")
const bcrypt = require("bcrypt");
const { RL} = require('../lib');
const app = Router();
app.use(async (req, res, next) => {
if (!req.user) return res.error(403, "You are not logged in");
next();
});
app.get("/", (req, res) => res.reply("security"));
app.post("/", RL(24 * 60 * 60_000, 5), async (req, res) => {
let { old_password, password } = req.body;
if (!old_password || !password) return res.error(400, "You forgot entering some values");
const { names } = req.app.get("limits");
if (password.length < 3 || password.length > names) return res.error(400, "Password must be between 3 - 25 characters");
const user = await UserModel.get(req.user.id, "+password");
if (!await bcrypt.compare(old_password, user.password)) return res.error(401, 'Incorrect password!');
user.password = await bcrypt.hash(password, 10);
await user.save();
res.send("Password changed");
});
module.exports = app;

34
src/routes/setup.js Normal file
View file

@ -0,0 +1,34 @@
const { UserModel } = require("../models")
const { Router } = require("express");
const app = Router();
const fs = require("fs");
app.use(async (req, res, next) => {
if (await UserModel.exists({ admin: true })) return res.error(400, "You have already setuped the site.");
next();
});
app.get("/", async (req, res) => res.reply("setup"))
app.post("/", async (req, res) => {
let original = {};
try {
original = JSON.parse(fs.readFileSync("./config.json", "utf8"));
} catch (e) {
try {
original = JSON.parse(fs.readFileSync("./config.json.example", "utf8"));
// eslint-disable-next-line no-empty
} catch (e) { }
}
const content = req.body;
for (const key in content)
if (key in original && content[key])
original[key] = content[key];
fs.writeFileSync("./config.json", JSON.stringify(original, null, 4));
require.cache[require.resolve("../../config.json")] = require("../../config.json");
res.redirect("/register");
})
module.exports = app;

43
src/routes/threads.js Normal file
View file

@ -0,0 +1,43 @@
const { Router } = require("express");
const app = Router();
const { ThreadModel, MessageModel, CategoryModel } = require("../models")
app.get("/", async (req, res) => {
const page = Number(req.query.page) || 0;
const query = req.user?.admin ? {} : { state: "OPEN" };
let threads = await ThreadModel.find(query).limit(10).skip(page * 10).sort({ time: -1 });
threads = await Promise.all(threads.map(thread => thread.get_author()));
return res.reply("threads", { threads, page, title: "Threads", desp: threads.length + " threads are listed", pages: Math.ceil(await ThreadModel.countDocuments(query) / 10) });
});
app.get("/create/", async (req, res) => res.reply("create_thread", { categories: await CategoryModel.find() }));
app.get("/:id/", async (req, res) => {
const { user, params: { id } } = req
const page = Number(req.query.page || 0);
const thread = await ThreadModel.get(id)
if (thread && (user?.admin || thread.state == "OPEN")) {
thread.count = await thread.messageCount(user?.admin);
thread.pages = Math.ceil(thread.count / 10);
thread.views++;
const query = { threadID: id };
if (!user || !user.admin) query.deleted = false;
const messages = await Promise.all(await MessageModel.find(query).sort({ time: 1 }).limit(10).skip(page * 10)
.then(messages => messages.map(message => message.get_author())));
res.reply("thread", { page, thread, messages });
thread.save();
} else
res.error(404, `We don't have any thread with id ${id}.`);
});
module.exports = app;

50
src/routes/users.js Normal file
View file

@ -0,0 +1,50 @@
const { Router } = require("express");
const app = Router();
const { UserModel, MessageModel, ThreadModel } = require("../models");
app.get("/", async (req, res) => {
const page = Number(req.query.page) || 0;
const query = req.user?.admin ? {} : { deleted: false };
let users = await UserModel.find(query).limit(10).skip(page * 10);
return res.reply("users", { users, page, pages: Math.ceil(await UserModel.countDocuments(query) / 10) });
});
app.get("/:id/avatar", async (req, res) => {
if (!req.user || (!req.user.admin && req.params.id !== req.user.id)) return res.error(403, "You have not got permission for this.");
const member = await UserModel.get(req.params.id);
if (member && (req.user?.admin || !member.deleted))
res.reply("avatar_upload", { member })
else
res.error(404, `We don't have any user with id ${req.params.id}.`);
})
app.get("/:id", async (req, res) => {
const user = req.user
const { id } = req.params;
const member = await UserModel.get(id, "+lastSeen +ips");
if (member && (user?.admin || !member.deleted)) {
const message = await MessageModel.countDocuments({ authorID: id });
const thread = await ThreadModel.countDocuments({ authorID: id });
res.reply("user", { member, counts: { message, thread }, discord: req.app.get("DISCORD_AUTH_URL") })
}
else res.error(404, `We don't have any user with id ${id}.`);
});
app.get("/:id/edit", async (req, res) => {
const user = req.user
const { id } = req.params;
const member = await UserModel.get(id);
if (!member) return res.error(404, `We don't have any user with id ${id}.`);
if (user?.admin || user.id === member.id)
return res.reply("edit_user", { member });
res.error(403, "You have not got permission for this.");
});
module.exports = app;

View file

@ -0,0 +1,6 @@
module.exports = {
name: "Bootstrap black theme",
codename: "bootstrap_black",
description: "A black theme fueled by bootstrap, created by Akif9748 and overwrited Alair's website's theme.",
author: "Akif9748"
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,650 @@
@import url('https://fonts.googleapis.com/css2?family=Poppins:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&display=swap');
:root {
--alair: #736FE9;
--main: #736FE9;
--btn-clr-1: #e8e8e8;
--menu-item: #ffffff;
--borders: #d9d9d9;
--input-clr: #dcdcdc;
--box-shadow: #c3c3c3;
--second: #9f9f9f;
--anti: #ebebeb;
--t-username: rgb(236 236 236);
--background-color: #000000;
}
/***********************************
START OF OLD THEME
***********************************/
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: Poppins;
min-height: 400px;
margin-bottom: 100px;
clear: both;
}
a {
text-decoration: none;
color: initial;
}
.header {
width: 100%;
padding: 20px;
display: flex;
justify-content: space-between;
align-items: center;
}
.logo {
padding: 10px;
font-size: 28px;
color: var(--main);
font-weight: 900;
}
.logo>span {
color: var(--second);
}
.buttons {
padding: 0;
display: flex;
align-items: center;
}
.btn-primary {
color: #fafafa;
background-color: var(--alair);
border-color: var(--alair);
padding: 10px 20px 10px 20px;
border-radius: 4px;
font-weight: 700;
margin: 10px;
cursor: pointer;
border: 2px solid var(--main);
}
.btn-primary:hover {
color: var(--alair);
background-color: rgba(0, 0, 0, 0);
border: 2px solid var(--alair);
}
.btn-danger {
color: var(--btn-clr-1);
background-color: var(--important);
padding: 0px 10px 0px 10px;
border-radius: 4px;
font-weight: 700;
margin: 10px;
cursor: pointer;
border: 2px solid var(--important);
}
.btn-outline-primary {
color: var(--alair);
padding: 10px 20px 10px 20px;
border-radius: 4px;
font-weight: 700;
margin: 10px;
cursor: pointer;
border: 2px solid var(--borders);
}
.btn-outline-primary:hover {
color: var(--btn-clr-1);
background-color: var(--alair);
border: 2px solid var(--alair);
}
.btn-danger:hover {
background-color: var(--important);
border: 2px solid var(--important);
}
.menu {
width: 100%;
padding: 20px;
display: flex;
justify-content: space-around;
}
.menu-item {
padding: 10px;
font-weight: 700;
background-color: var(--second);
color: var(--menu-item);
border-radius: 5px;
width: 100%;
text-align: center;
margin: 0px 10px 0px 0px;
cursor: pointer;
transition: background-color 0.3s ease;
}
.active-menu {
background-color: var(--main);
}
.menu-item:hover {
background-color: var(--main);
}
.admin-bar {
font-size: 15px;
color: var(--menu-item);
background: var(--main);
text-align: center;
}
div.avatar {
padding-left: 10px;
}
.avatar>img {
width: 30px;
height: 30px;
border-radius: 50%;
margin: auto;
}
.box-username {
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
font-weight: 700;
color: var(--main);
}
.title {
color: var(--main);
}
.input {
padding: 8px 10px;
color: var(--input-clr);
width: 100%;
margin-bottom: 10px;
border: 2px solid var(--borders);
}
form.post {
display: flex;
flex-direction: column;
align-items: center;
max-width: 1000px;
margin: 0 auto;
}
@media (max-width: 992px) {
.header {
flex-direction: column;
align-items: center;
}
.menu {
flex-direction: column;
}
.menu-item {
margin: 0px 10px 10px 0px;
}
}
@media (max-width: 480px) {
.btn-outline-primary {
width: 100%;
text-align: center;
}
.buttons {
flex-direction: column;
width: 100%;
}
.btn-primary {
width: 100%;
text-align: center;
}
}
/***********************************
START OF BOOTSTRAP THEME
***********************************/
.a:hover {
color: var(--alair);
}
.CodeMirror {
color: var(--input-clr);
background-color: var(--bs-body-bg);
}
footer {
background-color: #000000;
font-size: small;
}
body {
min-height: 400px;
background-size: cover;
clear: both;
}
a {
color: var(--alair);
}
.alair-trans {
padding: 20px;
background-color: rgba(0, 0, 0, 0.7);
}
.alair-cl {
width: 50%;
}
@media screen and (max-width: 768px) {
.alair-cl {
width: 90%;
}
}
.container-sm {
margin-top: 25px;
margin-bottom: 125px;
}
/************************************
Threads
*************************************/
.threads {
width: 100%;
padding: 20px;
}
.threads-box {
width: 100%;
padding: 8px;
box-shadow: 0 0 5px 0 var(--box-shadow);
display: flex;
justify-content: space-between;
margin: 0px 0px 8px 0px;
cursor: pointer;
align-items: center;
}
.threads-box:hover {
background-color: var(--box-shadow);
}
.thread-box-title {
padding: 10px;
font-size: 18px;
font-weight: 700;
color: var(--anti);
}
.thread-box-title>span {
color: var(--important);
}
@media (max-width: 480px) {
.threads-box {
flex-direction: column;
}
}
/*****
LOGIN / REGISTER
******/
form.login {
display: flex;
align-items: center;
flex-direction: column;
max-width: 500px;
margin: 0 auto;
padding: 8px;
}
/********
MESSAGE
********/
.message .date {
color: var(--second);
}
.message {
max-width: 800px;
box-shadow: 0 0 5px 0 var(--box-shadow);
margin: 10px auto;
padding: 20px;
display: flex;
gap: 10px;
position: relative;
}
.message .left {
text-align: center;
border-right: 2px solid var(--borders);
}
.message .left img {
width: 100px;
height: 100px;
border-radius: 50%;
margin-right: 5px;
}
.message .left .username a {
color: var(--t-username);
}
.message .content {
width: 70%;
color: var(--anti);
}
@media(max-width:980px) {
.message {
margin: 10px 20px;
max-width: 100%;
flex-direction: column;
position: relative;
}
.message .left {
padding: 40px;
border: none;
border-bottom: 2px solid var(--borders);
}
.message .left img {
width: 50px;
height: 50px;
border-radius: 50%;
margin-right: 5px;
position: absolute;
left: 20px;
top: 20px;
}
.message .left .username {
position: absolute;
top: 20px;
left: 80px;
}
.message .date:nth-of-type(3) {
position: absolute;
right: 20px;
top: 20px;
}
.message .date:nth-of-type(2) {
position: absolute;
right: 20px;
top: 40px;
}
.message .content {
width: 50%;
}
}
/************************************
SEARCH
***************************************/
.search form {
display: flex;
align-items: center;
flex-direction: column;
max-width: 500px;
margin: 20px auto;
width: 100%;
padding: 10px;
box-shadow: 0 0 5px 0 #b0b0b0;
}
/****************************************
USERS
*****************************************/
.users {
width: 100%;
padding: 20px;
display: grid;
grid-template-columns: 1fr 1fr 1fr 1fr;
gap: 10px;
}
.user-box {
width: 100%;
padding: 20px;
display: flex;
flex-direction: column;
align-items: center;
box-shadow: 0 0 5px 0 var(--box-shadow);
max-width: 500px;
}
.user-box-title {
padding: 10px;
margin: 8px;
font-weight: 500;
}
.user-box-title>a {
color: var(--anti);
}
.user-box-title>span {
color: var(--important);
}
.user-box-img {
width: 80px;
height: 80px;
margin: auto;
}
@media (max-width: 992px) {
.users {
display: block;
}
.user-box {
margin-bottom: 10px;
}
}
/************************************
THREAD
*************************************/
.title {
color: var(--input-clr);
font-weight: 700;
font-size: 36px;
}
.reactions {
position: absolute;
right: 20px;
bottom: 20px;
display: flex;
align-items: center;
gap: 10px;
}
.reactions div {
display: flex;
align-items: center;
gap: 5px;
padding: 4px;
color: var(--second);
cursor: pointer;
transition: color 0.3s ease;
}
.reactions div:hover {
color: var(--anti);
}
.reactions div i {
font-size: 22px;
}
.dots {
position: absolute;
right: 20px;
font-size: 22px;
top: 10px;
color: var(--second);
cursor: pointer;
}
.dots-menu {
position: absolute;
top: 50px;
right: 0;
background-color: var(--second);
width: 100px;
text-align: center;
display: none;
}
.active {
display: block;
}
.no-display {
display: none;
}
.dots-menu a {
display: block;
margin: 8px;
cursor: pointer;
}
.dots-menu a:hover:nth-child(1) {
color: var(--important);
}
.dots-menu a:hover:nth-child(2) {
color: var(--main);
}
/* Media Query */
@media(max-width:980px) {
.dots {
position: absolute;
right: auto;
top: 70px;
left: 40px;
}
.dots-menu {
position: absolute;
left: 70px;
top: 60px;
width: 120px;
}
.active {
display: flex;
}
}
/******************************
USER
******************************/
.usercontent {
display: flex;
flex-direction: column;
box-shadow: 0 0 5px 0 var(--box-shadow);
max-width: 900px;
margin: 0 auto;
padding: 10px;
color: var(--anti);
}
.userbox {
display: flex;
align-items: center;
justify-content: space-between;
margin: 0 auto;
max-width: 800px;
width: 100%;
}
.userbox-title {
font-weight: 400;
color: var(--anti);
}
.userbox-value {
font-weight: 300;
background-color: var(--main);
color: white;
font-size: 14px;
padding: 4px;
border-radius: 5px;
min-width: 50px;
text-align: center;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

View file

@ -0,0 +1,124 @@
<!DOCTYPE html>
<html lang="en">
<%- include(dataset.getFile(dataset.theme.codename +"/views/extra/meta"), {title: "Admin Panel!" }) %>
<body style="text-align: center;">
<%- include(dataset.getFile(dataset.theme.codename +"/views/extra/navbar")) %>
<style>
table {
font-family: arial, sans-serif;
border-collapse: collapse;
width: 100%;
}
td,
th {
border: 1px solid #dddddd;
text-align: left;
padding: 8px;
color: var(--anti);
}
tr:nth-child(even) {
background-color: #dddddd;
}
</style>
<h2>Welcome to the admin panel of the forum, <%= user.name %>!</h1>
<div>
<!-- Modal -->
<div class="modal fade" id="exampleModal" tabindex="-1" role="dialog" aria-labelledby="exampleModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="exampleModalLabel">Banned users</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close" onclick="document.getElementById('exampleModal').style.display = 'none';">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<table>
<tr>
<th>IP</th>
<th>Reason</th>
<th>AuthorID</th>
</tr>
<% for (const ban of bans) { %>
<tr>
<td><%=ban.ip%></td>
<td><%=ban.reason%></td>
<td><%=ban.authorID%></td>
</tr>
<% } %>
</table>
</div>
<div class="modal-footer">
<a class="btn-primary" onclick="ban();">IP BAN</a>
<a class="btn-outline-primary" onclick="unban();">REMOVE IP BAN</a>
</div>
</div>
</div>
</div>
<button onclick="window.location.href = '/categories/create';" class="btn-primary">Create Category</button>
<button onclick="window.location.href = '/admin/config';" class="btn-primary">Edit config</button>
<button onclick="document.getElementById('exampleModal').style.display = 'block';" class="btn-primary" type="button" data-bs-toggle="collapse" data-bs-target="#exampleModal" aria-controls="navbarText" aria-expanded="false" aria-label="Toggle navigation">
Banned users
</button>
<div>
<table>
<tr>
<th>Admin list</th>
</tr>
<% for (const admin of admins) { %>
<tr>
<td><a style="color: var(--anti);" href="<%= admin.getLink() %>"><%= admin.name %></a></td>
</tr>
<% } %>
</table>
<ul>
</ul>
</div>
<script type="module">
import request from "../../js/request.js";
window.unban = async function() {
const ip = prompt("Enter ip to unban");
if (!ip) return;
const response = await request("/api/bans/" + ip, "DELETE");
if (response)
alert("IP unbanned!");
else
alert("IP is not unbanned!");
location.reload();
}
window.ban = async function() {
const ip = prompt("Enter ip to ban");
if (!ip) return;
const response = await request("/api/bans/" + ip);
if (response)
alert("IP banned!");
else
alert("IP is not banned!");
location.reload();
}
</script>
</div>
<%- include(dataset.getFile(dataset.theme.codename +"/views/extra/footer")) %>
</body>
</html>

View file

@ -0,0 +1,55 @@
<!DOCTYPE html>
<html lang="en">
<%- include(dataset.getFile(dataset.theme.codename +"/views/extra/meta"), {title: "Avatar Upload Panel!" }) %>
<body style="text-align: center;">
<link rel="stylesheet" href="/libs/cropper/cropper.css">
<style>
.container {
margin: 20px auto;
max-width: 640px;
}
img {
max-width: 100%;
}
.cropper-view-box,
.cropper-face {
border-radius: 50%;
}
.cropper-view-box {
outline: 0;
box-shadow: 0 0 0 1px #39f;
}
</style>
<script src="/libs/cropper/cropper.js"></script>
<%- include(dataset.getFile(dataset.theme.codename +"/views/extra/navbar")) %>
<div class="container">
<h1>Upload avatar for <%= member.name %></h1>
<input type="file" id="file-input">
<img id="image" src="<%= member.avatar %>">
<p>
<button type="button" id="button">Upload</button>
</p>
<file id="cropped-image" name="avatar">
<form id="form"></form>
</div>
<script>
const MEM_ID="<%= member.id %>";
</script>
<script src="/js/avatar.js"></script>
<%- include(dataset.getFile(dataset.theme.codename +"/views/extra/footer")) %>
</body>
</html>

View file

@ -0,0 +1,76 @@
<!DOCTYPE html>
<html lang="en">
<%- include(dataset.getFile(dataset.theme.codename +"/views/extra/meta"), {title: "Category list!" }) %>
<body>
<link href='https://unpkg.com/boxicons@2.1.2/css/boxicons.min.css' rel='stylesheet'>
<%- include(dataset.getFile(dataset.theme.codename +"/views/extra/navbar")) %>
<div class="container my-3">
<div class="row">
<div class="col-12">
<h2 class="h4 text-white bg-info mb-0 p-4 rounded-top"><%= "Categories" %></h2>
<table class="table table-striped table-bordered table-responsive-lg">
<thead class="thead-light">
<tr>
<th scope="col" class="topic-col">Topic</th>
<th scope="col">Description</th>
<% if (user?.admin){ %> <th scope="col" class="last-post-col">Action</th> <% } %>
</tr>
</thead>
<tbody>
<% categories.forEach(category=>{ %>
<tr>
<td>
<h3 class="h6">
<a href="<%= category.getLink() %>"><%= category.name %></a>
</h3>
</td>
<td>
<div><%= category.desp %></div>
</td>
<% if (user?.admin){ %>
<td>
<a class="btn-danger" onclick="fetch('/api/categories/<%= category.id %>/',{method:'DELETE'})"><i class="bx bx-trash bx-sm"></i></a>
</td>
<% } %>
</tr>
<% }); %>
</tbody>
</table>
</div>
</div>
<% if(typeof page === "number"){ %>
<div class="mb-3 clearfix">
<nav aria-label="Navigate post pages">
<ul class="pagination justify-content-center">
<li class="page-item <%= page > 0 ?"": "disabled" %>">
<a class="page-link" href="/categories?page=<%= page-1 %>" tabindex="-1">Back</a>
</li>
<% for(let i=0; i < pages; i++){ %>
<li class="page-item">
<a class="page-link <%= i==page?'active':'' %>" href="/categories?page=<%= i %>"><%= i+1 %>
<% if (i==page){ %>
<span class="sr-only">(current)</span>
<% } %>
</a>
</li>
<% } %>
<li class="page-item <%= pages-1 > page ?"": "disabled" %>">
<a class="page-link" href="/categories?page=<%= page+1 %>">Next</a>
</li>
</ul>
</nav>
</div>
<% } %>
<%- include(dataset.getFile(dataset.theme.codename +"/views/extra/footer")) %>
</body>
</html>

View file

@ -0,0 +1,32 @@
<!DOCTYPE html>
<html lang="en">
<%- include(dataset.getFile(dataset.theme.codename +"/views/extra/meta"), {title: "Edit Forum Config!" }) %>
<body style="text-align: center;">
<%- include(dataset.getFile(dataset.theme.codename +"/views/extra/navbar")) %>
<h1>Edit forum config</h1>
<textarea rows="30" cols="75"><%= config %></textarea>
<a onclick="send();" class="btn-primary">Edit config</a>
<script>
const textarea = document.querySelector('textarea');
async function send() {
const res = await fetch('/api/config?text', {
method: 'PUT',
body: textarea.value,
headers: {
'Content-Type': 'application/json'
}
})
if (res.error) return alert(res.error);
alert('Success!');
textarea.value=JSON.stringify( await res.json(),null,4)
}
</script>
<%- include(dataset.getFile(dataset.theme.codename +"/views/extra/footer")) %>
</body>
</html>

View file

@ -0,0 +1,59 @@
<!DOCTYPE html>
<html lang="en">
<%- include(dataset.getFile(dataset.theme.codename +"/views/extra/meta"), {title: "Create Category!" }) %>
<body>
<%- include(dataset.getFile(dataset.theme.codename +"/views/extra/navbar")) %>
<link rel="stylesheet" href="/libs/simplemde/simplemde.min.css">
<script src="/libs/simplemde/simplemde.min.js"></script>
<div class="container my-3">
<div class="col-12">
<h2 class="h4 text-white bg-info mb-3 p-4 rounded">Create new category</h2>
<form class="mb-3">
<div class="form-group">
<label for="topic">Name</label>
<input type="text" class="form-control" id="title" placeholder="Give your category a name" required>
</div>
<div class="form-group">
<label for="comment">Description</label>
<textarea id="textarea"></textarea>
</div>
<button type="submit" class="btn btn-primary">Create</button>
<button type="reset" class="btn btn-danger">Reset</button>
</form>
</div>
</div>
</div>
<script src="/js/editor.js"></script>
<script type="module">
const simplemde = editor("category-create");
import request from "../../js/request.js";
document.addEventListener("submit", async e => {
e.preventDefault();
const response = await request("/api/categories/", "POST", {
name: document.getElementById("title").value,
desp: simplemde.value()
});
simplemde.clearAutosavedValue();
if (response)
window.location.href = "/categories/" + response.id;
});
</script>
<%- include(dataset.getFile(dataset.theme.codename +"/views/extra/footer")) %>
</body>
</html>

View file

@ -0,0 +1,69 @@
<!DOCTYPE html>
<html lang="en">
<%- include(dataset.getFile(dataset.theme.codename +"/views/extra/meta"), {title: "Create thread!" }) %>
<body>
<%- include(dataset.getFile(dataset.theme.codename +"/views/extra/navbar")) %>
<link rel="stylesheet" href="/libs/simplemde/simplemde.min.css">
<script src="/libs/simplemde/simplemde.min.js"></script>
<div class="container my-3">
<div class="col-12">
<h2 class="h4 text-white bg-info mb-3 p-4 rounded">Create new thread</h2>
<form class="mb-3">
<div class="form-group">
<label for="topic">Title</label>
<input type="text" class="form-control" id="title" placeholder="Give your thread a title." required>
</div>
<div class="form-group">
<label for="comment">Comment:</label>
<textarea id="textarea"></textarea>
</div>
<div class="form-group">
<label class="form-check-label">
Category:
</label>
<select id="category" class="input">
<% for (const category of categories) { %>
<option value="<%= category.id %>"><%= category.name %></option>
<% } %>
</select>
</div>
<button type="submit" class="btn btn-primary">Create</button>
<button type="reset" class="btn btn-danger">Reset</button>
</form>
</div>
</div>
</div>
<script src="/js/editor.js"></script>
<script type="module">
const simplemde = editor("thread-create");
import request from "../../js/request.js";
document.addEventListener("submit", async e => {
e.preventDefault();
const response = await request("/api/threads/", "POST", {
title: document.getElementById("title").value,
content: simplemde.value(),
category: document.getElementById("category").value
});
if (response) {
simplemde.clearAutosavedValue();
window.location.href = "/threads/" + response.id;
}
});
</script>
<%- include(dataset.getFile(dataset.theme.codename +"/views/extra/footer")) %>
</body>
</html>

View file

@ -0,0 +1,62 @@
<!DOCTYPE html>
<html lang="en">
<%- include(dataset.getFile(dataset.theme.codename +"/views/extra/meta"), {title: member.name }) %>
<body>
<%- include(dataset.getFile(dataset.theme.codename +"/views/extra/navbar")) %>
<link rel="stylesheet" href="/libs/simplemde/simplemde.min.css">
<script src="/libs/simplemde/simplemde.min.js"></script>
<div class="container my-3">
<div class="col-12">
<a class="btn-primary" href="/security" >Change your own password</a>
<h2 class="h4 text-white bg-info mb-3 p-4 rounded">Edit User</h2>
<form id="form" class="mb-3">
<input type="text" name="name" placeholder="<%= member.name %>" class="input">
<input type="email" name="email" placeholder="<%= member.email %>" class="input">
<textarea id="textarea" class="input" name="about" rows="4" cols="60" name="content" placeholder="<%=member.about%>"></textarea>
<% if (user?.admin){ %>
Is Admin? <input id='admin' type='checkbox' value='true' name='admin' <%=member.admin ? "checked": ""%>>
<input id='adminHidden' type='hidden' value='false' name='admin'>
<% } %>
Hide Last Seen? <input id='last' type='checkbox' value='true' name='hideLastSeen' <%=member.hideLastSeen ? "checked": ""%>>
<input id='lastHidden' type='hidden' value='false' name='admin'>
<button type="submit" class="btn-primary" style="width:100%;">Update User!</button>
</form>
</div>
</div>
<script src="/js/editor.js"></script>
<script type="module">
import request from "/js/request.js";
const simplemde = editor("user-edit-<%=member.id%>");
document.getElementById("form").addEventListener("submit", async e => {
e.preventDefault();
document.getElementById('adminHidden').disabled = document.getElementById("admin").checked;
document.getElementById('lastHidden').disabled = document.getElementById("last").checked;
const formdata = new FormData(e.target)
const res = await request("/api/users/<%=member.id%>", "PATCH", {
name: formdata.get("name"),
about: simplemde.value(),
admin: formdata.get("admin"),
email: formdata.get("email"),
hideLastSeen: formdata.get("hideLastSeen")
});
simplemde.clearAutosavedValue();
if (res) alert(`User is updated!`);
location.reload();
});
</script>
<%- include(dataset.getFile(dataset.theme.codename +"/views/extra/footer")) %>
</body>
</html>

View file

@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="en">
<%- include(dataset.getFile(dataset.theme.codename +"/views/extra/meta"), {title: type+" error!" }) %>
<body style="text-align: center;">
<%- include(dataset.getFile(dataset.theme.codename +"/views/extra/navbar")) %>
<h1 style="color: var(--main);"><%= type %></h1>
<h2 style="color: var(--second);"><%= error %></h2>
<%- include(dataset.getFile(dataset.theme.codename +"/views/extra/footer")) %>
</body>
</html>

View file

@ -0,0 +1,41 @@
<footer class="text-center text-white fixed-bottom">
<% if (user){ %>
<select id="theme_select">
<% for(const theme of dataset.themes){%>
<option value="<%= theme.codename %>"><%= theme.name %></option>
<% } %>
</select>
<script>
const theme_select = document.getElementById("theme_select");
theme_select.querySelector(`option[value=<%= user.theme.codename %>]`).selected = true;
theme_select.addEventListener("change", async e => {
const codename = e.target.value;
await fetch('/api/users/<%= user.id %>', {
method: 'PATCH',
body: JSON.stringify({
theme: {
codename
}
}),
headers: {
"Content-Type": "application/json"
}
});
const theme = await fetch("/api/themes/" + codename).then(res => res.json());
const txt = "Theme changed to:\n" +
"Name: " + theme.name + "\n" +
"Description: " + theme.description + "\n" +
"Author: " + theme.author + "\n";
alert(txt)
location.reload();
});
</script>
<% } %>
<div class="text-center p-3">
akf-forum bootstrap theme created by <a class="text-white" href="https://akif9748.github.io/">Akif9748</a>,<br>
This website is powered by <a class="text-white" href="https://github.com/Akif9748/akf-forum">akf-forum</a>
</div>
</footer>

View file

@ -0,0 +1,10 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title><%= title || dataset.forum_name %></title>
<meta name="description" content="<%= dataset.description %>">
<link rel="icon" type="image/x-icon" href="/favicon.ico">
<link href="/themes/bootstrap_black/bootstrap-night.min.css" rel="stylesheet">
<script src="/themes/bootstrap_black/bootstrap.min.js"></script>
<link rel="stylesheet" href="/themes/bootstrap_black/main.css" />
</head>

View file

@ -0,0 +1,70 @@
<% if (user?.admin){ %>
<div class="admin-bar">
<a href="/admin" class="admin-bar">Click here to reach admin panel</a>
</div>
<% } %>
<nav class="navbar navbar-expand-lg navbar-light bg-light">
<div class="container-fluid">
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarText" aria-controls="navbarText" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<a class="navbar-brand" href="/"><%= dataset.forum_name.toUpperCase() %></a>
<div class="collapse navbar-collapse" id="navbarText">
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
<li class="nav-item">
<a class="nav-link" href="/threads/create/">Create Thread</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/categories">Categories</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/threads">Threads</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/users">Users</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/search">Search</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/login">Logout</a>
</li>
</ul>
</div>
<div>
<% if (user) { %>
<a class="navbar-brand" href="<%=user.getLink()%>"> <%= user.name %>
<img style="border-radius: 50%;" width="32" src="<%=user.avatar %>">
</a>
<% } else{ %>
<a class="navbar-brand" id="login" href="/login">Login</a>
<script>
document.getElementById("login").href += "?redirect=" + location.pathname;
</script>
<% } %>
</div>
</div>
<script>
const menuItems = document.getElementsByClassName("nav-link");
for (let i = 0; i < menuItems.length; i++)
if (window.location.pathname.includes(menuItems[i].getAttribute("href"))) {
menuItems[i].classList.add("active");
break;
}
</script>
</nav>

View file

@ -0,0 +1,179 @@
<!DOCTYPE html>
<html lang="en">
<%- include("extra/meta", {title: "Welcome to the " + dataset.forum_name }) %>
<body>
<%- include("extra/navbar") %>
<link href='https://unpkg.com/boxicons@2.1.2/css/boxicons.min.css' rel='stylesheet'>
<div class="container my-3">
<nav class="breadcrumb">
<span class="breadcrumb-item active">
<% if (user) { %>
Welcome, <%= user.name %>
<% } else { %>
Welcome, Guest! <a href="/register">You can press here to register.</a>
<% } %>
</span>
</nav>
<div class="row">
<div class="col-12 col-xl-9">
<div class="category">
<h2 class="h4 text-white bg-danger mb-0 p-4 rounded-top">New threads</h2>
<table class="table table-striped table-bordered table-responsive">
<thead class="thead-light">
<tr>
<th scope="col" class="topic-col">Topic</th>
<th scope="col" class="created-col">Created</th>
<th scope="col">Statistics</th>
<% if (user?.admin){ %> <th scope="col" class="last-post-col">Action</th> <% } %>
</tr>
</thead>
<tbody>
<% newestThreads.forEach(thread=>{ %>
<tr>
<td>
<h3 class="h6">
<% if (thread.deleted) { %> <span class="badge badge-primary">[DELETED]</span><% } %>
<a href="<%= thread.getLink() %>"><%= thread.title %></a>
</h3>
</td>
<td>
<div class="avatar">by <a href="/users/<%= thread.authorID %>"><%= thread.author.name %></a><img src="<%=thread.author.avatar %>"></div>
<div><%= new Date(thread.time).toLocaleString() %></div>
</td>
<td>
<div><%= thread.messages.length %> messages</div>
<div><%= thread.views %> views</div>
</td>
<% if (user?.admin){ %>
<td>
<% if (!thread.deleted){ %>
<a class="btn-danger" onclick="fetch('/api/threads/<%= thread.id %>/',{method:'DELETE'})"><i class="bx bx-trash bx-sm"></i></a>
<% } %>
</td>
<% } %>
</tr>
<% }); %>
</tbody>
</table>
</div>
<div class="category">
<h2 class="h4 text-white bg-danger mb-0 p-4 rounded-top">New messages</h2>
<table class="table table-striped table-bordered table-responsive">
<thead class="thead-light">
<tr>
<th scope="col" class="forum-col">Message</th>
<th scope="col">Date</th>
<th scope="col" class="last-post-col">Author</th>
</tr>
</thead>
<tbody>
<% newestMessages.forEach(message => { %>
<tr>
<td>
<p class="mb-0">
<a href="<%= message.getLink() %>"> <%= message.content.slice(0, 100) %></a>
</p>
</td>
<td>
<div><%= new Date(message.time).toLocaleString() %></div>
</td>
<td>
<div class="avatar">by <a href="<%= message.getLink() %>"><%= message.author.name %></a><img src="<%=message.author.avatar %>"></div>
</td>
</tr>
<% }) %>
</tbody>
</table>
</div>
</div>
<div class="col-12 col-xl-3">
<aside>
<div class="row">
<div class="col-12 col-sm-6 col-xl-12">
<div class="card mb-3 mb-sm-0 mb-xl-3">
<div class="card-body">
<h2 class="h4 card-title">Members online</h2>
<style>
li.d-inline~li.d-inline::before {
content: ', ';
}
</style>
<ul class="list-unstyled mb-0">
<% onlineMembers.slice(0, 30).forEach(user => { %>
<li class="d-inline">
<a href="/users/<%=user.id %>"><%= user.name %></a>
</li>
<% }); %>
<% if (onlineMemberCount > 30) { %>
<li>
<a href="/users/"><%= onlineMemberCount - 30 %></a> more...
</li>
<% } %>
</ul>
</div>
<div class="card-footer">
<dl class="row mb-0">
<dt class="col-8">Total:</dt>
<dd class="col-4 mb-0"><%= onlineTotal %></dd>
</dl>
<dl class="row mb-0">
<dt class="col-8">Members:</dt>
<dd class="col-4 mb-0"><%= onlineMemberCount %></dd>
</dl>
<dl class="row mb-0">
<dt class="col-8">Guests:</dt>
<dd class="col-4 mb-0"><%= onlineGuests %></dd>
</dl>
</div>
</div>
</div>
<div class="col-12 col-sm-6 col-xl-12">
<div class="card">
<div class="card-body">
<h2 class="h4 card-title">Forum statistics</h2>
<dl class="row mb-0">
<dt class="col-8">Total categories:</dt>
<dd class="col-4 mb-0"><%= categories %></dd>
</dl>
<dl class="row mb-0">
<dt class="col-8">Total threads:</dt>
<dd class="col-4 mb-0"><%= threads %></dd>
</dl>
<dl class="row mb-0">
<dt class="col-8">Total messages:</dt>
<dd class="col-4 mb-0"><%= messages %></dd>
</dl>
<dl class="row mb-0">
<dt class="col-8">Total members:</dt>
<dd class="col-4 mb-0"><%= users %></dd>
</dl>
</div>
<% if(newestMember) {%>
<div class="card-footer">
<div>Newest member:</div>
<div><a href="/users/<%= newestMember.id %>"><%= newestMember.name %></a></div>
</div>
<% }%>
</div>
</div>
</div>
</aside>
</div>
</div>
</div>
<%- include("extra/footer") %>
</body>
</html>

View file

@ -0,0 +1,50 @@
<!DOCTYPE html>
<html lang="en">
<%- include(dataset.getFile(dataset.theme.codename +"/views/extra/meta"), {title: "Log in!" }) %>
<body style="text-align: center;">
<%- include(dataset.getFile(dataset.theme.codename +"/views/extra/navbar")) %>
<div class="jumbotron jumbotron-fluid">
<div class="container">
<div class="row">
<div class="col-12 col-sm-8 col-md-6 col-lg-4 offset-sm-4 offset-sd-3 offset-lg-4">
<h1 class="mb-3 text-center">Please log in</h1>
<form action="/login?redirect=<%= redirect !== "/register" ? redirect : "/" %>" method="post" class="mb-3">
<div class="form-group">
<label for="name">Email/Username:</label>
<input name="name" class="form-control" placeholder="example@email.com" id="email" required />
</div>
<div class="form-group">
<label for="password">Password:</label>
<input name="password" type="password" class="form-control" id="password" required />
</div>
<button type="submit" class="btn btn-primary btn-block">
Login
</button>
</form>
<div class="text-center">
<p>or..</p>
<a href="/register" class="btn btn-outline-primary">Create account</a>
<% if(discord) { %>
<a href="<%=discord%>" class="btn btn-outline-primary">Auth with discord</a>
<% } %>
</div>
</div>
</div>
</div>
</div>
<%- include(dataset.getFile(dataset.theme.codename +"/views/extra/footer")) %>
</body>
</html>

View file

@ -0,0 +1,35 @@
<!DOCTYPE html>
<html lang="en">
<%- include(dataset.getFile(dataset.theme.codename +"/views/extra/meta"), {title:"Message search!" }) %>
<body style="text-align: center;">
<%- include(dataset.getFile(dataset.theme.codename +"/views/extra/navbar")) %>
<div id="messages">
<% messages.filter(Boolean).forEach(message=>{ %>
<div class="message" id="message-<%= message.id %>">
<div class="left">
<img src="<%= message.author.avatar %>" />
<div class="username"><a href="/users/<%=message.authorID %>"><%=message.author.name %></a></div>
<div class="date">
<%= new Date(message.time).toLocaleDateString() %>
</div>
<div class="date">
<%= new Date(message.time).toLocaleTimeString() %>
</div>
</div>
<div class="content"><%- message.content %></div>
</div>
<% }); %>
</div>
<%- include(dataset.getFile(dataset.theme.codename +"/views/extra/footer")) %>
</body>
</html>

View file

@ -0,0 +1,32 @@
<!DOCTYPE html>
<html lang="en">
<%- include(dataset.getFile(dataset.theme.codename +"/views/extra/meta"), {title: "Register!" }) %>
<body style="text-align: center;">
<%- include(dataset.getFile(dataset.theme.codename +"/views/extra/navbar")) %>
<h1 class="title">Register</h1>
<form class="login" action="/register" method="post">
<input type="email" name="email" placeholder="Email" class="input" required>
<input type="text" name="name" placeholder="Username" class="input" required>
<input type="password" name="password" placeholder="Password" class="input" required>
<textarea class="input" name="about" rows="4" placeholder="About you... You can use markdown"></textarea>
<input type="submit" class="btn-primary" style="width:100%;" value="Register">
</form>
<% if(discord) { %>
<a href="<%=discord%>" class="btn-outline-primary">Auth with discord</a>
<% } %>
<a href="/login" class="btn-outline-primary">Login</a>
<%- include(dataset.getFile(dataset.theme.codename +"/views/extra/footer")) %>
</body>
</html>

Some files were not shown because too many files have changed in this diff Show more