Compare commits
257 commits
Author | SHA1 | Date | |
---|---|---|---|
36cd802b1b | |||
2721259a2a | |||
8c20c70874 | |||
9b5bb9b782 | |||
0438e4fdb4 | |||
00ea899fb4 | |||
aace4c6fd4 | |||
d4021e0e4a | |||
29f8af1394 | |||
![]() |
bda7988000 | ||
f6cafd17b9 | |||
![]() |
9ea41d8372 | ||
f2da949114 | |||
ef8cb5e819 | |||
6b983f9e55 | |||
4811c7a0e3 | |||
fed6ea0668 | |||
fe550051a7 | |||
31b9249cf8 | |||
138b585482 | |||
f31003b3f2 | |||
0bdc21ff35 | |||
9598813e66 | |||
ccd04139ba | |||
8923293198 | |||
9c183eb05e | |||
0311f363e2 | |||
8fe083ded0 | |||
922b8e5732 | |||
9ee1d589eb | |||
![]() |
83ae8507c0 | ||
e3b6345196 | |||
17486e4288 | |||
8e1eff30f5 | |||
a77bee5fde | |||
31b0e86d09 | |||
dced1b0a02 | |||
2a8e26b7b2 | |||
78e78c0694 | |||
edf9fc83dc | |||
080e1e6ced | |||
adb19e2e34 | |||
667c0222e9 | |||
30c701c09e | |||
10b021c16f | |||
8c78ecaed1 | |||
27198d3131 | |||
14fe5d6dbb | |||
16a2504665 | |||
93bb17981f | |||
bd722f1651 | |||
3ac99be051 | |||
fa222ad68d | |||
7f655de129 | |||
250b525e5a | |||
c71f18668f | |||
03d84a2564 | |||
da214db047 | |||
24cdd86e34 | |||
791af87906 | |||
05ac32277f | |||
dc19974506 | |||
388835b85f | |||
4e90431a3d | |||
9304548847 | |||
06632137c0 | |||
47cda470e2 | |||
02364c91ed | |||
0663f968d2 | |||
c3b6ad4d5f | |||
410859fbe3 | |||
a471f19f04 | |||
1de709c632 | |||
e824b844f6 | |||
cc8d64af5d | |||
846ebbe5c9 | |||
318e95ec2e | |||
abeaddc24b | |||
764dcc93f0 | |||
32c2d3d1ca | |||
cf8de73e7c | |||
43720f0ca0 | |||
b8b10a71c5 | |||
db61361a95 | |||
98d379d82c | |||
6b66974a86 | |||
87b7faa0ff | |||
4d39433fe1 | |||
984ac0e621 | |||
fd64ac8693 | |||
0d356239e7 | |||
df14d08cc3 | |||
c32db6dc28 | |||
3671ecc863 | |||
3010efc00a | |||
38042faaf5 | |||
87be28c71f | |||
5a05737f8b | |||
a1af3f2d4e | |||
b048d6a685 | |||
![]() |
e4a93cbf52 | ||
82ec45a786 | |||
598e48e25d | |||
e1aeb8fd14 | |||
d46e2362a4 | |||
abf5c14475 | |||
52386a58e4 | |||
67e12c2c26 | |||
cf02f85eab | |||
e341e8d2e9 | |||
b3735ce606 | |||
e0ce7dc577 | |||
49dbe2d73d | |||
![]() |
3bf0b75a7f | ||
62e7579043 | |||
16f72585a0 | |||
6c5f36836b | |||
a6098a1f34 | |||
8af04ee580 | |||
b7b7de5efd | |||
412a7c1a50 | |||
8864d56659 | |||
817f1c2e13 | |||
1cbcd16954 | |||
171f83f4cf | |||
ccbc1b8e45 | |||
7ecf0a51de | |||
daa5c36281 | |||
2064d0c0a6 | |||
109b83e7cd | |||
868068d80f | |||
2db20ce132 | |||
3527da03d0 | |||
657e18773b | |||
f138276abf | |||
0d0ef1d6c1 | |||
214dd16515 | |||
a816aab7eb | |||
ba0d4cbb8d | |||
34444c65db | |||
e1cca3966e | |||
00c509d0ec | |||
c06af31f26 | |||
aa0bd09efd | |||
980deb7db2 | |||
763204b01c | |||
4fc7fac631 | |||
70386f4088 | |||
40d5f57162 | |||
f7f66d74fe | |||
b660217da4 | |||
ef1b5bff37 | |||
1c207bd08b | |||
035b2b7184 | |||
0ed4a34c8c | |||
80a83d0f14 | |||
3c153ae64f | |||
feb64e2a21 | |||
3a78fd4f6c | |||
e0e6dadc14 | |||
3f36f7761a | |||
9ce1c1bfef | |||
09ac68ee70 | |||
03f52fed3b | |||
bfac0b16af | |||
74b6b73b72 | |||
69697b54b1 | |||
df8bf7eadc | |||
0c74cac622 | |||
b9ce9c149b | |||
f11711ac76 | |||
![]() |
4e12c2d325 | ||
![]() |
2efb974c31 | ||
![]() |
eef5c85217 | ||
![]() |
9831524a00 | ||
![]() |
18974753e0 | ||
![]() |
5e64d2d6e1 | ||
![]() |
56e3c0d41d | ||
538108da94 | |||
0fba3dca03 | |||
1d4e04035f | |||
![]() |
d63fe79e35 | ||
eefa58b585 | |||
2a3ab27ba6 | |||
5b2399bec5 | |||
0358e9e5a2 | |||
ca66d44825 | |||
![]() |
3a4390074e | ||
ad5603c3b7 | |||
c110b195a3 | |||
cd801c94c7 | |||
3b2de7b646 | |||
43408f8a90 | |||
9e9c7be917 | |||
a5cb75fbc5 | |||
f2e4b174d0 | |||
4b75334072 | |||
eb61bf82cd | |||
596f979fbb | |||
c268a5bed8 | |||
![]() |
3fef090992 | ||
6c3fb4c93c | |||
a95015a4b5 | |||
![]() |
46dbbcd343 | ||
![]() |
61ff3951bd | ||
![]() |
6ed388cc05 | ||
![]() |
66e3e37b1c | ||
![]() |
a09a74d370 | ||
2789428dfd | |||
460a870784 | |||
![]() |
13951a1330 | ||
![]() |
c980dc40de | ||
![]() |
51a641e5da | ||
![]() |
fec93df6c6 | ||
e2e2b5bf59 | |||
71f820160d | |||
46d5eac8d8 | |||
98675863bb | |||
0f436912d3 | |||
d2753c2070 | |||
b0a7ac7605 | |||
![]() |
86d6c64a7b | ||
256b70c611 | |||
06f15548b5 | |||
01a07697aa | |||
e9ad89b269 | |||
dd300d06ba | |||
f10b222380 | |||
ab1f062d9d | |||
e01ef642c3 | |||
5dc9570809 | |||
0a09da789c | |||
22028eeb3d | |||
5c259f02f3 | |||
5b020ff548 | |||
75c36abd75 | |||
0ee3d3be8b | |||
52eda8ddad | |||
facaf105eb | |||
9ad7c03162 | |||
c8474694da | |||
3378388e8c | |||
![]() |
9154b3c246 | ||
![]() |
cbe03616b6 | ||
![]() |
c43011d83d | ||
68e4a92b8a | |||
b80f7ac52d | |||
20b00af256 | |||
d6f301da72 | |||
![]() |
c5e62a300c | ||
![]() |
a9fe56bb6b | ||
![]() |
8b91d8d182 | ||
14bf6d45a4 | |||
7b4dc19cb7 | |||
b1afa3e9a9 | |||
![]() |
afc55b4dcb | ||
108bf2ddff |
|
@ -1 +1,7 @@
|
||||||
MONGO_DB_URL = mongodb://localhost:27017/akf-forum
|
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
|
@ -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/*"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
9
.gitignore
vendored
|
@ -1,8 +1,15 @@
|
||||||
# Dependency directories
|
# Dependency directories
|
||||||
node_modules/
|
node_modules/
|
||||||
|
|
||||||
# env
|
# Environment variables
|
||||||
.env
|
.env
|
||||||
|
|
||||||
|
# Config files
|
||||||
|
config.json
|
||||||
|
|
||||||
# Test files
|
# Test files
|
||||||
test.js
|
test.js
|
||||||
|
|
||||||
|
# avatars folder
|
||||||
|
src/public/images/avatars/*
|
||||||
|
!src/public/images/avatars/default.jpg
|
117
APIDOCS.md
|
@ -1,66 +1,105 @@
|
||||||
# API documentation of Akf-forum
|
# API documentation of Akf-forum
|
||||||
<img src="https://raw.githubusercontent.com/Akif9748/akf-forum/main/public/images/logo.jpg" align="right" width="300px" />
|
|
||||||
|
|
||||||
Akf-forum has got an API for AJAX, other clients etc.
|
Akf-forum has got an API for AJAX, other clients etc.
|
||||||
|
|
||||||
## Authorization
|
## Authorization
|
||||||
You need this headers for send request to API:
|
You need this header for send request to API:
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"username": "testUser",
|
"authorization": "Basic <base64 encoded username:password>"
|
||||||
"password": "testPassword"
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
But in front end, the API will works with session.
|
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?
|
## How to request?
|
||||||
|
|
||||||
### Request types:
|
### Request types:
|
||||||
- GET `/api/users/:id` for fetch user.
|
#### `/api/me`
|
||||||
- POST `/api/users/:id/delete` for delete user.
|
- GET `/api/me` to get your account.
|
||||||
- POST `/api/users/:id/undelete` for undelete user.
|
|
||||||
- POST `/api/users/:id/admin` for give admin permissions for a user.
|
|
||||||
|
|
||||||
- GET `/api/threads/:id` for fetch thread.
|
#### `/api/config`
|
||||||
- GET `/api/threads/:id/messages/:limit` for fetch messages in thread.
|
- GET `/` to reach config file.
|
||||||
- POST `/api/threads` for create thread.
|
- PUT `/` to edit config file.
|
||||||
- POST `/api/threads/:id/delete` for delete thread.
|
|
||||||
- POST `/api/threads/:id/undelete` for undelete thread.
|
|
||||||
|
|
||||||
- GET `/api/messages/:id` for fetch message.
|
#### `/api/bans`
|
||||||
- POST `/api/messages` for create message.
|
- GET `/` fetch all bans.
|
||||||
- POST `/api/messages/:id/delete` for delete message.
|
- GET `/:ip` fetch a ban.
|
||||||
- POST `/api/messages/:id/undelete` for undelete message.
|
- DELETE `/:ip` for unban an IP adress.
|
||||||
- POST `/api/messages/:id/react/:type` for react to a message.
|
- 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:
|
### Example request:
|
||||||
GET ```/api/messages/0```
|
GET ```/api/messages/0```
|
||||||
|
|
||||||
#### Example API Output:
|
#### Example API Output:
|
||||||
```json
|
```js
|
||||||
{
|
{
|
||||||
"_id": "63067429bc01da866fad508b",
|
"react": {
|
||||||
"threadID": "0",
|
"like": [],
|
||||||
"author": {
|
"dislike": ["0"]
|
||||||
"id": "0",
|
|
||||||
"name": "Akif9748",
|
|
||||||
"avatar": "https://cdn.discordapp.com/avatars/539506680140922890/abd74d10aac094fc8a5ad5c86f29fdb9.png?size=1024",
|
|
||||||
"time": "2022-08-24T18:54:55.666Z",
|
|
||||||
"deleted": false,
|
|
||||||
"admin": false,
|
|
||||||
"_id": "630673ffbc01da866fad507b",
|
|
||||||
"__v": 0
|
|
||||||
},
|
},
|
||||||
"content": "deneme",
|
"_id": "6325c216faa938c4cfc43075",
|
||||||
|
"author": {
|
||||||
|
"_id": "632e028ca4ba362ebbb75a43",
|
||||||
|
"name": "Akif9748",
|
||||||
|
"avatar": "/images/avatars/0.jpg",
|
||||||
"deleted": false,
|
"deleted": false,
|
||||||
"edited": false,
|
"edited": true,
|
||||||
"time": "2022-08-24T18:55:37.744Z",
|
"about": "# Owner",
|
||||||
|
"admin": true,
|
||||||
|
"theme": "black",
|
||||||
|
"hideLastSeen": false,
|
||||||
|
"time": "2022-09-23T19:01:32.610Z",
|
||||||
"id": "0",
|
"id": "0",
|
||||||
"__v": 0,
|
"__v": 0,
|
||||||
"react": {
|
"discordID": "539506680140922890"
|
||||||
"0": true
|
|
||||||
},
|
},
|
||||||
"authorID": "0",
|
"threadID": "0",
|
||||||
"reactCount": 1
|
"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
|
@ -1,5 +1,5 @@
|
||||||
GNU GENERAL PUBLIC LICENSE
|
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||||
Version 3, 29 June 2007
|
Version 3, 19 November 2007
|
||||||
|
|
||||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||||
Everyone is permitted to copy and distribute verbatim copies
|
Everyone is permitted to copy and distribute verbatim copies
|
||||||
|
@ -7,17 +7,15 @@
|
||||||
|
|
||||||
Preamble
|
Preamble
|
||||||
|
|
||||||
The GNU General Public License is a free, copyleft license for
|
The GNU Affero General Public License is a free, copyleft license for
|
||||||
software and other kinds of works.
|
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
|
The licenses for most software and other practical works are designed
|
||||||
to take away your freedom to share and change the works. By contrast,
|
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
|
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
|
software for all its users.
|
||||||
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.
|
|
||||||
|
|
||||||
When we speak of free software, we are referring to freedom, not
|
When we speak of free software, we are referring to freedom, not
|
||||||
price. Our General Public Licenses are designed to make sure that you
|
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
|
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.
|
free programs, and that you know you can do these things.
|
||||||
|
|
||||||
To protect your rights, we need to prevent others from denying you
|
Developers that use our General Public Licenses protect your rights
|
||||||
these rights or asking you to surrender the rights. Therefore, you have
|
with two steps: (1) assert copyright on the software, and (2) offer
|
||||||
certain responsibilities if you distribute copies of the software, or if
|
you this License which gives you legal permission to copy, distribute
|
||||||
you modify it: responsibilities to respect the freedom of others.
|
and/or modify the software.
|
||||||
|
|
||||||
For example, if you distribute copies of such a program, whether
|
A secondary benefit of defending all users' freedom is that
|
||||||
gratis or for a fee, you must pass on to the recipients the same
|
improvements made in alternate versions of the program, if they
|
||||||
freedoms that you received. You must make sure that they, too, receive
|
receive widespread use, become available for other developers to
|
||||||
or can get the source code. And you must show them these terms so they
|
incorporate. Many developers of free software are heartened and
|
||||||
know their rights.
|
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:
|
The GNU Affero General Public License is designed specifically to
|
||||||
(1) assert copyright on the software, and (2) offer you this License
|
ensure that, in such cases, the modified source code becomes available
|
||||||
giving you legal permission to copy, distribute and/or modify it.
|
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
|
An older license, called the Affero General Public License and
|
||||||
that there is no warranty for this free software. For both users' and
|
published by Affero, was designed to accomplish similar goals. This is
|
||||||
authors' sake, the GPL requires that modified versions be marked as
|
a different license, not a version of the Affero GPL, but Affero has
|
||||||
changed, so that their problems will not be attributed erroneously to
|
released a new version of the Affero GPL which permits relicensing under
|
||||||
authors of previous versions.
|
this license.
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
The precise terms and conditions for copying, distribution and
|
The precise terms and conditions for copying, distribution and
|
||||||
modification follow.
|
modification follow.
|
||||||
|
@ -72,7 +60,7 @@ modification follow.
|
||||||
|
|
||||||
0. Definitions.
|
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
|
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||||
works, such as semiconductor masks.
|
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
|
the Program, the only way you could satisfy both those terms and this
|
||||||
License would be to refrain entirely from conveying the Program.
|
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
|
Notwithstanding any other provision of this License, you have
|
||||||
permission to link or combine any covered work with a work licensed
|
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
|
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,
|
License will continue to apply to the part which is the covered work,
|
||||||
but the special requirements of the GNU Affero General Public License,
|
but the work with which it is combined will remain governed by version
|
||||||
section 13, concerning interaction through a network will apply to the
|
3 of the GNU General Public License.
|
||||||
combination as such.
|
|
||||||
|
|
||||||
14. Revised Versions of this License.
|
14. Revised Versions of this License.
|
||||||
|
|
||||||
The Free Software Foundation may publish revised and/or new versions of
|
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
|
the GNU Affero General Public License from time to time. Such new versions
|
||||||
be similar in spirit to the present version, but may differ in detail to
|
will be similar in spirit to the present version, but may differ in detail to
|
||||||
address new problems or concerns.
|
address new problems or concerns.
|
||||||
|
|
||||||
Each version is given a distinguishing version number. If the
|
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
|
Public License "or any later version" applies to it, you have the
|
||||||
option of following the terms and conditions either of that numbered
|
option of following the terms and conditions either of that numbered
|
||||||
version or of any later version published by the Free Software
|
version or of any later version published by the Free Software
|
||||||
Foundation. If the Program does not specify a version number of the
|
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.
|
by the Free Software Foundation.
|
||||||
|
|
||||||
If the Program specifies that a proxy can decide which future
|
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
|
public statement of acceptance of a version permanently authorizes you
|
||||||
to choose that version for the Program.
|
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>
|
Copyright (C) <year> <name of author>
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
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
|
it under the terms of the GNU Affero General Public License as published
|
||||||
the Free Software Foundation, either version 3 of the License, or
|
by the Free Software Foundation, either version 3 of the License, or
|
||||||
(at your option) any later version.
|
(at your option) any later version.
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
This program is distributed in the hope that it will be useful,
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
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/>.
|
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.
|
Also add information on how to contact you by electronic and paper mail.
|
||||||
|
|
||||||
If the program does terminal interaction, make it output a short
|
If your software can interact with users remotely through a computer
|
||||||
notice like this when it starts in an interactive mode:
|
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
|
||||||
<program> Copyright (C) <year> <name of author>
|
interface could display a "Source" link that leads users to an archive
|
||||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
of the code. There are many ways you could offer source, and different
|
||||||
This is free software, and you are welcome to redistribute it
|
solutions will be better for different programs; see section 13 for the
|
||||||
under certain conditions; type `show c' for details.
|
specific requirements.
|
||||||
|
|
||||||
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".
|
|
||||||
|
|
||||||
You should also get your employer (if you work as a programmer) or school,
|
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.
|
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/>.
|
<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>.
|
|
||||||
|
|
143
README.md
|
@ -1,19 +1,37 @@
|
||||||
# akf-forum
|
# akf-forum
|
||||||
<img src="https://raw.githubusercontent.com/Akif9748/akf-forum/main/public/images/logo.jpg" align="right" width="300px" />
|
|
||||||
|
|
||||||
A Node.js based forum software.
|
A Node.js based forum software.
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
- Clone or download this repo.
|
- Clone or download this repo.
|
||||||
- Run `npm i` to install **dependencies**.
|
- Run `npm i` to install **dependencies**.
|
||||||
|
- Enter your database credentials in `.env`.
|
||||||
- Run `npm start` for run it.
|
- Run `npm start` for run it.
|
||||||
|
- Go `/setup` page for setup your forum.
|
||||||
|
|
||||||
### Extra
|
### Extra (If you are not use `setup` page)
|
||||||
Run `node util/reset` to **reset the database**, and run `node util/admin` for give admin perms to first member.
|
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 themes of users...
|
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
|
## API
|
||||||
Akf-forum has got an API for AJAX, other clients etc. And, you can learn about API in `util/APIDOCS.md`.
|
Akf-forum has got an API for AJAX (fetch), other clients etc. And, you can learn about API in `APIDOCS.md`.
|
||||||
|
|
||||||
|
**And you can use [offical API wrapper](https://github.com/Akif9748/akf-forum-api).**
|
||||||
|
|
||||||
## Credits
|
## Credits
|
||||||
* [Akif9748](https://github.com/Akif9748) - Project mainteiner, main developer, made **old** frontend
|
* [Akif9748](https://github.com/Akif9748) - Project mainteiner, main developer, made **old** frontend
|
||||||
|
@ -21,78 +39,65 @@ Akf-forum has got an API for AJAX, other clients etc. And, you can learn about A
|
||||||
* [Camroku](https://github.com/Camroku) - Made **old** stylesheets
|
* [Camroku](https://github.com/Camroku) - Made **old** stylesheets
|
||||||
|
|
||||||
## Screenshot
|
## Screenshot
|
||||||
### Old frontend
|
|
||||||

|
|
||||||
### New frontend
|
|
||||||

|
|
||||||
|
|
||||||
|
### Thread Page w/Bootstrap theme
|
||||||
|

|
||||||
|
|
||||||
## Roadmap
|
## TO-DO list
|
||||||
### TO-DO:
|
|
||||||
- If thread deleted, not show its messages in API.
|
### Backend:
|
||||||
- Thread.ejs fix with new theme
|
#### Feature:
|
||||||
- Profile photos will store in database
|
- Profile Message or DM
|
||||||
- regex for pfp for now and
|
- Upload other photos, model for it
|
||||||
- admin perm for undelete, thread+message
|
- 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
|
||||||
|
|
||||||
### Frontend
|
### Frontend
|
||||||
### User
|
#### Features:
|
||||||
| To do | Is done? | Priority |
|
- change category name
|
||||||
| ----- | -------- | -------- |
|
- Add approval threads page.
|
||||||
| Login via redirect query | 🟢 | HIGH |
|
- add support for switch around gravatar and upload photo
|
||||||
| Register | 🟢 | HIGH |
|
- old contents / titles add to forum interface
|
||||||
| Logout | 🟢 | HIGH |
|
- who liked a message
|
||||||
| Admin | 🟢 | HIGH |
|
|
||||||
| Message count | 🟢 | MEDIUM |
|
|
||||||
| Delete user | 🟢 | HIGH |
|
|
||||||
| Undelete | 🟢 | MEDIUM |
|
|
||||||
| PM | 🔴 | MEDIUM |
|
|
||||||
| About me | 🔴 | LOW |
|
|
||||||
| Edit user | 🔴 | HIGH |
|
|
||||||
| IP ban | 🔴 | MEDIUM |
|
|
||||||
|
|
||||||
### Messages
|
#### Fixes:
|
||||||
| To do | Is done? | Priority |
|
- BETTER SETUP PAGE: use setup everytime
|
||||||
| ----- | -------- | -------- |
|
- add threads, messages etc. to "extra" folder
|
||||||
| Ratelimit | 🟢 | HIGH |
|
- add category to thread list page
|
||||||
| Send | 🟢 | HIGH |
|
- working reset button
|
||||||
| Delete | 🟢 | HIGH |
|
- text alling center body
|
||||||
| Regex for scripts | 🔴 | HIGH |
|
- thread.js unfuction only listener
|
||||||
| Undelete | 🟢 | MEDIUM |
|
- show error on modal
|
||||||
| React | 🟢 | MEDIUM |
|
- send delete, ban to user settings (edit user) menu and fix edit user menu
|
||||||
| Edit | 🔴 | MEDIUM |
|
|
||||||
|
|
||||||
### Threads
|
## Special Thanks:
|
||||||
| To do | Is done? | Priority |
|
https://github.com/akashgiricse/Online-Forum for bootstrap theme.
|
||||||
| ----- | -------- | -------- |
|
|
||||||
| Ratelimit | 🟢 | HIGH |
|
|
||||||
| Create | 🟢 | HIGH |
|
|
||||||
| Delete | 🟢 | HIGH |
|
|
||||||
| Undelete | 🟢 | MEDIUM |
|
|
||||||
| Edit | 🔴 | MEDIUM |
|
|
||||||
|
|
||||||
### API
|
@Tokmak for old frontend.
|
||||||
| To do | Is done?
|
|
||||||
| ----- | --------
|
https://fengyuanchen.github.io/cropperjs/examples/crop-a-round-image.html for avatar upload panel.
|
||||||
| RATELIMITS | 🟢
|
|
||||||
| Get message**s** | 🟢
|
https://github.com/mdbootstrap/bootstrap-profiles for profile page of bootstrap theme.
|
||||||
| Create message & thread & user | 🟢
|
|
||||||
| Get message & thread & user | 🟢
|
|
||||||
| Delete message & thread & user | 🟢
|
|
||||||
| Undelete message & thread & user | 🟢
|
|
||||||
| Edit message & thread & user | 🔴
|
|
||||||
|
|
||||||
### Other
|
|
||||||
| To do | Is done? | Priority |
|
|
||||||
| ----- | -------- | -------- |
|
|
||||||
| from form to AJAX | 🟢 | HIGH |
|
|
||||||
| auto-scroll | 🟢 | LOW |
|
|
||||||
| Multi-theme support, black theme | 🟡 | LOW |
|
|
||||||
| Search | 🔴 | MEDIUM |
|
|
||||||
| Page support, support message limit correct | 🔴 | MEDIUM |
|
|
||||||
| Locales | 🔴 | MEDIUM |
|
|
||||||
| Footer | 🔴 | LOW |
|
|
||||||
## Major Version History
|
## Major Version History
|
||||||
|
- V5: Enchanted Themes
|
||||||
|
- V4: Caching
|
||||||
- V3: New Theme
|
- V3: New Theme
|
||||||
- V2: Backend fix, mongoose is fixed. Really big fix.
|
- V2: Backend fix, mongoose is fixed. Really big fix.
|
||||||
- V1: Mongoose added.
|
- V1: Mongoose added.
|
||||||
|
|
|
@ -1,3 +0,0 @@
|
||||||
{
|
|
||||||
"def_theme": "default"
|
|
||||||
}
|
|
24
config.json.example
Normal 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"
|
||||||
|
}
|
38
index.js
|
@ -1,38 +0,0 @@
|
||||||
const { def_theme } = require("./config.json"),
|
|
||||||
session = require('express-session'),
|
|
||||||
{ UserModel } = require("./models"),
|
|
||||||
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.reply = (page, options = {}, status = 200) => res.status(status)
|
|
||||||
.render(page, { user: req.user, theme: req.user?.theme || def_theme, ...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.");
|
|
||||||
}
|
|
||||||
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));
|
|
|
@ -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;
|
|
||||||
|
|
|
@ -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);
|
|
|
@ -1,37 +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],
|
|
||||||
views: { type: Number, default: 0 }
|
|
||||||
|
|
||||||
});
|
|
||||||
|
|
||||||
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;
|
|
|
@ -1,29 +0,0 @@
|
||||||
const mongoose = require("mongoose")
|
|
||||||
const { def_theme } = require("../config.json");
|
|
||||||
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 },
|
|
||||||
theme: { type: String, default: def_theme }
|
|
||||||
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
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;
|
|
|
@ -1,6 +0,0 @@
|
||||||
const UserModel = require("./User"),
|
|
||||||
MessageModel = require("./Message"),
|
|
||||||
ThreadModel = require("./Thread"),
|
|
||||||
SecretModel = require("./Secret");
|
|
||||||
|
|
||||||
module.exports = { UserModel, MessageModel, ThreadModel, SecretModel };
|
|
4357
package-lock.json
generated
33
package.json
|
@ -1,10 +1,13 @@
|
||||||
{
|
{
|
||||||
"name": "akf-forum",
|
"name": "akf-forum",
|
||||||
"version": "3.2.0",
|
"version": "5.7.1",
|
||||||
"description": "A Node.js based forum software",
|
"description": "A Node.js based forum software",
|
||||||
"main": "index.js",
|
"main": "src/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node ."
|
"start": "node .",
|
||||||
|
"reset": "node util/reset",
|
||||||
|
"admin": "node util/admin",
|
||||||
|
"test": "echo \"Error: no test specified\" && exit 1"
|
||||||
},
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
|
@ -20,17 +23,23 @@
|
||||||
"url": "https://github.com/Akif9748/akf-forum/issues"
|
"url": "https://github.com/Akif9748/akf-forum/issues"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=16"
|
"node": ">=18 >=17.5 >=16.15"
|
||||||
},
|
},
|
||||||
"homepage": "https://akf-forum.glitch.me/",
|
"homepage": "https://akf-forum.glitch.me/",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bcrypt": "^5.0.1",
|
"bcrypt": "^5.1.1",
|
||||||
"body-parser": "^1.19.2",
|
"connect-mongo": "^5.1.0",
|
||||||
"dotenv": "^16.0.1",
|
"dotenv": "^16.4.2",
|
||||||
"ejs": "^3.1.6",
|
"ejs": "^3.1.9",
|
||||||
"express": "^4.17.3",
|
"express": "^4.18.2",
|
||||||
"express-rate-limit": "^6.5.1",
|
"express-rate-limit": "^7.1.5",
|
||||||
"express-session": "^1.17.2",
|
"express-session": "^1.18.0",
|
||||||
"mongoose": "^6.5.1"
|
"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
|
@ -1,23 +0,0 @@
|
||||||
.title {
|
|
||||||
color: #4d18e6;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
form {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
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;
|
|
||||||
}
|
|
|
@ -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;
|
|
||||||
}
|
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,188 +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;
|
|
||||||
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: */
|
|
||||||
|
|
||||||
|
|
||||||
.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;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/*
|
|
||||||
*****************************
|
|
||||||
ADMIN TEXT
|
|
||||||
*****************************
|
|
||||||
*/
|
|
||||||
p {font-size: 18px;}
|
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,43 +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;
|
|
||||||
max-width:500px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-box-title {
|
|
||||||
padding: 10px;
|
|
||||||
margin: 8px;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-box-title>span {
|
|
||||||
color: #ff0000;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-box-img {
|
|
||||||
width: 80px;
|
|
||||||
height: 80px;
|
|
||||||
margin: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 992px) {
|
|
||||||
.users {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-box {
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
}
|
|
Before Width: | Height: | Size: 5 KiB |
Before Width: | Height: | Size: 5 KiB |
Before Width: | Height: | Size: 15 KiB |
|
@ -1,111 +0,0 @@
|
||||||
import request from "./request.js";
|
|
||||||
|
|
||||||
const message_div = document.getElementById("messages");
|
|
||||||
|
|
||||||
const messages_raw = await fetch(`/api/threads/${message_div.getAttribute("value")}/messages/`).then(res => res.json());
|
|
||||||
for (const message of messages_raw)
|
|
||||||
renderMessage(message);
|
|
||||||
|
|
||||||
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}">
|
|
||||||
${/* if */!message.deleted ?
|
|
||||||
`
|
|
||||||
<a onclick="delete_message('${message.id}');">DELETE</a>
|
|
||||||
<a onclick="edit_message('${message.id}');">EDIT</a>
|
|
||||||
` /* else */ :
|
|
||||||
`<h3 style=\"display:inline;\">This message has been deleted</h3>
|
|
||||||
<a onclick="undelete_message('${message.id}');">UNDELETE</a>
|
|
||||||
`
|
|
||||||
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
<div style="float: right;">
|
|
||||||
<h3 id="count${message.id}" style="display:inline;">0</h3>
|
|
||||||
<a onclick="react('${message.id}', 'like');">+🔼</a>
|
|
||||||
<a onclick="react('${message.id}', 'dislike');">-🔽</a>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
message_div.appendChild(messageElement);
|
|
||||||
message_div.innerHTML += "<br>";
|
|
||||||
};
|
|
||||||
|
|
||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* OTHER FUNCTIONS
|
|
||||||
*/
|
|
||||||
|
|
||||||
async function delete_thread(id) {
|
|
||||||
const response = await request("/api/threads/" + id + "/delete");
|
|
||||||
if (response.deleted) {
|
|
||||||
alert("Thread deleted");
|
|
||||||
location.reload();
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
async function undelete_thread(id) {
|
|
||||||
const response = await request("/api/threads/" + id + "/undelete");
|
|
||||||
if (!response.deleted) {
|
|
||||||
alert("Thread undeleted");
|
|
||||||
location.reload();
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
async function undelete_message(id) {
|
|
||||||
const response = await request(`/api/messages/${id}/undelete`);
|
|
||||||
if (!response.deleted)
|
|
||||||
document.getElementById("message-delete-" + id).innerHTML = `<a onclick=\"delete_message('${id}');\">DELETE</a>`;
|
|
||||||
|
|
||||||
}
|
|
||||||
async function delete_message(id) {
|
|
||||||
const response = await request(`/api/messages/${id}/delete`);
|
|
||||||
if (response.deleted) {
|
|
||||||
alert("Message deleted");
|
|
||||||
document.getElementById("message-delete-" + id).innerHTML = `
|
|
||||||
<h3 style=\"display:inline;\">This message has been deleted</h3>
|
|
||||||
<a onclick="undelete_message('${id}');">UNDELETE</a>`;// ADMIN PERM FIX
|
|
||||||
}
|
|
||||||
}
|
|
||||||
async function react(id, type) {
|
|
||||||
const res = await request(`/api/messages/${id}/react/${type}`)
|
|
||||||
document.getElementById(`count${id}`).innerHTML = res.reactCount;
|
|
||||||
}
|
|
||||||
|
|
||||||
window.delete_message = delete_message;
|
|
||||||
window.undelete_message = undelete_message;
|
|
||||||
window.react = react;
|
|
||||||
window.delete_thread = delete_thread;
|
|
||||||
window.undelete_thread = undelete_thread;
|
|
19
routes/.js
|
@ -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.reply("index", { mem, users, threads, messages })
|
|
||||||
|
|
||||||
})
|
|
||||||
|
|
||||||
module.exports = app;
|
|
|
@ -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.reply("admin", { user2: false })
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
module.exports = app;
|
|
|
@ -1,43 +0,0 @@
|
||||||
const { Router, request, response } = require("express")
|
|
||||||
const app = Router();
|
|
||||||
const bcrypt = require("bcrypt");
|
|
||||||
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")
|
|
||||||
|
|
||||||
|
|
||||||
if (!bcrypt.compare(password, user.password)) 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;
|
|
|
@ -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;
|
|
|
@ -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;
|
|
|
@ -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(req.params.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;
|
|
|
@ -1,36 +0,0 @@
|
||||||
const { UserModel, SecretModel } = 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}));
|
|
||||||
|
|
||||||
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) {
|
|
||||||
|
|
||||||
if (!bcrypt.compare(password, user.password)) 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;
|
|
|
@ -1,42 +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.reply("register", { user: null }));
|
|
||||||
|
|
||||||
app.post("/", rateLimit({
|
|
||||||
windowMs: 24 * 60 * 60_000, max: 1, standardHeaders: true, legacyHeaders: false,
|
|
||||||
handler: (_r, response, _n, options) => response.error(options.statusCode, "You are begin ratelimited")
|
|
||||||
}), async (req, res) => {
|
|
||||||
req.session.userid = null;
|
|
||||||
|
|
||||||
|
|
||||||
let { username = null, password: body_pass = null, avatar } = req.body;
|
|
||||||
|
|
||||||
if (!username || !body_pass) return res.error(res, 400, "You forgot entering some values");
|
|
||||||
const user = await SecretModel.findOne({ username });
|
|
||||||
|
|
||||||
if (user) return res.error(res, 400, `We have got an user named ${username}!`)
|
|
||||||
|
|
||||||
|
|
||||||
const user2 = new UserModel({ name: req.body.username })
|
|
||||||
if (avatar && /https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)/g.test(avatar)) user2.avatar = avatar;
|
|
||||||
await user2.takeId()
|
|
||||||
await user2.save();
|
|
||||||
|
|
||||||
const salt = await bcrypt.genSalt(10);
|
|
||||||
const password = await bcrypt.hash(body_pass, salt);
|
|
||||||
await SecretModel.create({ username, password, id: user2.id })
|
|
||||||
req.session.userid = user2.id;
|
|
||||||
|
|
||||||
res.redirect('/');
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
module.exports = app;
|
|
|
@ -1,32 +0,0 @@
|
||||||
const { Router } = require("express");
|
|
||||||
const app = Router();
|
|
||||||
|
|
||||||
const { ThreadModel, MessageModel } = require("../models")
|
|
||||||
|
|
||||||
|
|
||||||
app.get("/", async (req, res) => {
|
|
||||||
|
|
||||||
const threads = await ThreadModel.find(req.user?.admin ? {} : { deleted: false })//.limit(10);
|
|
||||||
|
|
||||||
return res.reply("threads", { threads });
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
app.get("/create*", (req, res) => res.reply("create_thread"));
|
|
||||||
|
|
||||||
app.get("/:id", async (req, res) => {
|
|
||||||
|
|
||||||
const { id } = req.params;
|
|
||||||
|
|
||||||
const thread = await ThreadModel.get(id);
|
|
||||||
thread.views++;
|
|
||||||
|
|
||||||
if (thread && (req.user?.admin || !thread.deleted))
|
|
||||||
res.reply("thread", { thread, scroll: req.query.scroll || false });
|
|
||||||
else
|
|
||||||
res.error(404, "We have not got this thread.");
|
|
||||||
thread.save();
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
module.exports = app;
|
|
|
@ -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.reply("users", { users })
|
|
||||||
|
|
||||||
});
|
|
||||||
|
|
||||||
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.reply("user", { member, counts: { message, thread } })
|
|
||||||
}
|
|
||||||
else res.error(404, "We have not got this user.");
|
|
||||||
|
|
||||||
});
|
|
||||||
|
|
||||||
module.exports = app;
|
|
92
src/index.js
Normal 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
|
@ -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
|
@ -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
|
@ -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
|
@ -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
|
@ -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
|
@ -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
|
@ -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
|
@ -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
After Width: | Height: | Size: 4.2 KiB |
BIN
src/public/images/avatars/0.jpg
Normal file
After Width: | Height: | Size: 206 KiB |
BIN
src/public/images/avatars/1.jpg
Normal file
After Width: | Height: | Size: 64 KiB |
BIN
src/public/images/avatars/default.jpg
Normal file
After Width: | Height: | Size: 5.3 KiB |
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 4.4 KiB |
63
src/public/js/avatar.js
Normal 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
|
@ -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
|
@ -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
|
@ -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;
|
||||||
|
}
|
304
src/public/libs/cropper/cropper.css
Normal 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;
|
||||||
|
}
|
3259
src/public/libs/cropper/cropper.js
Normal file
3
src/public/libs/showdown/showdown.min.js
vendored
Normal file
7
src/public/libs/simplemde/simplemde.min.css
vendored
Normal file
15
src/public/libs/simplemde/simplemde.min.js
vendored
Normal file
31
src/routes/.js
Normal 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
|
@ -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
|
@ -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;
|
38
src/routes/api/routes/bans.js
Normal 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;
|
44
src/routes/api/routes/categories.js
Normal 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;
|
25
src/routes/api/routes/config.js
Normal 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;
|
113
src/routes/api/routes/messages.js
Normal 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;
|
40
src/routes/api/routes/search.js
Normal 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;
|
15
src/routes/api/routes/themes.js
Normal 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;
|
111
src/routes/api/routes/threads.js
Normal 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;
|
112
src/routes/api/routes/users.js
Normal 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
|
@ -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
|
@ -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
|
@ -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;
|
|
@ -7,8 +7,9 @@ const app = Router();
|
||||||
app.get("/:id", async (req, res) => {
|
app.get("/:id", async (req, res) => {
|
||||||
const message = await MessageModel.get(req.params.id);
|
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.");
|
if (!message || (message.deleted && !req.user?.admin)) return res.error( 404,
|
||||||
res.redirect("/threads/" + message.threadID+"?scroll="+req.params.id);
|
`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
|
@ -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
|
@ -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
|
@ -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
|
@ -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
|
@ -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
|
@ -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;
|
6
src/themes/bootstrap_black/index.js
Normal 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"
|
||||||
|
}
|
10
src/themes/bootstrap_black/public/bootstrap-night.min.css
vendored
Normal file
7
src/themes/bootstrap_black/public/bootstrap.min.js
vendored
Normal file
650
src/themes/bootstrap_black/public/main.css
Normal 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;
|
||||||
|
}
|
BIN
src/themes/bootstrap_black/public/upload.png
Normal file
After Width: | Height: | Size: 10 KiB |
124
src/themes/bootstrap_black/views/admin.ejs
Normal 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">×</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>
|
55
src/themes/bootstrap_black/views/avatar_upload.ejs
Normal 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>
|
76
src/themes/bootstrap_black/views/categories.ejs
Normal 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>
|
32
src/themes/bootstrap_black/views/config.ejs
Normal 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>
|
59
src/themes/bootstrap_black/views/create_category.ejs
Normal 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>
|
69
src/themes/bootstrap_black/views/create_thread.ejs
Normal 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>
|
62
src/themes/bootstrap_black/views/edit_user.ejs
Normal 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>
|
17
src/themes/bootstrap_black/views/error.ejs
Normal 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>
|
41
src/themes/bootstrap_black/views/extra/footer.ejs
Normal 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>
|
10
src/themes/bootstrap_black/views/extra/meta.ejs
Normal 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>
|
70
src/themes/bootstrap_black/views/extra/navbar.ejs
Normal 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>
|
179
src/themes/bootstrap_black/views/index.ejs
Normal 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>
|
50
src/themes/bootstrap_black/views/login.ejs
Normal 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>
|