Compare commits

...

132 Commits

Author SHA1 Message Date
8c20c70874 5.7.0 2024-02-10 23:51:48 +03:00
9b5bb9b782 Themes folder update
- discord better auth system with email
- module updates
- Cache is working now
- avatar.js extruted
- pagination++
- ban and password change button on interface
2024-02-10 23:51:42 +03:00
0438e4fdb4 5.6.1 2023-08-27 20:22:49 +03:00
00ea899fb4 Clear online guest, new dc username 2023-08-27 20:22:28 +03:00
aace4c6fd4 5.6.0 2023-08-25 13:46:04 +03:00
d4021e0e4a Merge branch 'main' of https://github.com/Akif9748/akf-forum 2023-08-25 13:45:59 +03:00
29f8af1394 Added me support for ID's 2023-08-25 13:45:55 +03:00
Akif9748
bda7988000
Update README.md 2023-08-22 22:42:45 +03:00
f6cafd17b9 fix typo 2023-08-10 15:33:40 +03:00
Akif9748
9ea41d8372
Update README.md 2023-08-10 15:23:11 +03:00
f2da949114 5.5.2 2023-05-26 21:08:22 +03:00
ef8cb5e819 Better user.ejs 2023-05-26 21:08:14 +03:00
6b983f9e55 5.5.1 2023-05-26 19:55:51 +03:00
4811c7a0e3 Delete -forum from forum name 2023-05-26 19:55:45 +03:00
fed6ea0668 update to do list 2023-05-26 19:47:31 +03:00
fe550051a7 5.5.0 2023-05-26 19:40:19 +03:00
31b9249cf8 change pass & email 2023-05-26 19:40:14 +03:00
138b585482 email & pass change 2023-05-26 19:39:57 +03:00
f31003b3f2 author url 2023-05-26 19:20:51 +03:00
0bdc21ff35 Fix for user pages 2023-05-26 19:11:14 +03:00
9598813e66 fix for avatars 2023-05-26 19:08:53 +03:00
ccd04139ba fix for cropper 2023-05-26 19:06:02 +03:00
8923293198 5.4.0 2023-05-26 18:57:35 +03:00
9c183eb05e New threads for homepage, and XENFORO theme 2023-05-26 18:57:29 +03:00
0311f363e2 5.3.0 2023-05-26 12:02:57 +03:00
8fe083ded0 Online users support and new messages on mainpage 2023-05-26 12:02:46 +03:00
922b8e5732 5.2.6 2023-05-25 19:28:15 +03:00
9ee1d589eb Better pagination and rewiretten threads etc. 2023-05-25 19:28:05 +03:00
Akif9748
83ae8507c0
Update README.md 2023-05-25 18:07:18 +03:00
e3b6345196 fix 2023-05-25 18:03:22 +03:00
17486e4288 5.2.5 2023-05-25 18:00:58 +03:00
8e1eff30f5 usermenu not needs to templates 2023-05-25 18:00:53 +03:00
a77bee5fde 5.2.4 2023-05-25 17:58:53 +03:00
31b0e86d09 better discord auth 2023-05-25 17:58:47 +03:00
dced1b0a02 5.2.3 2023-05-25 17:48:07 +03:00
2a8e26b7b2 better folder system 2023-05-25 17:47:54 +03:00
78e78c0694 5.2.2 2023-05-25 17:23:41 +03:00
edf9fc83dc full support for multiple themes, secure templates 2023-05-25 17:23:31 +03:00
080e1e6ced 5.2.1 2023-05-25 16:15:57 +03:00
adb19e2e34 setup and index is now differnt 2023-05-25 16:15:50 +03:00
667c0222e9 5.2.0 2023-05-24 15:43:45 +03:00
30c701c09e login with email 2023-05-24 15:43:40 +03:00
10b021c16f 5.1.1 2023-05-24 15:40:15 +03:00
8c78ecaed1 page support for categories, and one css file 2023-05-24 15:40:09 +03:00
27198d3131 5.1.0 2023-05-23 19:54:04 +03:00
14fe5d6dbb New uploader with cropper.
Fuck it taked my 4 hours.
2023-05-23 19:53:56 +03:00
16a2504665 5.0.0 2023-05-09 13:28:55 +03:00
93bb17981f Added themes api way, and bootstrap theme,useredit 2023-05-09 13:28:45 +03:00
bd722f1651 4.22.1 2023-05-08 18:17:53 +03:00
3ac99be051 Theme change available 2023-05-08 18:17:46 +03:00
fa222ad68d 4.22.0 2023-05-08 17:41:23 +03:00
7f655de129 added gravatar support 2023-05-08 17:41:16 +03:00
250b525e5a 4.21.0 2023-05-08 17:16:35 +03:00
c71f18668f 4.20.0 2023-05-08 17:16:23 +03:00
03d84a2564 rollback to v4.x, and mini fixes 2023-05-08 17:16:12 +03:00
da214db047 eslint 2023-03-19 17:14:33 +03:00
24cdd86e34 hmmmmm 2022-10-11 23:24:47 +03:00
791af87906 hmmm 2022-10-10 00:12:21 +03:00
05ac32277f 5.0.0 2022-10-10 00:06:14 +03:00
dc19974506 Packages are updated 2022-10-10 00:06:07 +03:00
388835b85f Enchanted theme support! 2022-10-10 00:04:25 +03:00
4e90431a3d 4.20.0 2022-10-09 22:57:57 +03:00
9304548847 Added edit config to web 2022-10-09 22:57:46 +03:00
06632137c0 4.19.4 2022-10-09 22:25:15 +03:00
47cda470e2 Added usermenu & hide last seen 2022-10-09 22:25:03 +03:00
02364c91ed 4.19.3 2022-10-09 21:33:55 +03:00
0663f968d2 caching 2022-10-09 21:33:47 +03:00
c3b6ad4d5f 4.19.2 2022-10-09 21:23:52 +03:00
410859fbe3 Added state for users. 2022-10-09 21:23:31 +03:00
a471f19f04 message&thread search user profile 2022-10-09 20:58:55 +03:00
1de709c632 Added virtuals for delete 2022-10-09 20:10:19 +03:00
e824b844f6 fix for not login 2022-10-09 19:44:19 +03:00
cc8d64af5d hmm 2022-10-06 22:11:18 +03:00
846ebbe5c9 4.19.1 2022-09-27 21:34:23 +03:00
318e95ec2e Modal.js 2022-09-27 21:34:13 +03:00
abeaddc24b 4.19.0 2022-09-24 01:39:19 +03:00
764dcc93f0 HTTP codes, approval system for user, email 2022-09-24 01:39:06 +03:00
32c2d3d1ca 4.18.0 2022-09-24 00:15:25 +03:00
cf8de73e7c Added unauth w/discord, and better auth 2022-09-24 00:15:17 +03:00
43720f0ca0 Better markdown editor for create_thread 2022-09-23 23:20:00 +03:00
b8b10a71c5 4.17.0 2022-09-23 23:10:22 +03:00
db61361a95 Major update named as minor!
Forum Setup page,
edit forum config w/api
ban user's all ips,
secretmodel deleted,
/undelete is disabled
and better themes
if user reacted a message, view it
fixs for reactions of messages
discord auth in config.json
2022-09-23 23:10:13 +03:00
98d379d82c 4.16.0 2022-09-21 23:54:58 +03:00
6b66974a86 /undelete depr, added thread.state 2022-09-21 23:54:48 +03:00
87b7faa0ff 4.15.0 2022-09-21 23:06:22 +03:00
4d39433fe1 Added old titles, and content for API. 2022-09-21 23:06:14 +03:00
984ac0e621 4.14.1 2022-09-21 22:42:16 +03:00
fd64ac8693 Better auth for API 2022-09-21 22:42:08 +03:00
0d356239e7 4.14.0 2022-09-17 22:22:14 +03:00
df14d08cc3 Sessions are stored now 2022-09-17 22:21:35 +03:00
c32db6dc28 Better 2022-09-17 21:43:04 +03:00
3671ecc863 4.13.2 2022-09-17 21:36:41 +03:00
3010efc00a Added markdown to editors 2022-09-17 21:36:33 +03:00
38042faaf5 4.13.1 2022-09-17 20:25:26 +03:00
87be28c71f Better limits 2022-09-17 20:25:19 +03:00
5a05737f8b Fix for mobility 2022-09-17 20:17:18 +03:00
a1af3f2d4e Better host input 2022-09-17 19:53:00 +03:00
b048d6a685 Added host to config.json 2022-09-17 19:48:27 +03:00
Akif Yüce
e4a93cbf52
Delete config.json 2022-09-17 19:45:31 +03:00
82ec45a786 4.13.0 2022-09-17 19:34:02 +03:00
598e48e25d Added discord auth support 2022-09-17 19:33:51 +03:00
e1aeb8fd14 4.12.6 2022-09-17 16:56:27 +03:00
d46e2362a4 Added admins to admin page 2022-09-17 16:56:19 +03:00
abf5c14475 Better APIDOCS 2022-09-17 16:44:04 +03:00
52386a58e4 4.12.5 2022-09-17 16:37:44 +03:00
67e12c2c26 Added view user ips! 2022-09-17 16:36:48 +03:00
cf02f85eab 4.12.4 2022-09-17 16:27:12 +03:00
e341e8d2e9 Added limits to config.json 2022-09-17 16:27:01 +03:00
b3735ce606 README 2022-09-17 15:51:38 +03:00
e0ce7dc577 4.12.3 2022-09-17 15:19:50 +03:00
49dbe2d73d Better reset 2022-09-17 15:19:41 +03:00
Akif Yüce
3bf0b75a7f
Update README.md 2022-09-17 13:59:31 +03:00
62e7579043 4.12.2 2022-09-17 01:15:53 +03:00
16f72585a0 Added user ips to user page 2022-09-17 01:15:44 +03:00
6c5f36836b Footer position is finally fixed, and ips for user 2022-09-17 01:15:04 +03:00
a6098a1f34 DOCTYPE html 2022-09-17 00:56:18 +03:00
8af04ee580 4.12.1 2022-09-17 00:52:00 +03:00
b7b7de5efd Added footer everywhere 2022-09-17 00:51:52 +03:00
412a7c1a50 Not required 2022-09-17 00:32:15 +03:00
8864d56659 A test script? 2022-09-17 00:30:26 +03:00
817f1c2e13 4.12.0 2022-09-17 00:27:45 +03:00
1cbcd16954 Added char limits 2022-09-17 00:27:38 +03:00
171f83f4cf 4.11.7 2022-09-16 23:51:25 +03:00
ccbc1b8e45 Fix for threads page, black theme 2022-09-16 23:51:16 +03:00
7ecf0a51de Better scroll and better category page 2022-09-16 23:38:06 +03:00
daa5c36281 4.11.6 2022-09-16 23:22:39 +03:00
2064d0c0a6 Better location for theme replacer 2022-09-16 23:22:29 +03:00
109b83e7cd 4.11.5 2022-09-16 23:12:19 +03:00
868068d80f hmm 2022-09-16 23:12:06 +03:00
2db20ce132 User about MD support and LIB, user avatar DEP 2022-09-16 23:11:53 +03:00
3527da03d0 4.11.4 2022-09-16 22:53:03 +03:00
657e18773b Deleted a not required module 2022-09-16 22:52:55 +03:00
144 changed files with 10891 additions and 3606 deletions

