diff --git a/.env.example b/.env.example index 3a850e3..225329d 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,2 @@ MONGO_DB_URL = mongodb://localhost:27017/akf-forum -DISCORD_CLIENT = discord_app_id SECRET = secret \ No newline at end of file diff --git a/APIDOCS.md b/APIDOCS.md index 59d46b3..3317ee8 100644 --- a/APIDOCS.md +++ b/APIDOCS.md @@ -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``` diff --git a/README.md b/README.md index 18ea482..395d6b0 100644 --- a/README.md +++ b/README.md @@ -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 ## 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 diff --git a/config.json.example b/config.json.example index 1a9c90b..1418a46 100644 --- a/config.json.example +++ b/config.json.example @@ -13,7 +13,7 @@ "max": 25, "windowMs": 60000 }, - "discord_auth": false, + "discord_auth": "", "defaultThreadState": "OPEN", "host": "https://akf-forum.glitch.me" } \ No newline at end of file diff --git a/index.js b/index.js index 3c5e645..d730c23 100644 --- a/index.js +++ b/index.js @@ -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)); diff --git a/lib.js b/lib.js index 34a3805..3599709 100644 --- a/lib.js +++ b/lib.js @@ -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, diff --git a/models/Ban.js b/models/Ban.js index fc5f1d2..c40040f 100644 --- a/models/Ban.js +++ b/models/Ban.js @@ -6,4 +6,5 @@ const schema = new mongoose.Schema({ authorID: { type: String } }); -module.exports = mongoose.model('ban', schema); \ No newline at end of file +const model = mongoose.model('ban', schema); +module.exports = model; \ No newline at end of file diff --git a/models/Message.js b/models/Message.js index 796aad9..356ffcc 100644 --- a/models/Message.js +++ b/models/Message.js @@ -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] } }) diff --git a/models/Secret.js b/models/Secret.js deleted file mode 100644 index 6482408..0000000 --- a/models/Secret.js +++ /dev/null @@ -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); \ No newline at end of file diff --git a/models/User.js b/models/User.js index 912b1b9..90248b0 100644 --- a/models/User.js +++ b/models/User.js @@ -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 () { diff --git a/models/index.js b/models/index.js index 1dcc225..3df647a 100644 --- a/models/index.js +++ b/models/index.js @@ -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 }; \ No newline at end of file +module.exports = { + CategoryModel: require("./Category"), + MessageModel: require("./Message"), + ThreadModel: require("./Thread"), + UserModel: require("./User"), + BanModel: require("./Ban") +}; \ No newline at end of file diff --git a/public/css/setup.css b/public/css/setup.css new file mode 100644 index 0000000..078e362 --- /dev/null +++ b/public/css/setup.css @@ -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); +} \ No newline at end of file diff --git a/public/js/thread.js b/public/js/thread.js index ad289cf..5746a8c 100644 --- a/public/js/thread.js +++ b/public/js/thread.js @@ -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); diff --git a/routes/.js b/routes/.js index 398ca9e..71d30b2 100644 --- a/routes/.js +++ b/routes/.js @@ -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; \ No newline at end of file diff --git a/routes/api/index.js b/routes/api/index.js index 0e8b61c..6701cec 100644 --- a/routes/api/index.js +++ b/routes/api/index.js @@ -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(); }); diff --git a/routes/api/routes/config.js b/routes/api/routes/config.js new file mode 100644 index 0000000..23c6697 --- /dev/null +++ b/routes/api/routes/config.js @@ -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; \ No newline at end of file diff --git a/routes/api/routes/messages.js b/routes/api/routes/messages.js index 2ba7d65..5368dc9 100644 --- a/routes/api/routes/messages.js +++ b/routes/api/routes/messages.js @@ -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; \ No newline at end of file diff --git a/routes/api/routes/users.js b/routes/api/routes/users.js index eb887bb..8f686ed 100644 --- a/routes/api/routes/users.js +++ b/routes/api/routes/users.js @@ -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') diff --git a/routes/login.js b/routes/login.js index 096a345..41edcb3 100644 --- a/routes/login.js +++ b/routes/login.js @@ -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 || '/'); diff --git a/routes/register.js b/routes/register.js index 38c5af9..95b5393 100644 --- a/routes/register.js +++ b/routes/register.js @@ -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; \ No newline at end of file diff --git a/views/login.ejs b/views/login.ejs index dacb5e8..6c48024 100644 --- a/views/login.ejs +++ b/views/login.ejs @@ -14,7 +14,7 @@
" method="post"> - +
diff --git a/views/register.ejs b/views/register.ejs index c4051aa..9798e2e 100644 --- a/views/register.ejs +++ b/views/register.ejs @@ -15,7 +15,7 @@
- + diff --git a/views/setup.ejs b/views/setup.ejs new file mode 100644 index 0000000..ea08904 --- /dev/null +++ b/views/setup.ejs @@ -0,0 +1,34 @@ + + + +<%- include("extra/meta", {title: "Setup the Akf-forum!" }) %> + + + + <%- include("extra/navbar") %> + +

Setup

+

There is default settings for akf-forum, you not need to edit them, but you can! And, the first registered user will be admin.

+ + Default theme: + + Forum name: + + Forum description: + + Default state for new threads, change with "APPROVAL" for approval system: + + Domain of the forum, defaulty setted: + +
+ (Optional) Discord app ID for Discord login: + + + +
+ + + + \ No newline at end of file diff --git a/views/thread.ejs b/views/thread.ejs index 995fa28..1bb41b8 100644 --- a/views/thread.ejs +++ b/views/thread.ejs @@ -78,11 +78,15 @@ <% } %>
-
+
+ style="color: var(--main)" + <% } %> >
<%=message.react.like.length %>
-
+
+ style="color: var(--main)" + <% } %>>
<%=message.react.dislike.length %>
diff --git a/views/user.ejs b/views/user.ejs index 6cdb142..bff3973 100644 --- a/views/user.ejs +++ b/views/user.ejs @@ -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()