Compare commits

..

No commits in common. "main" and "v4.8.0" have entirely different histories.
main ... v4.8.0

147 changed files with 3677 additions and 13277 deletions

View file

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

View file

@ -1,27 +0,0 @@
{
"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/*"
]
}
]
}

31
.github/workflows/node.js.yml vendored Normal file
View file

@ -0,0 +1,31 @@
# 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,12 +4,9 @@ node_modules/
# Environment variables
.env
# Config files
config.json
# Test files
test.js
# avatars folder
src/public/images/avatars/*
!src/public/images/avatars/default.jpg
public/images/avatars/*
!public/images/avatars/default.jpg

View file

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

101
README.md
View file

@ -4,35 +4,15 @@ 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 (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`.
### 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...
## API
Akf-forum has got an API for AJAX (fetch), other clients etc. And, you can learn about API in `APIDOCS.md`.
**And you can use [offical API wrapper](https://github.com/Akif9748/akf-forum-api).**
## Credits
* [Akif9748](https://github.com/Akif9748) - Project mainteiner, main developer, made **old** frontend
* [Tokmak](https://github.com/tokmak0) - Made **new** frontend
@ -40,63 +20,32 @@ Akf-forum has got an API for AJAX (fetch), other clients etc. And, you can learn
## Screenshot
### Thread Page w/Bootstrap theme
![image](https://github.com/Akif9748/akf-forum/assets/70021050/1ad4ad8e-d000-46a6-834e-7d76cdddda60)
### 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>
## TO-DO list
### Backend:
#### Feature:
- Profile Message or DM
- Upload other photos, model for it
- Edit & download template
- Banner
- Add @me support for ids, <%= member.id %>
- Roles & Permissions
```
role: "moderator",
permissions: ["see_deleted_message"]
```
#### Fixes:
- Admin deleting other admins.
- theme.js, change theme js code to a file.
- same email discord???? direct err
- IMPORTANT: add user/member id to file so scripts can access
#### ETC:
- Rewrite apidocs
- Add a feature list to README.md
### 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.
| To do | Is done? | Priority |
| ----- | -------- | -------- |
| Local pfp store | 🟢 | MEDIUM |
| Page support for search | 🟡 | LOW |
| IPs of users will add SecretModel | 🔴 | MEDIUM |
| Category | ⚪ | MEDIUM |
| Profile Message | 🔴 | LOW |
| Last seen, last seen info | 🔴 | LOW |
| Better Auth | 🔴 | MEDIUM |
- Fix footer, theme
- Navbar manuel select
- Version info to footer
- upload other photos, model for it
- black theme is broken
## Major Version History
- V5: Enchanted Themes
- V4: Caching
- V3: New Theme
- V2: Backend fix, mongoose is fixed. Really big fix.

5
config.json Normal file
View file

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

View file

@ -1,24 +0,0 @@
{
"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"
}

48
index.js Normal file
View file

@ -0,0 +1,48 @@
const { def_theme, forum_name, desp } = require("./config.json"),
{ UserModel, BanModel } = require("./models"),
rateLimit = require('express-rate-limit'),
ipBlock = require('express-ip-block'),
session = require('express-session'),
bodyParser = require('body-parser'),
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(
session({ secret: 'secret', resave: true, saveUninitialized: true }),
express.static("public"), express.json(), ipBlock(app.ips),
async (req, res, next) => {
req.headers["x-forwarded-for"];
req.user = await UserModel.get(req.session.userID);
res.reply = (page, options = {}, status = 200) => res.status(status)
.render(page, { user: req.user, theme: req.user?.theme || def_theme, forum_name, desp, ...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();
}, rateLimit({
windowMs: 60_000, max: 20,
handler: (req, res, next, opts) => !req.user?.admin ? res.error(opts.statusCode, "You are begin ratelimited") : next()
}), bodyParser.urlencoded({ 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));

11
lib/index.js Normal file
View file

@ -0,0 +1,11 @@
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

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

View file

@ -7,17 +7,10 @@ const schema = new mongoose.Schema({
authorID: { type: String }
});
schema.methods.takeId = async function () {
// eslint-disable-next-line no-use-before-define
this.id = await model.countDocuments() || 0;
this.id = String(await model.count() || 0);
return this;
}
schema.methods.getLink = function (id = this.id) {
return "/categories/" + id;
}
const model = mongoose.model('category', schema);
module.exports = model;

36
models/Message.js Normal file
View file

@ -0,0 +1,36 @@
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;

9
models/Secret.js Normal file
View file

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

53
models/Thread.js Normal file
View file

@ -0,0 +1,53 @@
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;

30
models/User.js Normal file
View file

@ -0,0 +1,30 @@
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 }
});
schema.methods.takeId = async function () {
this.id = String(await model.count() || 0);
return this;
}
schema.methods.getLink = function (id = this.id) {
return "/users/" + id;
}
const model = mongoose.model('user', schema);
model.get = id => model.findOne({ id });
module.exports = model;

13
models/cache.js Normal file
View file

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

8
models/index.js Normal file
View file

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

4251
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,13 +1,10 @@
{
"name": "akf-forum",
"version": "5.7.1",
"version": "4.8.0",
"description": "A Node.js based forum software",
"main": "src/index.js",
"main": "index.js",
"scripts": {
"start": "node .",
"reset": "node util/reset",
"admin": "node util/admin",
"test": "echo \"Error: no test specified\" && exit 1"
"start": "node ."
},
"repository": {
"type": "git",
@ -23,23 +20,19 @@
"url": "https://github.com/Akif9748/akf-forum/issues"
},
"engines": {
"node": ">=18 >=17.5 >=16.15"
"node": ">=16"
},
"homepage": "https://akf-forum.glitch.me/",
"dependencies": {
"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"
"bcrypt": "^5.0.1",
"body-parser": "^1.19.2",
"dotenv": "^16.0.1",
"ejs": "^3.1.6",
"express": "^4.17.3",
"express-ip-block": "^0.1.2",
"express-rate-limit": "^6.5.1",
"express-session": "^1.17.2",
"mongoose": "^6.5.1",
"multer": "^1.4.5-lts.1"
}
}

2114
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

212
public/css/common.css Normal file
View file

@ -0,0 +1,212 @@
@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

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

26
public/css/login.css Normal file
View file

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

34
public/css/messages.css Normal file
View file

@ -0,0 +1,34 @@
.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(--reaction-hover);
}

75
public/css/modal.css Normal file
View file

@ -0,0 +1,75 @@
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%;
}
}

41
public/css/pages.css Normal file
View file

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

27
public/css/search.css Normal file
View file

@ -0,0 +1,27 @@
.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

@ -0,0 +1,13 @@
:root {
--main: #ac8fff;
--btn-clr-1: #e8e8e8;
--menu-item: #ffffff;
--borders: #d9d9d9;
--input-clr: #dcdcdc;
--box-shadow: #c3c3c3;
--second: #9f9f9f;
--reaction-hover: #ebebeb;
--t-username: rgb(236 236 236);
background-color: #000000;
}

View file

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

181
public/css/thread.css Normal file
View file

@ -0,0 +1,181 @@
.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(--reaction-hover);
}
.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(--reaction-hover);
}
.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%;
}
}

38
public/css/threads.css Normal file
View file

@ -0,0 +1,38 @@
.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(--reaction-hover);
}
.thread-box-title>span {
color: var(--important);
}
@media (max-width: 480px) {
.threads-box {
flex-direction: column;
}
}

38
public/css/user.css Normal file
View file

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

47
public/css/users.css Normal file
View file

@ -0,0 +1,47 @@
.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(--reaction-hover);
}
.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

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 4.2 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

View file

@ -1,6 +1,5 @@
import request from "./request.js";
let editing;
// THREAD:
window.edit_thread = async function (id) {
@ -19,7 +18,7 @@ window.delete_thread = async function (id) {
}
window.undelete_thread = async function (id) {
const res = await request(`/api/threads/${id}/`, "PATCH", { state: "OPEN" });
const res = await request(`/api/threads/${id}/undelete`);
if (res.error) return;
alert(`Thread undeleted`);
location.reload();
@ -29,31 +28,23 @@ window.undelete_thread = async function (id) {
// MESSAGES:
window.send_edit = async function (id) {
const message = document.getElementById(`message-${id}`);
const content = editing.value();
const content = message.querySelector("#content").value;
const res = await request(`/api/messages/${id}/`, "PATCH", { content });
if (res.error) return;
alert(`Message updated`);
message.querySelector(".content").innerHTML = converter.makeHtml(res.content);
message.querySelector(".content").innerHTML = res.content;
}
window.edit_message = async function (id) {
const message = document.getElementById(`message-${id}`)
const content = message.querySelector(".content");
const content = document.getElementById(`message-${id}`).querySelector(".content");
content.innerHTML = `
<textarea rows="4" cols="40" id="content"></textarea>
<textarea rows="4" cols="40" id="content">${content.innerHTML}</textarea>
<button onclick="send_edit(${id});" class="btn-primary">Edit!</button>`;
const cnt = message.querySelector("#content");
editing = new SimpleMDE({
element: cnt,
spellChecker: false,
height: "200px"
});
editing.value(content.rawText)
}
window.undelete_message = async function (id) {
const response = await request(`/api/messages/${id}/`, "PATCH", { deleted: false });
const response = await request(`/api/messages/${id}/undelete`);
if (response.deleted) return;
const message = document.getElementById("message-" + id);

17
routes/.js Normal file
View file

@ -0,0 +1,17 @@
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;

9
routes/admin.js Normal file
View file

@ -0,0 +1,9 @@
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;

41
routes/api/index.js Normal file
View file

@ -0,0 +1,41 @@
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();
});
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

@ -22,10 +22,7 @@ app.get("/:ip", async (req, res) => {
app.post("/:ip", async (req, res) => {
if (await BanModel.exists({ ip: req.params.ip })) return res.error(400, "This ip is already banned.");
const ban = await BanModel.create({ ip: req.params.ip, reason: req.query.reason || "No reason given", authorID: req.user.id });
req.app.ips.push(req.params.ip);
res.complate(ban);
res.complate(await BanModel.create({ ip: req.params.ip, reason: req.query.reason || "No reason given", authorID: req.user.id }));
});

View file

@ -19,7 +19,10 @@ app.get("/", async (req, res) => {
res.complate(categories);
});
app.get("/:id", async (req, res) => res.complate(req.category));
app.get("/:id", async (req, res) => {
const {category} = req;
res.complate(category);
});
app.patch("/:id", async (req, res) => {
const {category} = req;
@ -28,15 +31,17 @@ 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;
if (!name) return res.error(400, "You have to give a name for the category.");
if (await CategoryModel.exists({ name })) return res.error(400, "This category is already opened.");
const category = await new CategoryModel({ name, desp, authorID: req.user.id }).takeId();
res.complate(await category.save());
res.complate(await CategoryModel.create({ name, desp, authorID: req.user.id }).then(c => c.takeId()));
});

View file

@ -1,5 +1,6 @@
const { MessageModel, ThreadModel } = require("../../../models");
const { RL } = require('../../../lib');
const rateLimit = require('express-rate-limit')
const { Router } = require("express")
const app = Router();
@ -18,40 +19,15 @@ 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.");
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;
const { content = null } = req.body;
if (!content) return res.error(400, "Missing message content in request body.");
message.content = content;
if (!message.oldContents.includes(content))
message.oldContents.push(content);
message.edited = true;
await message.save();
@ -59,13 +35,14 @@ app.patch("/:id/", async (req, res) => {
})
app.post("/", RL(), 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) => {
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);
@ -110,4 +87,33 @@ 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

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

View file

@ -1,6 +1,5 @@
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) => {
@ -8,7 +7,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.state !== "OPEN" && !req.user?.admin)
if (req.thread.deleted && !req.user?.admin)
return res.error(404, `You do not have permissions to view this thread with id ${id}.`)
next();
@ -37,19 +36,14 @@ app.get("/:id/messages/", async (req, res) => {
})
app.post("/", RL(5 * 60_000, 1), async (req, res) => {
app.post("/", async (req, res) => {
const { title, content, category } = req.body;
const { title, content } = req.body;
if (!content || !title) return res.error(400, "Missing content/title in request body.");
const limits = req.app.get("limits");
if (title.length < 5 || title.length > limits.title) return res.error(400, `title must be between 5 - ${limits.title} characters`);
if (content.length < 5 || content.length > limits.message) return res.error(400, `content must be between 5 - ${limits.message} characters`);
const { user } = req;
const thread = await new ThreadModel({ title, author: user }).takeId()
if (category)
thread.categoryID = category;
const message = await new MessageModel({ content, author: user, threadID: thread.id }).takeId()
await thread.push(message.id).save();
await message.save();
@ -57,37 +51,14 @@ app.post("/", RL(5 * 60_000, 1), async (req, res) => {
res.complate(thread);
});
app.patch("/:id/", async (req, res) => {
const { user, thread } = req;
if (user.id !== thread.authorID && !user.admin) return res.error(403, "You have not got permission for this.");
if (!Object.values(req.body).some(Boolean)) return res.error(400, "Missing thread informations for update in request body.");
const { title, state } = req.body;
if (title) {
const limits = req.app.get("limits");
if (title.length < 5 || title.length > limits.title) return res.error(400, `title must be between 5 - ${limits.title} characters`);
if (thread.oldTitles.at(-1) == title) return res.error(400, "You can't use the same title as the previous one.");
thread.oldTitles.push(thread.title = title);
}
if (state) {
if (!user.admin)
return res.error(403, "You have not got permission for change state.");
if (thread.state === state) return res.error(400, "You can't change thread state to same state.");
if (!threadEnum.includes(state)) return res.error(400, "Invalid thread state.");
if (thread.deleted)
await MessageModel.updateMany({ threadID: thread.id }, { deleted: false });
thread.state = state;
}
const { title } = req.body;
if (!title) return res.error(400, "Missing thread title in request body.");
thread.title = title;
await thread.save();
res.complate(thread);
@ -99,7 +70,7 @@ 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(404, "This thread is already deleted.");
if (thread.deleted) return res.error(403, "This thread is already deleted.");
thread.deleted = true;
await thread.save();
@ -107,5 +78,19 @@ app.delete("/:id/", async (req, res) => {
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;

102
routes/api/routes/users.js Normal file
View file

@ -0,0 +1,102 @@
const { UserModel, SecretModel } = require("../../../models");
const { Router } = require("express");
const { URLRegex } = require("../../../lib");
const multer = require("multer");
const app = Router();
app.param("id", async (req, res, next, id) => {
req.member = await UserModel.get(id);
if (!req.member) return res.error(404, `We don't have any user with id ${id}.`);
if (req.member.deleted && !req.user?.admin)
return res.error(404, `You do not have permissions to view this user with id ${id}.`);
next();
});
app.get("/:id", async (req, res) => res.complate(req.member));
app.delete("/:id/", async (req, res) => {
const { user, member } = req;
if (!user.admin)
return res.error(403, "You have not got permission for this.");
const { id = null } = req.params;
if (member.deleted) return res.error(404, `This user is with id ${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) => {
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.");
const { avatar, name, about, theme, admin, deleted } = 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;
if (name) {
await SecretModel.updateOne({ id: member.id }, { username: name });
member.name = name;
}
if (about) member.about = about;
if (theme || ["default", "black"].includes(theme)) member.theme = theme;
if (typeof admin === "boolean" || ["false", "true"].includes(admin)) member.admin = admin;
if (deleted === false) member.deleted = false;
member.edited = true;
res.complate(await member.save());
})
const storage = multer.diskStorage({
destination: function (_req, _file, cb) {
cb(null, './public/images/avatars')
},
filename: function (req, _file, cb) {
cb(null, req.member.id + ".jpg")
}
})
const upload = multer({ storage })
app.put("/:id/", 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;
res.complate(await member.save());
});
module.exports = app;

29
routes/login.js Normal file
View file

@ -0,0 +1,29 @@
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

@ -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}#message-${message.id}`);
res.redirect(`/threads/${message.threadID}?scroll=${message.id}`);
});

44
routes/register.js Normal file
View file

@ -0,0 +1,44 @@
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.destroy()
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;

33
routes/search.js Normal file
View file

@ -0,0 +1,33 @@
const { UserModel, ThreadModel, MessageModel } = require("../models")
const { Router } = require("express");
const app = Router();
app.get("/", (req, res) => res.reply("search"));
app.get("/users", async (req, res) => {
if (!req.query.q) return res.error(400, "Missing query parameter 'q' in request body.");
const users = await UserModel.find({ name: { $regex: req.query.q, $options: "i" } }).limit(10);
res.reply("users", { users, page: null });
});
app.get("/messages", async (req, res) => {
if (!Object.values(req.query).length) return res.error(400, "Missing query parameters in request body.");
const query = {};
if (req.query.q) query.content = { $regex: req.query.q, $options: "i" };
if (req.query.authorID) query.authorID = req.query.authorID;
const messages = await MessageModel.find(query).limit(10);
res.reply("messages",{messages});
});
app.get("/threads", async (req, res) => {
if (!Object.values(req.query).length) return res.error(400, "Missing query parameters in request body.");
const query = {};
if (req.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).limit(10);
res.reply("threads", { threads, page: null });
});
module.exports = app;

View file

@ -1,18 +1,19 @@
const { Router } = require("express");
const app = Router();
const { ThreadModel, MessageModel, CategoryModel } = require("../models")
const { clearContent } = require("../lib");
const { ThreadModel, MessageModel } = require("../models")
app.get("/", async (req, res) => {
const page = Number(req.query.page) || 0;
const query = req.user?.admin ? {} : { state: "OPEN" };
let threads = await ThreadModel.find(query).limit(10).skip(page * 10).sort({ time: -1 });
const query = req.user?.admin ? {} : { deleted: false };
let threads = await ThreadModel.find(query).limit(10).skip(page * 10);
threads = await Promise.all(threads.map(thread => thread.get_author()));
return res.reply("threads", { threads, page, title: "Threads", desp: threads.length + " threads are listed", pages: Math.ceil(await ThreadModel.countDocuments(query) / 10) });
return res.reply("threads", { threads, page, pages: Math.ceil(await ThreadModel.count(query) / 10) });
});
app.get("/create/", async (req, res) => res.reply("create_thread", { categories: await CategoryModel.find() }));
app.get("/create/", (req, res) => res.reply("create_thread"));
app.get("/:id/", async (req, res) => {
@ -21,7 +22,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.state == "OPEN")) {
if (thread && (user?.admin || !thread.deleted)) {
thread.count = await thread.messageCount(user?.admin);
thread.pages = Math.ceil(thread.count / 10);
thread.views++;
@ -29,9 +30,11 @@ app.get("/:id/", async (req, res) => {
if (!user || !user.admin) query.deleted = false;
const messages = await Promise.all(await MessageModel.find(query).sort({ time: 1 }).limit(10).skip(page * 10)
.then(messages => messages.map(message => message.get_author())));
res.reply("thread", { page, thread, messages });
.then(messages => messages.map(async message => {
message.content = clearContent(message.content)
return await message.get_author();
})));
res.reply("thread", { page, thread, messages, scroll: req.query.scroll || messages[0]?.id });
thread.save();

View file

@ -1,12 +1,14 @@
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.countDocuments(query) / 10) });
return res.reply("users", { users, page, pages: Math.ceil(await UserModel.count(query) / 10) });
});
app.get("/:id/avatar", async (req, res) => {
@ -18,33 +20,20 @@ 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 +ips");
const member = await UserModel.get(id);
if (member && (user?.admin || !member.deleted)) {
const message = await MessageModel.countDocuments({ authorID: id });
const thread = await ThreadModel.countDocuments({ authorID: id });
res.reply("user", { member, counts: { message, thread }, discord: req.app.get("DISCORD_AUTH_URL") })
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 } })
}
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

@ -1,92 +0,0 @@
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));

View file

@ -1,34 +0,0 @@
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

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

View file

@ -1,68 +0,0 @@
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;

View file

@ -1,51 +0,0 @@
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;

View file

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

View file

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 206 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

View file

@ -1,63 +0,0 @@
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]);
});

View file

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

View file

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

View file

@ -1,304 +0,0 @@
/*!
* 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

View file

@ -1,31 +0,0 @@
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;

View file

@ -1,15 +0,0 @@
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;

View file

@ -1,39 +0,0 @@
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

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

View file

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

View file

@ -1,27 +0,0 @@
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

@ -1,64 +0,0 @@
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

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

View file

@ -1,28 +0,0 @@
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;

View file

@ -1,34 +0,0 @@
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,6 +0,0 @@
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

@ -1,650 +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');
: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.

Before

Width:  |  Height:  |  Size: 10 KiB

View file

@ -1,124 +0,0 @@
<!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

@ -1,55 +0,0 @@
<!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

@ -1,76 +0,0 @@
<!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

@ -1,32 +0,0 @@
<!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

@ -1,59 +0,0 @@
<!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

@ -1,69 +0,0 @@
<!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

@ -1,62 +0,0 @@
<!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

@ -1,17 +0,0 @@
<!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

@ -1,41 +0,0 @@
<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

@ -1,10 +0,0 @@
<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

@ -1,70 +0,0 @@
<% 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>

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