View File

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

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

View File

@ -1,31 +0,0 @@
# This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node
# For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions
name: Node.js CI
on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [14.x, 16.x]
# See supported Node.js release schedule at https://nodejs.org/en/about/releases/
steps:
- uses: actions/checkout@v3
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
- run: npm ci
- run: npm run build --if-present
- run: npm test

7
.gitignore vendored
View File

@ -4,9 +4,12 @@ node_modules/
# Environment variables
.env
# Config files
config.json
# Test files
test.js
# avatars folder
public/images/avatars/*
!public/images/avatars/default.jpg
src/public/images/avatars/*
!src/public/images/avatars/default.jpg

View File

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

View File

@ -4,11 +4,29 @@ A Node.js based forum software.
## Installation
- Clone or download this repo.
- Run `npm i` to install **dependencies**.
- Enter your database credentials in `.env`.
- Run `npm start` for run it.
- Go `/setup` page for setup your forum.
### Extra
Run `node util/reset` to **reset the database**, and run `node util/admin` for give admin perms to first member.
Edit `config.json` for default themes of users, and forum name...
### Extra (If you are not use `setup` page)
Run `node util/reset` to **reset the database** for duplicate key errors, and run `node util/admin` for give admin perms to first member.
Edit `config.json` for default theme for users, forum name, meta description, character limits, discord auth enabler, global ratelimit etc.
### How to install theme:
- Copy your theme to `src/themes` folder.
Additional note for themes: If a theme has not got any .ejs file, it will use default theme's .ejs files. default theme is in themes folder, named as `common`.
### DISCORD AUTH:
`"discord_auth": true` in config.json.
Add your app secret and app id to `.env` as `DISCORD_SECRET` and `DISCORD_ID`.
Create a redirect url in discord developer portal:
`https://forum_url.com/auth/discord`
### EMAIL AUTH:
You can configure it. Just edit `config.json` and `.env` files.
`"email_auth": true, "default_user_state": "APPROVAL"` in config.json.
Add your email credentials to `.env` as `EMAIL_USER` and `EMAIL_PASS`.
Add your email domain to `.env` as `EMAIL_SERVICE`.
## API
Akf-forum has got an API for AJAX (fetch), other clients etc. And, you can learn about API in `APIDOCS.md`.
@ -22,28 +40,63 @@ Akf-forum has got an API for AJAX (fetch), other clients etc. And, you can learn
## Screenshot
### Thread Page w/Black Theme
![black-theme](https://user-images.githubusercontent.com/70021050/187899782-2ff010aa-0d39-4fc2-b00c-19bcf1623c8a.png)
### Threads Page w/Default Theme
![light-theme](https://user-images.githubusercontent.com/70021050/186941146-f9a8fbf8-9b2b-4028-afc8-81cff559d9fb.png)
<details>
<summary><b>Mobile view</b></summary>
<img src="https://user-images.githubusercontent.com/70021050/187901065-fd75ef85-56e3-42ce-8b34-cb8d799a6517.png"></img>
</details>
### Thread Page w/Bootstrap theme
![image](https://github.com/Akif9748/akf-forum/assets/70021050/1ad4ad8e-d000-46a6-834e-7d76cdddda60)
## TO-DO list
| To do | Is done? | Priority |
| ----- | -------- | -------- |
| Profile Message | 🟡 | LOW |
| IPs of users will add UserModel with select- | ⚪ | MEDIUM |
| Better Auth | ⚪ | MEDIUM |
- mod role, permissions
- Fix footer, theme, category pages
- upload other photos, model for it
- category system bloat.
- replace not found errors with no perm
- prewiev for send messages in markdown format. Markdown in user about, deprecate clean content
### Backend:
#### Feature:
- Profile Message or DM
- Upload other photos, model for it
- Edit & download template
- Banner
- Add @me support for ids, <%= member.id %>
- Roles & Permissions
```
role: "moderator",
permissions: ["see_deleted_message"]
```
#### Fixes:
- Admin deleting other admins.
- theme.js, change theme js code to a file.
- same email discord???? direct err
- IMPORTANT: add user/member id to file so scripts can access
#### ETC:
- Rewrite apidocs
- Add a feature list to README.md
### Frontend
#### Features:
- change category name
- Add approval threads page.
- add support for switch around gravatar and upload photo
- old contents / titles add to forum interface
- who liked a message
#### Fixes:
- BETTER SETUP PAGE: use setup everytime
- add threads, messages etc. to "extra" folder
- add category to thread list page
- working reset button
- text alling center body
- thread.js unfuction only listener
- show error on modal
- send delete, ban to user settings (edit user) menu and fix edit user menu
## Special Thanks:
https://github.com/akashgiricse/Online-Forum for bootstrap theme.
@Tokmak for old frontend.
https://fengyuanchen.github.io/cropperjs/examples/crop-a-round-image.html for avatar upload panel.
https://github.com/mdbootstrap/bootstrap-profiles for profile page of bootstrap theme.
## Major Version History
- V5: Enchanted Themes
- V4: Caching
- V3: New Theme
- V2: Backend fix, mongoose is fixed. Really big fix.

View File

@ -1,5 +0,0 @@
{
"def_theme": "default",
"forum_name": "akf",
"description": "Akf-forum!"
}

24
config.json.example Normal file
View File

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

View File

@ -1,52 +0,0 @@
const { urlencoded: BP } = require('body-parser'),
{ mw: IP } = require('request-ip'),
RL = require('express-rate-limit'),
BAN = require('express-ip-block'),
SES = require('express-session');
const
{ def_theme, forum_name, description } = require("./config.json"),
{ UserModel, BanModel } = require("./models"),
port = process.env.PORT || 3000,
mongoose = require("mongoose"),
express = require('express'),
fs = require("fs"),
app = express();
app.ips = [];
require("dotenv").config();
mongoose.connect(process.env.MONGO_DB_URL,
async () => console.log("Database is connected with", (app.ips = await BanModel.find({})).length, "banned IPs"));
app.set("view engine", "ejs");
app.use(express.static("public"), express.json(), IP(), BAN(app.ips),
SES({ secret: 'secret', resave: true, saveUninitialized: true }),
async (req, res, next) => {
req.user = req.session.userID ? await UserModel.findOneAndUpdate({ id: req.session.userID }, {
lastSeen: Date.now(), $addToSet: { ips: req.clientIp }
}) : null;
res.reply = (page, options = {}, status = 200) => res.status(status)
.render(page, { user: req.user, theme: req.user?.theme || def_theme, forum_name, description, ...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();
}, RL({
windowMs: 60_000, max: 20,
handler: (req, res, next, opts) => !req.user?.admin ? res.error(opts.statusCode, "You are begin ratelimited") : next()
}), BP({ extended: true })
);
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(`${forum_name}-forum on port:`, port));

View File

@ -1,11 +0,0 @@
module.exports = {
URLRegex: /https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)/g,
clearContent: (content) => {
if (!content) return "";
return content.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;").replaceAll(">", "&gt;")
.replaceAll("\"", "&quot;").replaceAll("'", "&#39;")
.replaceAll("\n", "<br>");
}
}

View File

@ -1,36 +0,0 @@
const mongoose = require("mongoose")
const cache = require("./cache")
const schema = new mongoose.Schema({
id: { type: String, unique: true },
author: Object,
threadID: String,
authorID: String,
content: String,
time: { type: Date, default: Date.now },
deleted: { type: Boolean, default: false },
edited: { type: Boolean, default: false },
react: {
like: [Number],
dislike: [Number]
}
})
schema.methods.get_author = cache.getAuthor
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 = async id => {
const message = await model.findOne({ id })
return await message.get_author();
};
module.exports = model;

View File

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

View File

@ -1,53 +0,0 @@
const mongoose = require("mongoose");
const cache = require("./cache")
const MessageModel = require("./Message");
const schema = new mongoose.Schema({
id: { type: String, unique: true },
categoryID: String,
authorID: String,
author: Object,
title: String,
time: { type: Date, default: Date.now },
deleted: { type: Boolean, default: false },
edited: { type: Boolean, default: false },
messages: [String],
views: { type: Number, default: 0 }
});
schema.methods.get_author = cache.getAuthor;
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.count(query) || 0;
};
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 = async id => {
const thread = await model.findOne({ id })
return await thread.get_author();
};
module.exports = model;

View File

@ -1,32 +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/avatars/default.jpg" },
time: { type: Date, default: Date.now },
deleted: { type: Boolean, default: false },
edited: { type: Boolean, default: false },
about: { type: String, default: "" },
admin: { type: Boolean, default: false },
theme: { type: String, default: def_theme },
lastSeen: { type: Date, default: Date.now, select: false },
hideLastSeen: { type: Boolean, default: false },
ips: { type: [String], select: false }
});
schema.methods.takeId = async function () {
this.id = String(await model.count() || 0);
return this;
}
schema.methods.getLink = function (id = this.id) {
return "/users/" + id;
}
const model = mongoose.model('user', schema);
model.get = (id, select) => model.findOne({ id }).select(select);
module.exports = model;

View File

@ -1,13 +0,0 @@
const UserModel = require("./User");
const UserCache = [];
module.exports.getAuthor = async function () {
const id = this.authorID || this.author?.id;
let user = UserCache.find(user => user?.id == id)
if (!user) {
user = await UserModel.findOne({ id })
UserCache.push(user)
}
this.author = user;
return this;
}

View File

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

4262
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -1,212 +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;
font-family: Poppins;
}
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: var(--btn-clr-1);
background-color: var(--main);
padding: 10px 20px 10px 20px;
border-radius: 4px;
font-weight: 700;
margin: 10px;
cursor: pointer;
border: 2px solid var(--main);
}
.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(--main);
padding: 10px 20px 10px 20px;
border-radius: 4px;
font-weight: 700;
margin: 10px;
cursor: pointer;
border: 2px solid var(--borders);
}
.btn-primary:hover {
color: var(--main);
background-color: rgba(0, 0, 0, 0);
border: 2px solid var(--main);
}
.btn-outline-primary:hover {
border: 2px solid var(--main);
}
.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);
}
@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;
}
}
/*
FOOTER
*/
select {
background: #ebebeb;
border: white;
padding: 8px 20px;
font-size: 16px;
font-family: inherit;
}
select option {
font-family: inherit;
width: 290px;
}
.footer {
width: 100%;
margin-top: 10px;
background-color: var(--main);
padding: 10px;
color: white;
display: flex;
justify-content: space-between;
align-items: center;
}

