Major update named as minor!

Forum Setup page,
edit forum config w/api
ban user's all ips,
secretmodel deleted,
/undelete is disabled
and better themes
if user reacted a message, view it
fixs for reactions of messages
discord auth in config.json
This commit is contained in:
Akif9748 2022-09-23 23:10:13 +03:00
parent 98d379d82c
commit db61361a95
25 changed files with 228 additions and 153 deletions

View File

@ -1,3 +1,2 @@
MONGO_DB_URL = mongodb://localhost:27017/akf-forum
DISCORD_CLIENT = discord_app_id
SECRET = secret

View File

@ -41,11 +41,9 @@ You can change them in config.json.
- GET `/:id` for fetch message.
- DELETE `/:id` for delete message.
- PATCH `/:id` for edit message.
- POST `/:id/undelete` for undelete message.
- POST `/:id/react/:type` for react to a message.
- POST `/` for create message.
#### `/api/search` use `?limit=&skip=` for skip and limit
- GET `/users?q=query` find users.
- GET `/threads?q=query&authorID=not_required` find threads.
@ -63,6 +61,7 @@ You can change them in config.json.
- DELETE `/:id` for delete user.
- PATCH `/:id` for edit user.
- PUT `/:id` for add profile photo to user.
- POST `/:id/ban` for ban all ips of user.
### Example request:
GET ```/api/messages/0```

View File

@ -6,14 +6,14 @@ A Node.js based forum software.
- 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
### 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 themes (`black` or `default`) of users, and forum name, meta description, character limits, discord auth enabler, global ratelimit.
### DISCORD AUTH:
`discord_auth: true` in config.json.
Enter application id to `.env`.
`"discord_auth": "your_app_id"` in config.json.
Create a redirect url in discord developer portal:
`https://forum_url.com/discord_auth/hash`
@ -39,21 +39,20 @@ Akf-forum has got an API for AJAX (fetch), other clients etc. And, you can learn
</details>
## TO-DO list
| To do | Is done? |
| ----- | -------- |
| Profile Message or DM | ⚪ |
| Better Auth for API way | 🟢 |
| mod role, permissions | ⚪ |
| upload other photos, model for it | ⚪ |
| categories page is need a update, thread count in category | ⚪ |
| preview for send messages in markdown format | 🟢 |
| DC auth will store code for taking tokens, and create secret model setting | ⚪ |
- IF a person liked a message, view.
- Disable last seen button.
- Profile Message or DM
- mod role, permissions
- upload other photos, model for it
- categories page is need a update, thread count in category
- DC auth will store code for taking tokens, and create secret model setting
- Disable last seen button for web.
- email auth.
- thread.state =="approval" for threads.
- old contents / titles add to forum interface
- limits
- add ban button to user profile.
- change password.
- add approval threads page.
- who liked a message for web.
- edit config from web admin panel.
## Major Version History
- V4: Caching
- V3: New Theme

View File

@ -13,7 +13,7 @@
"max": 25,
"windowMs": 60000
},
"discord_auth": false,
"discord_auth": "",
"defaultThreadState": "OPEN",
"host": "https://akf-forum.glitch.me"
}

View File