View File

@ -1,23 +0,0 @@
.title {
color: var(--main);
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: var(--input-clr);
width: 100%;
margin-bottom: 10px;
border: 2px solid var(--borders);
}

View File

@ -1,26 +0,0 @@
.title {
color: var(--main);
font-weight: 700;
}
form {
display: flex;
align-items: center;
flex-direction: column;
max-width: 500px;
margin: 0 auto;
width: 100%;
padding: 8px;
}
.input {
padding: 8px 10px;
font-family: inherit;
display: block;
font-weight: 600;
color: var(--input-clr);
width: 100%;
margin-bottom: 10px;
border: 2px solid var(--borders);
}

View File

@ -1,34 +0,0 @@
.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);
}
.content {
width: 70%;
color: var(--anti);
}

View File

@ -1,75 +0,0 @@
form {
display: flex;
align-items: center;
flex-direction: column;
max-width: 500px;
margin: 0 auto;
width: 100%;
padding: 8px;
}
.input {
padding: 8px 10px;
font-family: inherit;
display: block;
font-weight: 600;
color: var(--input-clr);
width: 100%;
margin-bottom: 10px;
border: 2px solid var(--borders);
}
/* MODAL */
.modal {
position: fixed;
left: 0;
top: 0;
width: 100%;
height: 100%;
background: #5b5b5bde;
}
.modal-content {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: white;
padding: 10px;
opacity: 0;
display: block;
animation: fadeIn 1s forwards;
}
.modal.no-active {
display: none;
}
.modal-close {
position: absolute;
top: 0;
right: 10px;
font-size: 36px;
color: var(--main);
cursor: pointer;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@media(max-width:760px) {
.modal-content {
width: 95%;
}
}

View File

@ -1,41 +0,0 @@
.pagination {
box-shadow: 0 0 5px 0 var(--box-shadow);
margin: 10px auto;
padding: 8px;
display: flex;
justify-content: space-between;
align-items: center;
max-width: 400px;
gap: 10px;
position: relative;
}
.pagination .back,
.pagination .after {
color: var(--second);
font-size: 26px;
cursor: pointer;
}
.pagination .numbers {
display: flex;
align-items: center;
gap: 5px;
}
.pagination .number {
color: var(--second);
font-size: 22px;
border: 0 0 5px var(--second);
padding: 8px;
border-radius: 2px;
font-weight: 600;
cursor: pointer;
margin: 8px;
}
.pagination .number.active {
color: var(--main);
font-weight: 700;
}

View File

@ -1,27 +0,0 @@
.title {
color: var(--main);
font-weight: 700;
}
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;
}
.input {
padding: 8px 10px;
font-family: inherit;
display: block;
font-weight: 600;
color: var(--input-clr);
width: 100%;
margin-bottom: 10px;
border: 2px solid var(--borders);
}

View File

@ -1,13 +0,0 @@
:root {
--main: #ac8fff;
--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;
}

View File

@ -1,13 +0,0 @@
:root {
--main: #4d18e6;
--btn-clr-1: #e8e8e8;
--menu-item: #ffffff;
--borders: #d9d9d9;
--input-clr: #414141;
--box-shadow: #c3c3c3;
--second: #747474;
--anti: #151515;
--t-username: #555;
--important: red;
background-color: #ffffff;
}

View File

@ -1,181 +0,0 @@
.title {
color: var(--input-clr);
font-weight: 700;
font-size: 36px;
}
.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);
}
.content {
width: 70%;
color: var(--anti);
}
.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(--btn-clr-1);
width: 100px;
text-align: center;
display: none;
}
.dots-menu.active {
display: block;
}
.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);
}
.send>textarea{
font-family:inherit;
width: 100%;
margin: 10px;
border: 2px solid #e3e3e3;
}
/* Media Query */
@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;
}
.dots {
position: absolute;
right: auto;
top: 70px;
left: 40px;
}
.dots-menu {
position: absolute;
left: 70px;
top: 60px;
width: 120px;
}
.dots-menu.active {
display: flex;
}
.content {
width: 50%;
}
}

View File

@ -1,38 +0,0 @@
.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;
}
}