@ -44,7 +44,7 @@ app.use(express.static("public"), express.json(), express.urlencoded({extended:t
);
if (discord_auth)
app.set("discord_auth", `https://discord.com/api/oauth2/authorize?client_id=${process.env.DISCORD_CLIENT}&redirect_uri=${host}%2Fdiscord_auth%2Fhash&response_type=token&scope=identify`);
app.set("discord_auth", `https://discord.com/api/oauth2/authorize?client_id=${discord_auth}&redirect_uri=${host}%2Fdiscord_auth%2Fhash&response_type=token&scope=identify`);
if (RLS.enabled) app.use(RL(RLS.windowMs, RLS.max));

1
lib.js
View File

@ -2,6 +2,7 @@ const RL = require('express-rate-limit');
module.exports = {
threadEnum: ["OPEN", "APPROVAL", "DELETED"],
themes: ["default", "black"],
RL(windowMs = 60_000, max = 1) {
return RL({
windowMs, max, standardHeaders: true, legacyHeaders: false,

View File

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

View File

@ -13,8 +13,8 @@ const schema = new mongoose.Schema({
deleted: { type: Boolean, default: false },
edited: { type: Boolean, default: false },
react: {
like: [Number],
dislike: [Number]
like: [String],
dislike: [String]
}
})

View File

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

View File

@ -2,7 +2,7 @@ const mongoose = require("mongoose")
const { def_theme, limits } = require("../config.json");
const schema = new mongoose.Schema({
id: { type: String, unique: true },
discordID: { 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 },
@ -13,7 +13,8 @@ const schema = new mongoose.Schema({
theme: { type: String, default: def_theme },
lastSeen: { type: Date, default: Date.now, select: false },
hideLastSeen: { type: Boolean, default: false },
ips: { type: [String], select: false }
ips: { type: [String], default: [], select: false },
password: { type: String, select: false }
});
schema.methods.takeId = async function () {

View File

@ -1,8 +1,7 @@
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 };
module.exports = {
CategoryModel: require("./Category"),
MessageModel: require("./Message"),
ThreadModel: require("./Thread"),
UserModel: require("./User"),
BanModel: require("./Ban")
};

26
public/css/setup.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);
}

View File

@ -53,7 +53,7 @@ window.edit_message = async function (id) {
editing.value(content.rawText)
}
window.undelete_message = async function (id) {
const response = await request(`/api/messages/${id}/undelete`);
const response = await request(`/api/messages/${id}/`, "PATCH", { deleted: false });
if (response.deleted) return;
const message = document.getElementById("message-" + id);

View File

@ -1,17 +1,45 @@
const { UserModel, ThreadModel, MessageModel } = require("../models")
const { Router } = require("express");
const app = Router();
const fs = require("fs");
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 }),
threads = await ThreadModel.count({ state: "DELETED"}),
messages = await MessageModel.count({ deleted: false });
res.reply("index", { mem, users, threads, messages })
})
app.get("/setup", async (req, res) => {
if (await UserModel.exists({ admin: true })) return res.error(400, "You have already setuped the site.");
res.reply("setup");
})
app.post("/setup", async (req, res) => {
if (await UserModel.exists({ admin: true })) return res.error(400, "You have already setuped the site.");
let original = {};
try {
original = JSON.parse(fs.readFileSync("./config.json", "utf8"));
} catch (e) {
try {
original = JSON.parse(fs.readFileSync("./config.json.example", "utf8"));
} 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,36 +1,29 @@
const { Router, request, response } = require("express");
const { Router } = require("express");
const app = Router();
const fs = require("fs");
const bcrypt = require("bcrypt");
const { SecretModel, UserModel } = require("../../models");
const { 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 authHeader = req.headers.authorization;
if (!authHeader) return res.error(401, "No authorization header");
const [username, password] = Buffer.from(authHeader.split(' ')[1], "base64").toString().split(":");
const [name, password] = Buffer.from(authHeader.split(' ')[1], "base64").toString().split(":");
if (!username || !password)
return res.error(401, "Authorise headers are missing")
if (!name || !password)
return res.error(400, "Authorise headers are not well formed");
const user = await SecretModel.findOne({ username });
const user = await UserModel.findOne({ name });
if (!user)
return res.error(401, `We don't have any thread with name ${username}.`)
if (!user || user.deleted) return res.error(401, `We don't have any user with name ${name}.`)
if (!await bcrypt.compare(password, user.password)) return res.error(401, 'Incorrect Password!');
req.user = await UserModel.findOne({ name: req.headers.username });
req.user = user;
next();
});

View File

@ -0,0 +1,24 @@
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.post("/", (req, res) => {
fs.writeFileSync("./config.json", JSON.stringify(req.body, null, 4));
require.cache[require.resolve("../config.json")] = require("../../../config.json");
res.complate(require("../../../config.json"));
});
module.exports = app;

View File

@ -18,18 +18,35 @@ 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(403, "This message is already deleted.");
message.deleted = true;
await message.save()
res.complate(message);
});
app.patch("/:id/", async (req, res) => {
const { message, user } = req;
if (user.id !== message.authorID && !user.admin) return res.error(403, "You have not got permission for this.");
const { content = null } = req.body;
if (!content) return res.error(400, "Missing message content in request body.");
if (!Object.values(req.body).some(Boolean)) return res.error(400, "Missing message informations for update in request body.");
const { content, deleted } = req.body;
const limits = req.app.get("limits");
if (content.length < 5 || content.length > limits.message) return res.error(400, `content must be between 5 - ${limits.message} characters`);
if (deleted === false) message.deleted = false;
message.content = content;
if (!message.oldContents.includes(content))
@ -93,33 +110,4 @@ app.post("/:id/react/:type", async (req, res) => {
});
app.delete("/:id/", async (req, res) => {
const { message, user } = req;
if (user.id != message.authorID && !user.admin)
return res.error(403, "You have not got permission for this.");
if (message.deleted) return res.error(403, "This message is already deleted.");
message.deleted = true;
await message.save();
res.complate(message);
})
app.post("/:id/undelete", async (req, res) => {
const { message } = req;
if (!message.deleted) return res.error(404, "This message is not deleted, first, delete it.");
message.deleted = false;
await message.save();
res.complate(message);
})
module.exports = app;

View File

@ -1,11 +1,12 @@
const { UserModel, SecretModel } = require("../../../models");
const { UserModel, BanModel } = require("../../../models");
const { Router } = require("express");
const multer = require("multer");
const { themes } = require("../../../lib");
const app = Router();
app.param("id", async (req, res, next, id) => {
req.member = await UserModel.get(id, req.user.admin ? "+lastSeen" : "");
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}.`);
@ -17,37 +18,21 @@ app.param("id", async (req, res, next, id) => {
app.get("/:id", async (req, res) => res.complate(req.member));
app.delete("/:id/", async (req, res) => {
app.delete("/:id", async (req, res) => {
const { user, member } = req;
if (!user.admin)
return res.error(403, "You have not got permission for this.");
const { id = null } = req.params;
if (member.deleted) return res.error(404, `This user is with id ${id} already deleted.`);
if (member.deleted) return res.error(404, `This user is with id ${member.id} already deleted.`);
member.deleted = true;
await member.save();
res.complate(member);
});
app.post("/:id/undelete/", async (req, res) => {
if (!req.user.admin) return res.error(403, "You have not got permission for this.");
const { user, member } = req;
if (!member) return res.error(404, `We don't have any user with id ${req.params.id}.`);
if (!member.deleted) return res.error(404, "This user is not deleted, first, delete it.");
member.deleted = false;
res.complate(await member.save());
})
app.patch("/:id/", async (req, res) => {
app.patch("/:id", async (req, res) => {
const { user, member } = req;
if (req.user.id !== member.id && !user.admin) return res.error(403, "You have not got permission for this.");
@ -59,9 +44,7 @@ app.patch("/:id/", async (req, res) => {
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`);
await SecretModel.updateOne({ id: member.id }, { username: name });
member.name = name;
}
@ -69,7 +52,7 @@ app.patch("/:id/", async (req, res) => {
if (about.length > desp) return res.error(400, `About must be under ${desp} characters`);
member.about = about;
}
if (theme || ["default", "black"].includes(theme)) member.theme = theme;
if (theme || themes.includes(theme)) member.theme = theme;
if (typeof admin === "boolean" || ["false", "true"].includes(admin)) member.admin = admin;
if (deleted === false) member.deleted = false;
@ -78,6 +61,22 @@ app.patch("/:id/", async (req, res) => {
res.complate(await member.save());
})
app.post("/:id/ban", async (req, res) => {
if (!req.user.admin) return res.error(403, "You have not got permission for this.");
const { member } = req;
for (const ip of member.ips)
try {
await BanModel.create({ ip, reason: `Ban for ${member.name}`, authorID: req.user.id });
req.app.ips.push(ip);
} catch {
continue;
}
res.complate(member);
});
const storage = multer.diskStorage({
destination: function (_req, _file, cb) {
cb(null, './public/images/avatars')

View File

@ -1,4 +1,4 @@
const { UserModel, SecretModel } = require("../models");
const { UserModel } = require("../models");
const { Router } = require("express");
const app = Router();
const bcrypt = require("bcrypt");
@ -8,19 +8,15 @@ app.get("/", (req, res) => res.reply("login", { redirect: req.query.redirect, us
app.post("/", async (req, res) => {
req.session.userID = null;
const { username = null, password = null } = req.body;
const { name, password } = req.body;
if (!username || !password)
return res.error(400, "You forgot entering some values")
if (!name || !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!');
const member = await UserModel.findOne({ name }, "+password");
if (!member || member.deleted) return res.error(403, 'Incorrect username!');
if (!await bcrypt.compare(password, member.password)) return res.error(403, 'Incorrect 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;
req.session.userID = member.id;
res.redirect(req.query.redirect || '/');

View File

@ -1,4 +1,4 @@
const { UserModel, SecretModel } = require("../models");
const { UserModel } = require("../models");
const { Router } = require("express")
const bcrypt = require("bcrypt");
const { RL } = require('../lib');
@ -10,37 +10,31 @@ app.post("/", RL(24 * 60 * 60_000, 5), async (req, res) => {
req.session.userID = null;
let { username, password: body_pass, about } = req.body;
let { name, password, about } = req.body;
if (!username || !body_pass) return res.error(400, "You forgot entering some values");
if (!name || !password) return res.error(400, "You forgot entering some values");
const { names } = req.app.get("limits");
if (username.length < 3 || names > 25) return res.error(400, "Username must be between 3 - 25 characters");
if (body_pass.length < 3 || names > 25) return res.error(400, "Password must be between 3 - 25 characters");
if (name.length < 3 || names > 25) return res.error(400, "Name must be between 3 - 25 characters");
if (password.length < 3 || names > 25) return res.error(400, "Password must be between 3 - 25 characters");
const user = await SecretModel.findOne({ username });
if (user) return res.error(400, `We have got an user named ${username}!`)
const user2 = new UserModel({ name: username })
if (await UserModel.exists({ name })) return res.error(400, `We have got an user named ${name}!`)
const user = new UserModel({ name });
if (about) {
if (about.length > 256) return res.error(400, "about must be under 256 characters");
user2.about = about;
user.about = about;
}
await user2.takeId()
await user2.save();
await user.takeId()
if (user.id === "0") user.admin = true;
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;
user.password = await bcrypt.hash(password, await bcrypt.genSalt(10));
await user.save();
req.session.userID = user.id;
res.redirect('/');
})
});
module.exports = app;

View File

@ -14,7 +14,7 @@
<form action="/login?redirect=<%= redirect !== "/register" ? redirect : "/" %>" method="post">
<input type="text" name="username" placeholder="Username" class="input" required>
<input type="text" name="name" placeholder="Username" class="input" required>
<input type="password" name="password" placeholder="Password" class="input" required>
<input type="submit" style="width:100%" class="btn-primary" value="Login">
</form>

View File

@ -15,7 +15,7 @@
<form action="/register" method="post">
<input type="text" name="username" placeholder="Username" class="input" required>
<input type="text" name="name" placeholder="Username" class="input" required>
<input type="password" name="password" placeholder="Password" class="input" required>
<textarea class="input" name="about" rows="4" placeholder="About you... You can use markdown"></textarea>
<input type="submit" class="btn-primary" style="width:100%;" value="Register">

34
views/setup.ejs Normal file
View File

@ -0,0 +1,34 @@
<!DOCTYPE html>
<html lang="en">
<%- include("extra/meta", {title: "Setup the Akf-forum!" }) %>
<body style="text-align: center;">
<%- include("extra/navbar") %>
<link rel="stylesheet" href="/css/setup.css">
<h1 style="color: var(--main);">Setup</h1>
<h2 style="color: var(--second);">There is default settings for akf-forum, you not need to edit them, but you can! And, the first registered user will be admin.</h2>
<form method="POST">
Default theme:
<input type="text" name="def_theme" value="default" required>
Forum name:
<input type="text" name="forum_name" value="akf" required>
Forum description:
<input type="text" name="description" value="Akf-forum!" required>
Default state for new threads, change with "APPROVAL" for approval system:
<input type="text" name="defaultThreadState" value="OPEN" required>
Domain of the forum, defaulty setted:
<input type="text" name="host" id="domain" value="Akf-forum!" required>
<hr>
(Optional) Discord app ID for Discord login:
<input type="text" name="discord_auth">
<input type="submit" class="btn-primary" value="Setup">
</form>
<script>
document.getElementById("domain").value = location.origin;
</script>
</body>
</html>

View File

@ -78,11 +78,15 @@
<% } %>
<div class="reactions">
<div>
<div <% if (message.react.like.includes(user?.id)) { %>
style="color: var(--main)"
<% } %> >
<i onclick='react("<%= message.id %>","like");' class='bx bx-like'></i>
<div id="like"><%=message.react.like.length %></div>
</div>
<div>
<div <% if (message.react.dislike.includes(user?.id)) { %>
style="color: var(--main)"
<% } %>>
<i onclick='react("<%= message.id %>","dislike");' class='bx bx-dislike'></i>
<div id="dislike"><%=message.react.dislike.length %></div>
</div>

View File

@ -58,7 +58,7 @@
alert("User is deleted!");
location.reload()
} else if (e.target.id == "undelete") {
const response = await request("/api/users/<%= member.id %>/undelete");
const response = await request("/api/users/<%= member.id %>/", "PATCH", { deleted: false });
if (response.deleted) return;
alert("User is undeleted successfully!");
location.reload()