View File

@ -1,39 +0,0 @@
.title {
color: var(--main);
font-weight: 700;
}
.content {
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);
}
.box {
display: flex;
align-items: center;
justify-content: space-between;
margin: 0 auto;
max-width: 800px;
width: 100%;
}
.box-title{
font-weight: 400;
}
.box-value {
font-weight: 300;
background-color: var(--main);
color: white;
font-size: 14px;
padding: 4px;
border-radius: 5px;
min-width: 50px;
text-align: center;
}

View File

@ -1,47 +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 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;
}
}

View File

@ -1,17 +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 });
res.reply("index", { mem, users, threads, messages })
})
module.exports = app;

View File

@ -1,9 +0,0 @@
const { Router } = require("express")
const { BanModel } = require("../models");
const app = Router();
app.get("/", async (req, res) => {
if (!req.user?.admin) return res.error(403, "You have not got permissions for view to this page.");
res.reply("admin",{bans: await BanModel.find({})});
});
module.exports = app;

View File

@ -1,43 +0,0 @@
const { Router, request, response } = require("express");
const app = Router();
const fs = require("fs");
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 don't have any thread with name ${username}.`)
if (!await bcrypt.compare(password, user.password)) return res.error(401, 'Incorrect Password!');
req.user = await UserModel.findOne({ name: req.headers.username });
next();
});
app.get("/me", (req, res) => res.complate(req.user))
for (const file of fs.readdirSync("./routes/api/routes"))
app.use("/" + file.replace(".js", ""), require(`./routes/${file}`));
app.all("*", (req, res) => res.error(400, "Bad request"));
module.exports = app;

View File

@ -1,29 +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)
return res.error(400, "You forgot entering some values")
const user = await SecretModel.findOne({ username });
if (!user) return res.error(403, 'Incorrect Username and/or Password!');
if (!await 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 || '/');
});
module.exports = app;

View File

@ -1,45 +0,0 @@
const { UserModel, SecretModel } = require("../models");
const { Router } = require("express")
const bcrypt = require("bcrypt");
const rateLimit = require('express-rate-limit')
const {URLRegex} = require("../lib")
const app = Router();
app.get("/", (req, res) => res.reply("register", { user: null }));
app.post("/", rateLimit({
windowMs: 24 * 60 * 60_000, max: 5, 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, about } = 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 && URLRegex.test(avatar)) user2.avatar = avatar;
if (about) user2.about = about;
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;

92
src/index.js Normal file
View File

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

34
src/lib.js Normal file
View File

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

View File

@ -6,4 +6,5 @@ const schema = new mongoose.Schema({
authorID: { type: String }
});
module.exports = mongoose.model('ban', schema);
const model = mongoose.model('ban', schema);
module.exports = model;

View File

@ -7,13 +7,15 @@ const schema = new mongoose.Schema({
authorID: { type: String }
});
schema.methods.takeId = async function () {
this.id = String(await model.count() || 0);
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/" + this.id;
return "/categories/" + id;
}
const model = mongoose.model('category', schema);

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

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

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

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

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

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

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

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

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

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

View File

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

View File

Before

Width:  |  Height:  |  Size: 5.3 KiB

After

Width:  |  Height:  |  Size: 5.3 KiB

View File

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

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

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

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

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

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

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

View File

@ -1,5 +1,6 @@
import request from "./request.js";
let editing;
// THREAD:
window.edit_thread = async function (id) {
@ -18,7 +19,7 @@ window.delete_thread = async function (id) {
}
window.undelete_thread = async function (id) {
const res = await request(`/api/threads/${id}/undelete`);
const res = await request(`/api/threads/${id}/`, "PATCH", { state: "OPEN" });
if (res.error) return;
alert(`Thread undeleted`);
location.reload();
@ -28,7 +29,7 @@ window.undelete_thread = async function (id) {
// MESSAGES:
window.send_edit = async function (id) {
const message = document.getElementById(`message-${id}`);
const content = message.querySelector("#content").value;
const content = editing.value();
const res = await request(`/api/messages/${id}/`, "PATCH", { content });
if (res.error) return;
@ -36,15 +37,23 @@ window.send_edit = async function (id) {
message.querySelector(".content").innerHTML = converter.makeHtml(res.content);
}
window.edit_message = async function (id) {
const content = document.getElementById(`message-${id}`).querySelector(".content");
const message = document.getElementById(`message-${id}`)
const content = message.querySelector(".content");
content.innerHTML = `
<textarea rows="4" cols="40" id="content">${content.rawText}</textarea>
<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}/undelete`);
const response = await request(`/api/messages/${id}/`, "PATCH", { deleted: false });
if (response.deleted) return;
const message = document.getElementById("message-" + id);

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

31
src/routes/.js Normal file
View File

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

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

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

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

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

View File

@ -19,10 +19,7 @@ app.get("/", async (req, res) => {
res.complate(categories);
});
app.get("/:id", async (req, res) => {
const { category } = req;
res.complate(category);
});
app.get("/:id", async (req, res) => res.complate(req.category));
app.patch("/:id", async (req, res) => {
const { category } = req;
@ -31,9 +28,7 @@ app.patch("/:id", async (req, res) => {
res.complate(await category.save());
});
app.delete("/:id", async (req, res) => {
res.complate(await CategoryModel.deleteOne({ id: req.params.id }));
});
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;

View File

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

View File

@ -1,6 +1,5 @@
const { MessageModel, ThreadModel } = require("../../../models");
const rateLimit = require('express-rate-limit')
const { RL } = require('../../../lib');
const { Router } = require("express")
const app = Router();
@ -19,15 +18,40 @@ app.param("id", async (req, res, next, id) => {
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.");
const { content = null } = req.body;
if (!content) return res.error(400, "Missing message content in request body.");
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();
@ -35,14 +59,13 @@ app.patch("/:id/", async (req, res) => {
})
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) => {
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);
@ -87,33 +110,4 @@ app.post("/:id/react/:type", async (req, res) => {
});
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(403, "This message is already deleted.");
message.deleted = true;
await message.save();
res.complate(message);
})
app.post("/:id/undelete", async (req, res) => {
const { message } = req;
if (!message.deleted) return res.error(404, "This message is not deleted, first, delete it.");
message.deleted = false;
await message.save();
res.complate(message);
})
module.exports = app;

View File

@ -28,7 +28,9 @@ app.get("/messages", async (req, res) => {
});
app.get("/threads", async (req, res) => {
if (!Object.values(req.query).length) return res.error(400, "Missing query parameters in request body.");
const query = { ...req.sq };
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)

View File

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

View File

@ -1,5 +1,6 @@
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) => {
@ -7,7 +8,7 @@ app.param("id", async (req, res, next, id) => {
if (!req.thread) return res.error(404, `We don't have any thread with id ${id}.`);
if (req.thread.deleted && !req.user?.admin)
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();
@ -36,12 +37,15 @@ app.get("/:id/messages/", async (req, res) => {
})
app.post("/", async (req, res) => {
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)
@ -53,14 +57,37 @@ app.post("/", async (req, res) => {
res.complate(thread);
});
app.patch("/:id/", async (req, res) => {
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.");
const { title } = req.body;
if (!title) return res.error(400, "Missing thread title in request body.");
thread.title = title;
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);
@ -72,27 +99,13 @@ app.delete("/:id/", async (req, res) => {
if (user.id != thread.authorID && !user.admin)
return res.error(403, "You have not got permission for this.");
if (thread.deleted) return res.error(403, "This thread is already deleted.");
thread.deleted = true;
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);
})
app.post("/:id/undelete", async (req, res) => {
const { thread } = req;
if (!thread.deleted) return res.error(404, "This thread is not deleted, first, delete it.");
thread.deleted = false;
thread.edited = true;
await thread.save();
await MessageModel.updateMany({ threadID: thread.id }, { deleted: false });
res.complate(thread);
})
module.exports = app;

View File

@ -1,15 +1,17 @@
const { UserModel, SecretModel } = require("../../../models");
const { UserModel, BanModel } = require("../../../models");
const { Router } = require("express");
const { URLRegex } = require("../../../lib");
const multer = require("multer");
const { themes, emailRegEx } = require("../../../lib")
const app = Router();
const { join } = require("path");
app.param("id", async (req, res, next, id) => {
req.member = await UserModel.get(id, req.user.admin ? "+lastSeen": "");
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}.`);
@ -18,69 +20,76 @@ app.param("id", async (req, res, next, id) => {
app.get("/:id", async (req, res) => res.complate(req.member));
app.delete("/:id/", async (req, res) => {
app.delete("/:id", async (req, res) => {
const { user, member } = req;
if (!user.admin)
return res.error(403, "You have not got permission for this.");
const { id = null } = req.params;
if (member.deleted) return res.error(404, `This user is with id ${id} already deleted.`);
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.post("/:id/undelete/", async (req, res) => {
if (!req.user.admin) return res.error(403, "You have not got permission for this.");
const { user, member } = req;
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;
;
res.complate(await member.save());
})
app.patch("/:id/", async (req, res) => {
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.values(req.body).some(Boolean)) return res.error(400, "Missing member informations in request body.");
if (!Object.keys(req.body).some(Boolean)) return res.error(400, "Missing member informations in request body.");
const { avatar, name, about, theme, admin, deleted } = req.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.");
if (avatar && URLRegex.test(avatar))
member.avatar = avatar;
const { names, desp } = req.app.get("limits");
if (name) {
await SecretModel.updateOne({ id: member.id }, { username: name });
if (name.length < 3 || names > 25) return res.error(400, `Username must be between 3 - ${names} characters`);
member.name = name;
}
if (about) member.about = about;
if (theme || ["default", "black"].includes(theme)) member.theme = theme;
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: function (_req, _file, cb) {
cb(null, './public/images/avatars')
},
destination: join(__dirname, "..", "..", "..", "public", "images", "avatars"),
filename: function (req, _file, cb) {
cb(null, req.member.id + ".jpg")
}
@ -88,13 +97,14 @@ const storage = multer.diskStorage({
const upload = multer({ storage })
app.put("/:id/", upload.single('avatar'), async (req, res) => {
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 = req.file.destination.slice("./public".length) + "/" + req.file.filename;
member.avatar = "/images/avatars/" + req.file.filename;
res.complate(await member.save());
});

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

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

View File

@ -1,25 +1,30 @@
const { CategoryModel,ThreadModel } = require("../models");
const { CategoryModel, ThreadModel } = require("../models");
const { Router } = require("express");
const app = Router();
app.get("/", async (req, res) => {
const categories = await CategoryModel.find({});
res.reply("categories", { categories });
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("/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;
if (!req.user?.admin) query.deleted = false;
let threads = await ThreadModel.find(query).limit(10).skip(page * 10);
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.count(query) / 10) });
res.reply("threads", { threads, page, title: `Threads in ${category.name}`, desp: category.desp, pages: Math.ceil(await ThreadModel.countDocuments(query) / 10) });
});
module.exports = app;

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

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

View File

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

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

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

View File

@ -19,7 +19,7 @@ app.get("/users", async (req, res) => {
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.count(req.sq) / 10)
pages: Math.ceil(await UserModel.countDocuments(req.sq) / 10)
});
});
@ -31,19 +31,21 @@ app.get("/messages", async (req, res) => {
const messages = await MessageModel.find(query, null, req.so)
res.reply("messages", {
messages, page: req.page,
pages: Math.ceil(await MessageModel.count(query) / 10)
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 = { ...req.sq };
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.count(query) / 10)
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`
});
});

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

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

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

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

View File

@ -1,15 +1,14 @@
const { Router } = require("express");
const app = Router();
const { clearContent } = require("../lib");
const { ThreadModel, MessageModel, CategoryModel } = require("../models")
app.get("/", async (req, res) => {
const page = Number(req.query.page) || 0;
const query = req.user?.admin ? {} : { deleted: false };
let threads = await ThreadModel.find(query).limit(10).skip(page * 10);
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 + " thread listed", pages: Math.ceil(await ThreadModel.count(query) / 10) });
return res.reply("threads", { threads, page, title: "Threads", desp: threads.length + " threads are listed", pages: Math.ceil(await ThreadModel.countDocuments(query) / 10) });
});
@ -22,7 +21,7 @@ app.get("/:id/", async (req, res) => {
const page = Number(req.query.page || 0);
const thread = await ThreadModel.get(id)
if (thread && (user?.admin || !thread.deleted)) {
if (thread && (user?.admin || thread.state == "OPEN")) {
thread.count = await thread.messageCount(user?.admin);
thread.pages = Math.ceil(thread.count / 10);
thread.views++;
@ -32,7 +31,7 @@ app.get("/:id/", async (req, res) => {
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, scroll: req.query.scroll || messages[0]?.id });
res.reply("thread", { page, thread, messages });
thread.save();

View File

@ -1,14 +1,12 @@
const { Router } = require("express");
const app = Router();
const { clearContent } = require("../lib");
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.count(query) / 10) });
return res.reply("users", { users, page, pages: Math.ceil(await UserModel.countDocuments(query) / 10) });
});
app.get("/:id/avatar", async (req, res) => {
@ -20,20 +18,33 @@ app.get("/:id/avatar", async (req, res) => {
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");
const member = await UserModel.get(id, "+lastSeen +ips");
if (member && (user?.admin || !member.deleted)) {
const message = await MessageModel.count({ authorID: id });
const thread = await ThreadModel.count({ authorID: id });
member.about = clearContent(member.about)
res.reply("user", { member, counts: { message, thread } })
const message = await MessageModel.countDocuments({ authorID: id });
const thread = await ThreadModel.countDocuments({ authorID: id });
res.reply("user", { member, counts: { message, thread }, discord: req.app.get("DISCORD_AUTH_URL") })
}
else res.error(404, `We don't have any user with id ${id}.`);
});
app.get("/:id/edit", async (req, res) => {
const user = req.user
const { id } = req.params;
const member = await UserModel.get(id);
if (!member) return res.error(404, `We don't have any user with id ${id}.`);
if (user?.admin || user.id === member.id)
return res.reply("edit_user", { member });
res.error(403, "You have not got permission for this.");
});
module.exports = app;

View File

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,12 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<%- include("extra/meta", {title:"Message search!" }) %>
<%- include(dataset.getFile(dataset.theme.codename +"/views/extra/meta"), {title:"Message search!" }) %>
<body style="text-align: center;">
<%- include("extra/navbar") %>
<link rel="stylesheet" href="/css/messages.css" />
<%- include(dataset.getFile(dataset.theme.codename +"/views/extra/navbar")) %>
<div id="messages">
@ -28,6 +28,8 @@
<% }); %>
</div>
<%- include(dataset.getFile(dataset.theme.codename +"/views/extra/footer")) %>
</body>
</html>

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