├── .env ├── .gitignore ├── README.md ├── package-lock.json ├── package.json ├── screenshot.png └── src ├── index.js ├── modules ├── DB.js ├── controllers │ ├── authController.js │ ├── forumController.js │ ├── generalController.js │ ├── messagesController.js │ ├── profileController.js │ └── uploadsController.js ├── models │ ├── Answer.js │ ├── AuthHistory.js │ ├── Ban.js │ ├── Board.js │ ├── Comment.js │ ├── Dialogue.js │ ├── File.js │ ├── Folder.js │ ├── Message.js │ ├── Notification.js │ ├── Report.js │ ├── Thread.js │ └── User.js ├── socket │ └── index.js └── utils │ ├── checkFileExec.js │ ├── createThumbnail.js │ ├── deleteFiles.js │ ├── generate_keys.js │ ├── jwt.js │ ├── storage.js │ ├── transliterate.js │ └── validationSchema.js └── routes ├── api.js ├── auth.js └── index.js /.env: -------------------------------------------------------------------------------- 1 | NODE_ENV=deveplopment 2 | PORT=8000 3 | BACKEND=http://localhost:8000 4 | CLIENT=http://localhost:3000 5 | MONGODB=mongodb+srv://Super-Smile:Acg428571!@cluster0.bsnld.mongodb.net/test 6 | SECRET= -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # dependencies 2 | /node_modules 3 | 4 | # production 5 | /public 6 | 7 | # misc 8 | .DS_Store 9 | # .env 10 | # .env.local 11 | # .env.development.local 12 | # .env.test.local 13 | # .env.production 14 | # .env.production.local 15 | 16 | npm-debug.log* 17 | yarn-debug.log* 18 | yarn-error.log* 19 | .eslintcache 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # MERN-Forum 2 | 3 | Fully responsive, multilingual, NodeJs forum app built using Mongoose, ExpressJs, React, Socket.IO, JWT. 4 | 5 | ![Forum screenshot](/screenshot.png) 6 | 7 | ## Installation 8 | - Clone and install dependencies 9 | - `git clone https://github.com/xrystalll/MERN-Forum.git` 10 | - `cd MERN-Forum` 11 | - `npm install` 12 | 13 | - And install for client 14 | - `cd client` 15 | - `npm install` 16 | 17 | - Fill environment (rename file `.env.development` to `.env`) 18 | - `PORT` - Express server port 19 | - `BACKEND` - The address where located backend 20 | - `CLIENT` - The address where located the react client. The backend and client must point to each other and can be the same if running on the same address 21 | - `MONGODB` - Your MongoDB url 22 | - `SECRET` - You can generate a secret key by execute the `/src/modules/utils/generate_keys.js` file in console 23 | 24 | - Set backend address for client in file `/client/src/support/Constants.js` 25 | 26 | ## Launch 27 | - Go to the client folder `cd client` 28 | - Build client production build with the command `npm run build` or run with the command `npm start` 29 | 30 | - Run backend with the command `npm start` or in development mode `npm run dev` 31 | 32 | - Enjoy 🙌 33 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "forum-backend", 3 | "version": "1.0.0", 4 | "author": "xrystalll", 5 | "description": "MERN Forum", 6 | "main": "index.js", 7 | "scripts": { 8 | "start": "node src/index", 9 | "dev": "nodemon src/index" 10 | }, 11 | "dependencies": { 12 | "bcrypt": "^5.0.1", 13 | "cors": "^2.8.5", 14 | "dotenv": "^8.2.0", 15 | "express": "^4.17.1", 16 | "express-rate-limit": "^5.2.6", 17 | "ffmpeg-static": "^4.3.0", 18 | "ffprobe-static": "^3.0.0", 19 | "fluent-ffmpeg": "^2.1.2", 20 | "http-errors": "^1.8.0", 21 | "joi": "^17.4.0", 22 | "jsonwebtoken": "^8.5.1", 23 | "mongoose": "^5.12.3", 24 | "mongoose-paginate-v2": "^1.3.17", 25 | "multer": "^1.4.2", 26 | "sharp": "^0.27.2", 27 | "socket.io": "^4.0.1", 28 | "wanakana": "^4.0.2" 29 | }, 30 | "devDependencies": { 31 | "nodemon": "^2.0.7" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Pro-WebTech/Forum-BE/905106bac354039b77b458148f8f31801e02eff8/screenshot.png -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | require('dotenv').config(); 2 | 3 | const path = require('path'); 4 | 5 | const http = require('http'); 6 | const express = require('express'); 7 | const cors = require('cors'); 8 | const RateLimit = require('express-rate-limit'); 9 | const createError = require('http-errors'); 10 | 11 | console.log("Env:", process.env.MONGODB) 12 | const DB = require('./modules/DB'); 13 | 14 | const app = express() 15 | const httpServer = http.createServer(app) 16 | const io = require('./modules/socket')(httpServer) 17 | 18 | app.use(express.static(path.join(__dirname, '..', 'public'))) 19 | app.use(express.static(path.join(__dirname, '..', 'client', 'build'))) 20 | app.use(cors({ 21 | origin: process.env.CLIENT 22 | })) 23 | app.use(express.json()) 24 | 25 | const limiter = new RateLimit({ 26 | windowMs: 1 * 60 * 1000, 27 | max: 50, 28 | message: { 29 | error: { 30 | status: 429, 31 | message: 'Too many requests per minute' 32 | } 33 | } 34 | }) 35 | app.use('/auth', limiter) 36 | app.use('/api', limiter) 37 | 38 | app.use('/auth', require('./routes/auth')) 39 | 40 | app.use((req, res, next) => { 41 | req.io = io 42 | next() 43 | }) 44 | 45 | app.use('/api', require('./routes/api')) 46 | app.use('/', require('./routes')) 47 | 48 | app.use((err, req, res, next) => { 49 | res.status(err.status || 500) 50 | res.json({ 51 | error: { 52 | status: err.status || 500, 53 | message: err.message 54 | } 55 | }) 56 | }) 57 | 58 | const port = process.env.PORT || 8000 59 | 60 | DB().then(() => { 61 | httpServer.listen({ port }, () => { 62 | console.log(`Server run on ${process.env.BACKEND}`) 63 | }) 64 | }).catch(console.error) 65 | -------------------------------------------------------------------------------- /src/modules/DB.js: -------------------------------------------------------------------------------- 1 | require('dotenv').config(); 2 | const Mongoose = require('mongoose'); 3 | 4 | const DB = () => { 5 | if (process.env.MONGODB) { 6 | const options = { 7 | useUnifiedTopology: true, 8 | useNewUrlParser: true, 9 | useFindAndModify: false, 10 | useCreateIndex: true 11 | } 12 | 13 | return Mongoose.connect(process.env.MONGODB, options) 14 | } else { 15 | return new Promise((resolve, reject) => { 16 | reject('Set MONGODB url in env') 17 | }) 18 | } 19 | } 20 | 21 | module.exports = DB; 22 | -------------------------------------------------------------------------------- /src/modules/controllers/authController.js: -------------------------------------------------------------------------------- 1 | const { Types } = require('mongoose'); 2 | const createError = require('http-errors'); 3 | const { isJapanese, toRomaji } = require('wanakana'); 4 | 5 | const User = require('../models/User'); 6 | const AuthHistory = require('../models/AuthHistory'); 7 | 8 | const { registerSchema, loginSchema } = require('../utils/validationSchema'); 9 | const { signAccessToken } = require('../utils/jwt'); 10 | const toLatin = require('../utils/transliterate'); 11 | 12 | const register = async (req, res, next) => { 13 | try { 14 | const result = await registerSchema.validateAsync(req.body) 15 | 16 | let name = result.username.toLowerCase().replace(/\s/g, '') 17 | if (/[а-яА-ЯЁё]/.test(name)) { 18 | name = toLatin(name) 19 | } 20 | if (isJapanese(name)) { 21 | name = toRomaji(name) 22 | } 23 | 24 | const forbiddenNames = [ 25 | 'admin', 'administrator', 'moder', 'moderator', 'deleted', 'user', 'test', 'qwerty', '12345', '123456789', '1234567890' 26 | ] 27 | if (forbiddenNames.find(i => i === name)) { 28 | throw createError.Conflict('Username is prohibited') 29 | } 30 | 31 | const userNamedoesExist = await User.findOne({ name }) 32 | if (userNamedoesExist) { 33 | throw createError.Conflict('Username is already been registered') 34 | } 35 | 36 | const emailDoesExist = await User.findOne({ email: result.email }) 37 | if (emailDoesExist) { 38 | throw createError.Conflict('E-mail is already been registered') 39 | } 40 | 41 | let displayName = result.username.replace(/\s/g, '') 42 | 43 | const now = new Date().toISOString() 44 | 45 | const user = new User({ 46 | name, 47 | displayName, 48 | email: result.email, 49 | password: result.password, 50 | createdAt: now, 51 | onlineAt: now 52 | }) 53 | const savedUser = await user.save() 54 | const accessToken = await signAccessToken(savedUser) 55 | 56 | const authHistory = new AuthHistory({ 57 | user: savedUser._id, 58 | loginAt: now, 59 | ip: req.headers['x-forwarded-for'] || req.connection.remoteAddress, 60 | ua: req.headers['user-agent'] 61 | }) 62 | await authHistory.save() 63 | 64 | res.json({ 65 | user: { 66 | id: savedUser._id, 67 | name: savedUser.name, 68 | displayName: savedUser.displayName, 69 | picture: savedUser.picture, 70 | role: savedUser.role 71 | }, 72 | accessToken 73 | }) 74 | } catch(error) { 75 | if (error.isJoi === true) error.status = 422 76 | next(error) 77 | } 78 | } 79 | 80 | const login = async (req, res, next) => { 81 | try { 82 | const result = await loginSchema.validateAsync(req.body) 83 | 84 | let name = result.username.toLowerCase().replace(/\s/g, '') 85 | if (/[а-яА-ЯЁё]/.test(name)) { 86 | name = toLatin(name) 87 | } 88 | if (isJapanese(name)) { 89 | name = toRomaji(name) 90 | } 91 | 92 | const populate = { 93 | path: 'ban', 94 | select: '_id expiresAt', 95 | } 96 | const user = await User.findOne({ name }).populate(populate) 97 | 98 | if (!user) throw createError.NotFound('User not registered') 99 | 100 | const isMatch = await user.isValidPassword(result.password) 101 | if (!isMatch) throw createError.Unauthorized('Username or password not valid') 102 | 103 | const now = new Date().toISOString() 104 | 105 | if (user.ban) { 106 | if (user.ban.expiresAt < now) { 107 | await User.updateOne({ _id: Types.ObjectId(user._id) }, { ban: null }) 108 | } else { 109 | return res.json({ 110 | ban: { 111 | userId: user._id, 112 | } 113 | }) 114 | } 115 | } 116 | 117 | const accessToken = await signAccessToken(user) 118 | 119 | const authHistory = new AuthHistory({ 120 | user: user._id, 121 | loginAt: now, 122 | ip: req.headers['x-forwarded-for'] || req.connection.remoteAddress, 123 | ua: req.headers['user-agent'] 124 | }) 125 | await authHistory.save() 126 | 127 | res.json({ 128 | user: { 129 | id: user._id, 130 | name: user.name, 131 | displayName: user.displayName, 132 | picture: user.picture, 133 | role: user.role 134 | }, 135 | accessToken 136 | }) 137 | } catch(error) { 138 | if (error.isJoi === true) { 139 | return next(createError.BadRequest('Invalid username or password')) 140 | } 141 | next(error) 142 | } 143 | } 144 | 145 | module.exports = { register, login } 146 | -------------------------------------------------------------------------------- /src/modules/controllers/forumController.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const { Types } = require('mongoose'); 3 | const createError = require('http-errors'); 4 | const multer = require('multer'); 5 | 6 | const User = require('../models/User'); 7 | const Board = require('../models/Board'); 8 | const Thread = require('../models/Thread'); 9 | const Answer = require('../models/Answer'); 10 | const Notification = require('../models/Notification'); 11 | 12 | const deleteFiles = require('../utils//deleteFiles'); 13 | const { checkFileExec, videoTypes } = require('../utils/checkFileExec'); 14 | const storage = require('../utils/storage'); 15 | const createThumb = require('../utils/createThumbnail'); 16 | 17 | const upload = multer({ 18 | storage: storage('forum', 'attach'), 19 | fileFilter: (req, file, callback) => checkFileExec(file, callback), 20 | limits: { fields: 1, fileSize: 1048576 * 20 } // 20Mb 21 | }).array('attach', 4) // 20 * 4 = 80Mb 22 | 23 | module.exports.getBoards = async (req, res, next) => { 24 | try { 25 | const { limit = 10, page = 1, sort, pagination = true } = req.query 26 | 27 | let boards 28 | if (sort === 'popular') { 29 | boards = await Board.paginate({}, { sort: { threadsCount: -1, answersCount: -1 }, page, limit, pagination: JSON.parse(pagination) }) 30 | } else if (sort === 'answersCount') { 31 | boards = await Board.paginate({}, { sort: { answersCount: -1 }, page, limit, pagination: JSON.parse(pagination) }) 32 | } else if (sort === 'newestThread') { 33 | boards = await Board.paginate({}, { sort: { newestThread: -1 }, page, limit, pagination: JSON.parse(pagination) }) 34 | } else if (sort === 'newestAnswer') { 35 | boards = await Board.paginate({}, { sort: { newestAnswer: -1 }, page, limit, pagination: JSON.parse(pagination) }) 36 | } else { 37 | boards = await Board.paginate({}, { sort: { position: -1 }, page, limit, pagination: JSON.parse(pagination) }) 38 | } 39 | 40 | res.json(boards) 41 | } catch(err) { 42 | next(createError.InternalServerError(err)) 43 | } 44 | } 45 | 46 | module.exports.getBoard = async (req, res, next) => { 47 | try { 48 | const { name, boardId } = req.query 49 | 50 | let board 51 | if (name) { 52 | board = await Board.findOne({ name }) 53 | } else if (boardId) { 54 | board = await Board.findById(boardId) 55 | } else { 56 | return next(createError.BadRequest('Board name or boardId must not be empty')) 57 | } 58 | 59 | res.json(board) 60 | } catch(err) { 61 | next(createError.InternalServerError(err)) 62 | } 63 | } 64 | 65 | module.exports.createBoard = async (req, res, next) => { 66 | try { 67 | const { name, title, body, position } = req.body 68 | const admin = req.payload.role === 3 69 | 70 | if (!admin) return next(createError.Unauthorized('Action not allowed')) 71 | if (name.trim() === '') return next(createError.BadRequest('Board name must not be empty')) 72 | if (title.trim() === '') return next(createError.BadRequest('Board title must not be empty')) 73 | if (!position || !Number.isInteger(position) || position < 0) return next(createError.BadRequest('Position must be number')) 74 | 75 | const nameUrl = name.trim().toLowerCase().substring(0, 12).replace(/[^a-z0-9-_]/g, '') 76 | 77 | const nameExist = await Board.findOne({ name: nameUrl }) 78 | if (nameExist) return next(createError.Conflict('Board with this short name is already been created')) 79 | 80 | const newBoard = new Board({ 81 | name: nameUrl, 82 | title: title.trim().substring(0, 21), 83 | body: body.substring(0, 100), 84 | position, 85 | createdAt: new Date().toISOString(), 86 | threadsCount: 0, 87 | answersCount: 0 88 | }) 89 | 90 | const board = await newBoard.save() 91 | 92 | res.json(board) 93 | } catch(err) { 94 | next(createError.InternalServerError(err)) 95 | } 96 | } 97 | 98 | module.exports.deleteBoard = async (req, res, next) => { 99 | try { 100 | const { boardId } = req.body 101 | const admin = req.payload.role === 3 102 | 103 | if (!admin) return next(createError.Unauthorized('Action not allowed')) 104 | if (!boardId) return next(createError.BadRequest('boardId must not be empty')) 105 | 106 | const board = await Board.findById(boardId) 107 | await board.delete() 108 | 109 | res.json({ message: 'Board successfully deleted' }) 110 | } catch(err) { 111 | next(createError.InternalServerError(err)) 112 | } 113 | } 114 | 115 | module.exports.editBoard = async (req, res, next) => { 116 | try { 117 | const { boardId, name, title, body, position } = req.body 118 | const admin = req.payload.role === 3 119 | 120 | if (!admin) return next(createError.Unauthorized('Action not allowed')) 121 | if (!boardId) return next(createError.BadRequest('boardId must not be empty')) 122 | if (name.trim() === '') return next(createError.BadRequest('Board name must not be empty')) 123 | if (title.trim() === '') return next(createError.BadRequest('Board title must not be empty')) 124 | if (!position || !Number.isInteger(position) || position < 0) return next(createError.BadRequest('Position must be number')) 125 | 126 | const nameUrl = name.trim().toLowerCase().substring(0, 12).replace(/[^a-z0-9-_]/g, '') 127 | 128 | const nameExist = await Board.findOne({ name: nameUrl }) 129 | if (nameExist) return next(createError.Conflict('Board with this short name is already been created')) 130 | 131 | await Board.updateOne({ _id: Types.ObjectId(boardId) }, { 132 | name: nameUrl, 133 | title: title.trim().substring(0, 21), 134 | body: body.substring(0, 100), 135 | position 136 | }) 137 | const board = await Board.findById(boardId) 138 | 139 | res.json(board) 140 | } catch(err) { 141 | next(createError.InternalServerError(err)) 142 | } 143 | } 144 | 145 | module.exports.getRecentlyThreads = async (req, res, next) => { 146 | try { 147 | const { limit = 10, page = 1 } = req.query 148 | 149 | const populate = [{ 150 | path: 'author', 151 | select: '_id name displayName onlineAt picture role ban' 152 | }, { 153 | path: 'likes', 154 | select: '_id name displayName picture' 155 | }] 156 | const threads = await Thread.paginate({}, { sort: { pined: -1, newestAnswer: -1, createdAt: -1 }, page, limit, populate }) 157 | 158 | res.json(threads) 159 | } catch(err) { 160 | next(createError.InternalServerError(err)) 161 | } 162 | } 163 | 164 | module.exports.getThreads = async (req, res, next) => { 165 | try { 166 | const { boardId, limit = 10, page = 1, sort } = req.query 167 | 168 | if (!boardId) return next(createError.BadRequest('boardId must not be empty')) 169 | 170 | const populate = [{ 171 | path: 'author', 172 | select: '_id name displayName onlineAt picture role ban' 173 | }, { 174 | path: 'likes', 175 | select: '_id name displayName picture' 176 | }] 177 | let threads 178 | if (sort === 'answersCount') { 179 | threads = await Thread.paginate({ boardId }, { sort: { pined: -1, answersCount: -1 }, page, limit, populate }) 180 | } else if (sort === 'newestAnswer') { 181 | threads = await Thread.paginate({ boardId }, { sort: { pined: -1, newestAnswer: -1 }, page, limit, populate }) 182 | } else { 183 | threads = await Thread.paginate({ boardId }, { sort: { pined: -1, createdAt: -1 }, page, limit, populate }) 184 | } 185 | 186 | res.json(threads) 187 | } catch(err) { 188 | next(createError.InternalServerError(err)) 189 | } 190 | } 191 | 192 | module.exports.getThread = async (req, res, next) => { 193 | try { 194 | const { threadId } = req.query 195 | 196 | if (!threadId) return next(createError.BadRequest('threadId must not be empty')) 197 | 198 | const populate = [{ 199 | path: 'author', 200 | select: '_id name displayName onlineAt picture role ban' 201 | }, { 202 | path: 'likes', 203 | select: '_id name displayName picture' 204 | }] 205 | const thread = await Thread.findById(threadId).populate(populate) 206 | const board = await Board.findById(thread.boardId).select('_id name title') 207 | 208 | res.json({ board, thread }) 209 | } catch(err) { 210 | next(createError.InternalServerError(err)) 211 | } 212 | } 213 | 214 | module.exports.createThread = async (req, res, next) => { 215 | try { 216 | upload(req, res, async (err) => { 217 | if (err) return next(createError.BadRequest(err.message)) 218 | 219 | const { boardId, title, body } = JSON.parse(req.body.postData) 220 | 221 | if (!boardId) return next(createError.BadRequest('boardId must not be empty')) 222 | if (title.trim() === '') return next(createError.BadRequest('Thread title must not be empty')) 223 | if (body.trim() === '') return next(createError.BadRequest('Thread body must not be empty')) 224 | 225 | const now = new Date().toISOString() 226 | 227 | let files = null 228 | if (req.files.length) { 229 | files = [] 230 | await Promise.all(req.files.map(async (item) => { 231 | if (videoTypes.find(i => i === item.mimetype)) { 232 | const thumbFilename = item.filename.replace(path.extname(item.filename), '.jpg') 233 | 234 | const thumbnail = await createThumb(item.path, 'forum', thumbFilename) 235 | 236 | files.push({ 237 | file: `/forum/${item.filename}`, 238 | thumb: `/forum/thumbnails/${thumbnail}`, 239 | type: item.mimetype, 240 | size: item.size 241 | }) 242 | } else { 243 | files.push({ 244 | file: `/forum/${item.filename}`, 245 | thumb: null, 246 | type: item.mimetype, 247 | size: item.size 248 | }) 249 | } 250 | })) 251 | } 252 | 253 | const newThread = new Thread({ 254 | boardId, 255 | pined: false, 256 | closed: false, 257 | title: title.trim().substring(0, 100), 258 | body: body.substring(0, 1000), 259 | createdAt: now, 260 | author: req.payload.id, 261 | newestAnswer: now, 262 | attach: files 263 | }) 264 | 265 | const thread = await newThread.save() 266 | 267 | await Board.updateOne({ _id: Types.ObjectId(boardId) }, { $inc: { threadsCount: 1 }, newestThread: now }) 268 | await User.updateOne({ _id: Types.ObjectId(req.payload.id) }, { $inc: { karma: 5 } }) 269 | 270 | res.json(thread) 271 | }) 272 | } catch(err) { 273 | next(createError.InternalServerError(err)) 274 | } 275 | } 276 | 277 | module.exports.deleteThread = async (req, res, next) => { 278 | try { 279 | const { threadId } = req.body 280 | const moder = req.payload.role >= 2 281 | 282 | if (!moder) return next(createError.Unauthorized('Action not allowed')) 283 | if (!threadId) return next(createError.BadRequest('threadId must not be empty')) 284 | 285 | const thread = await Thread.findById(threadId).populate({ path: 'author', select: 'role' }) 286 | 287 | if (!thread.author) { 288 | thread.author = { 289 | role: 1 290 | } 291 | } 292 | if (req.payload.role < thread.author.role) return next(createError.Unauthorized('Action not allowed')) 293 | 294 | if (thread.attach && thread.attach.length) { 295 | const files = thread.attach.reduce((array, item) => { 296 | if (item.thumb) { 297 | return [ 298 | ...array, 299 | path.join(__dirname, '..', '..', '..', 'public', 'forum', path.basename(item.file)), 300 | path.join(__dirname, '..', '..', '..', 'public', 'forum', 'thumbnails', path.basename(item.thumb)) 301 | ] 302 | } 303 | 304 | return [ 305 | ...array, 306 | path.join(__dirname, '..', '..', '..', 'public', 'forum', path.basename(item.file)) 307 | ] 308 | }, []) 309 | 310 | deleteFiles(files, (err) => { 311 | if (err) console.error(err) 312 | }) 313 | } 314 | 315 | const answers = await Answer.find({ threadId: Types.ObjectId(threadId) }) 316 | const answersCount = answers.length 317 | await Promise.all(answers.map(async (item) => { 318 | const answer = await Answer.findById(item._id) 319 | 320 | if (answer.attach && answer.attach.length) { 321 | const files = answer.attach.reduce((array, item) => { 322 | if (item.thumb) { 323 | return [ 324 | ...array, 325 | path.join(__dirname, '..', '..', '..', 'public', 'forum', path.basename(item.file)), 326 | path.join(__dirname, '..', '..', '..', 'public', 'forum', 'thumbnails', path.basename(item.thumb)) 327 | ] 328 | } 329 | 330 | return [ 331 | ...array, 332 | path.join(__dirname, '..', '..', '..', 'public', 'forum', path.basename(item.file)) 333 | ] 334 | }, []) 335 | 336 | deleteFiles(files, (err) => { 337 | if (err) console.error(err) 338 | }) 339 | } 340 | 341 | await answer.delete() 342 | })) 343 | 344 | await thread.delete() 345 | 346 | await Board.updateOne({ _id: Types.ObjectId(thread.boardId) }, { 347 | $inc: { 348 | threadsCount: -1, 349 | answersCount: -answersCount 350 | } 351 | }) 352 | 353 | res.json({ message: 'Thread successfully deleted' }) 354 | 355 | req.io.to('thread:' + threadId).emit('threadDeleted', { id: threadId }) 356 | } catch(err) { 357 | next(createError.InternalServerError(err)) 358 | } 359 | } 360 | 361 | module.exports.clearThread = async (req, res, next) => { 362 | try { 363 | const { threadId } = req.body 364 | const moder = req.payload.role >= 2 365 | 366 | if (!moder) return next(createError.Unauthorized('Action not allowed')) 367 | if (!threadId) return next(createError.BadRequest('threadId must not be empty')) 368 | 369 | const thread = await Thread.findById(threadId).populate({ path: 'author', select: 'role' }) 370 | 371 | if (!thread.author) { 372 | thread.author = { 373 | role: 1 374 | } 375 | } 376 | if (req.payload.role < thread.author.role) return next(createError.Unauthorized('Action not allowed')) 377 | 378 | const answers = await Answer.find({ threadId: Types.ObjectId(threadId) }) 379 | const answersCount = answers.length 380 | await Promise.all(answers.map(async (item) => { 381 | const answer = await Answer.findById(item._id) 382 | 383 | if (answer.attach && answer.attach.length) { 384 | const files = answer.attach.reduce((array, item) => { 385 | if (item.thumb) { 386 | return [ 387 | ...array, 388 | path.join(__dirname, '..', '..', '..', 'public', 'forum', path.basename(item.file)), 389 | path.join(__dirname, '..', '..', '..', 'public', 'forum', 'thumbnails', path.basename(item.thumb)) 390 | ] 391 | } 392 | 393 | return [ 394 | ...array, 395 | path.join(__dirname, '..', '..', '..', 'public', 'forum', path.basename(item.file)) 396 | ] 397 | }, []) 398 | 399 | deleteFiles(files, (err) => { 400 | if (err) console.error(err) 401 | }) 402 | } 403 | 404 | await answer.delete() 405 | })) 406 | 407 | await Thread.updateOne({ _id: Types.ObjectId(threadId) }, { answersCount: 0 }) 408 | await Board.updateOne({ _id: Types.ObjectId(thread.boardId) }, { $inc: { answersCount: -answersCount } }) 409 | 410 | res.json({ message: 'Thread successfully cleared' }) 411 | 412 | req.io.to('thread:' + threadId).emit('threadCleared', { id: threadId }) 413 | } catch(err) { 414 | next(createError.InternalServerError(err)) 415 | } 416 | } 417 | 418 | module.exports.editThread = async (req, res, next) => { 419 | try { 420 | upload(req, res, async (err) => { 421 | if (err) return next(createError.BadRequest(err.message)) 422 | 423 | const { threadId, title, body, closed } = JSON.parse(req.body.postData) 424 | 425 | if (!threadId) return next(createError.BadRequest('threadId must not be empty')) 426 | if (title.trim() === '') return next(createError.BadRequest('Thread title must not be empty')) 427 | if (body.trim() === '') return next(createError.BadRequest('Thread body must not be empty')) 428 | 429 | const thread = await Thread.findById(threadId).populate({ path: 'author', select: 'role' }) 430 | 431 | if (!thread.author) { 432 | thread.author = { 433 | role: 1 434 | } 435 | } 436 | if (req.payload.id !== thread.author._id) { 437 | if (req.payload.role < thread.author.role) { 438 | return next(createError.Unauthorized('Action not allowed')) 439 | } 440 | } 441 | 442 | if (req.files.length && thread.attach && thread.attach.length) { 443 | const files = thread.attach.reduce((array, item) => { 444 | if (item.thumb) { 445 | return [ 446 | ...array, 447 | path.join(__dirname, '..', '..', '..', 'public', 'forum', path.basename(item.file)), 448 | path.join(__dirname, '..', '..', '..', 'public', 'forum', 'thumbnails', path.basename(item.thumb)) 449 | ] 450 | } 451 | 452 | return [ 453 | ...array, 454 | path.join(__dirname, '..', '..', '..', 'public', 'forum', path.basename(item.file)) 455 | ] 456 | }, []) 457 | 458 | deleteFiles(files, (err) => { 459 | if (err) console.error(err) 460 | }) 461 | } 462 | 463 | let files = thread.attach 464 | if (req.files.length) { 465 | files = [] 466 | await Promise.all(req.files.map(async (item) => { 467 | if (videoTypes.find(i => i === item.mimetype)) { 468 | const thumbFilename = item.filename.replace(path.extname(item.filename), '.jpg') 469 | 470 | await createThumb(item.path, 'forum', thumbFilename) 471 | 472 | files.push({ 473 | file: `/forum/${item.filename}`, 474 | thumb: `/forum/thumbnails/${thumbFilename}`, 475 | type: item.mimetype, 476 | size: item.size 477 | }) 478 | } else { 479 | files.push({ 480 | file: `/forum/${item.filename}`, 481 | thumb: null, 482 | type: item.mimetype, 483 | size: item.size 484 | }) 485 | } 486 | })) 487 | } 488 | 489 | const obj = { 490 | title: title.trim().substring(0, 100), 491 | body: body.substring(0, 1000), 492 | closed: closed === undefined ? thread.closed : closed, 493 | attach: files 494 | } 495 | if (closed === undefined) { 496 | obj.edited = { 497 | createdAt: new Date().toISOString() 498 | } 499 | } 500 | 501 | await Thread.updateOne({ _id: Types.ObjectId(threadId) }, obj) 502 | 503 | const populate = [{ 504 | path: 'author', 505 | select: '_id name displayName onlineAt picture role ban' 506 | }, { 507 | path: 'likes', 508 | select: '_id name displayName picture' 509 | }] 510 | const editedThread = await Thread.findById(threadId).populate(populate) 511 | 512 | res.json(editedThread) 513 | 514 | req.io.to('thread:' + threadId).emit('threadEdited', editedThread) 515 | }) 516 | } catch(err) { 517 | next(createError.InternalServerError(err)) 518 | } 519 | } 520 | 521 | module.exports.adminEditThread = async (req, res, next) => { 522 | try { 523 | upload(req, res, async (err) => { 524 | if (err) return next(createError.BadRequest(err.message)) 525 | 526 | const { threadId, title, body, pined, closed } = JSON.parse(req.body.postData) 527 | const moder = req.payload.role >= 2 528 | 529 | if (!moder) return next(createError.Unauthorized('Action not allowed')) 530 | if (!threadId) return next(createError.BadRequest('threadId must not be empty')) 531 | if (title.trim() === '') return next(createError.BadRequest('Board title must not be empty')) 532 | if (body.trim() === '') return next(createError.BadRequest('Thread body must not be empty')) 533 | 534 | const thread = await Thread.findById(threadId).populate({ path: 'author', select: 'role' }) 535 | 536 | if (!thread.author) { 537 | thread.author = { 538 | role: 1 539 | } 540 | } 541 | if (req.payload.role < thread.author.role) return next(createError.Unauthorized('Action not allowed')) 542 | 543 | if (req.files.length && thread.attach && thread.attach.length) { 544 | const files = thread.attach.reduce((array, item) => { 545 | if (item.thumb) { 546 | return [ 547 | ...array, 548 | path.join(__dirname, '..', '..', '..', 'public', 'forum', path.basename(item.file)), 549 | path.join(__dirname, '..', '..', '..', 'public', 'forum', 'thumbnails', path.basename(item.thumb)) 550 | ] 551 | } 552 | 553 | return [ 554 | ...array, 555 | path.join(__dirname, '..', '..', '..', 'public', 'forum', path.basename(item.file)) 556 | ] 557 | }, []) 558 | 559 | deleteFiles(files, (err) => { 560 | if (err) console.error(err) 561 | }) 562 | } 563 | 564 | let files = thread.attach 565 | if (req.files.length) { 566 | files = [] 567 | await Promise.all(req.files.map(async (item) => { 568 | if (videoTypes.find(i => i === item.mimetype)) { 569 | const thumbFilename = item.filename.replace(path.extname(item.filename), '.jpg') 570 | 571 | await createThumb(item.path, 'forum', thumbFilename) 572 | 573 | files.push({ 574 | file: `/forum/${item.filename}`, 575 | thumb: `/forum/thumbnails/${thumbFilename}`, 576 | type: item.mimetype, 577 | size: item.size 578 | }) 579 | } else { 580 | files.push({ 581 | file: `/forum/${item.filename}`, 582 | thumb: null, 583 | type: item.mimetype, 584 | size: item.size 585 | }) 586 | } 587 | })) 588 | } 589 | 590 | const obj = { 591 | title: title.trim().substring(0, 100), 592 | body: body.substring(0, 1000), 593 | pined: pined === undefined ? thread.pined : pined, 594 | closed: closed === undefined ? thread.closed : closed, 595 | attach: files 596 | } 597 | if (pined === undefined && closed === undefined) { 598 | obj.edited = { 599 | createdAt: new Date().toISOString() 600 | } 601 | } 602 | 603 | await Thread.updateOne({ _id: Types.ObjectId(threadId) }, obj) 604 | 605 | const populate = [{ 606 | path: 'author', 607 | select: '_id name displayName onlineAt picture role ban' 608 | }, { 609 | path: 'likes', 610 | select: '_id name displayName picture' 611 | }] 612 | const editedThread = await Thread.findById(threadId).populate(populate) 613 | 614 | res.json(editedThread) 615 | 616 | req.io.to('thread:' + threadId).emit('threadEdited', editedThread) 617 | }) 618 | } catch(err) { 619 | next(createError.InternalServerError(err)) 620 | } 621 | } 622 | 623 | module.exports.likeThread = async (req, res, next) => { 624 | try { 625 | const { threadId } = req.body 626 | 627 | if (!threadId) return next(createError.BadRequest('threadId must not be empty')) 628 | 629 | const thread = await Thread.findById(threadId) 630 | 631 | if (thread.likes.find(like => like.toString() === req.payload.id)) { 632 | thread.likes = thread.likes.filter(like => like.toString() !== req.payload.id) // unlike 633 | } else { 634 | thread.likes.push(req.payload.id) // like 635 | } 636 | await thread.save() 637 | 638 | const populate = [{ 639 | path: 'author', 640 | select: '_id name displayName onlineAt picture role ban' 641 | }, { 642 | path: 'likes', 643 | select: '_id name displayName picture' 644 | }] 645 | const likedThread = await Thread.findById(threadId).populate(populate) 646 | 647 | res.json(likedThread) 648 | 649 | req.io.to('thread:' + threadId).emit('threadLiked', likedThread) 650 | } catch(err) { 651 | next(createError.InternalServerError(err)) 652 | } 653 | } 654 | 655 | module.exports.getAnswers = async (req, res, next) => { 656 | try { 657 | const { threadId, limit = 10, page = 1, pagination = true } = req.query 658 | 659 | if (!threadId) return next(createError.BadRequest('threadId must not be empty')) 660 | 661 | const populate = [{ 662 | path: 'author', 663 | select: '_id name displayName onlineAt picture role ban' 664 | }, { 665 | path: 'likes', 666 | select: '_id name displayName picture' 667 | }] 668 | const answers = await Answer.paginate({ threadId }, { page, limit, populate, pagination: JSON.parse(pagination) }) 669 | 670 | res.json(answers) 671 | } catch(err) { 672 | next(createError.InternalServerError(err)) 673 | } 674 | } 675 | 676 | module.exports.createAnswer = async (req, res, next) => { 677 | try { 678 | upload(req, res, async (err) => { 679 | if (err) return next(createError.BadRequest(err.message)) 680 | 681 | const { threadId, answeredTo, body } = JSON.parse(req.body.postData) 682 | 683 | if (!threadId) return next(createError.BadRequest('threadId must not be empty')) 684 | if (body.trim() === '') return next(createError.BadRequest('Answer body must not be empty')) 685 | 686 | const now = new Date().toISOString() 687 | 688 | const thread = await Thread.findById(threadId) 689 | 690 | let files = null 691 | if (req.files.length) { 692 | files = [] 693 | await Promise.all(req.files.map(async (item) => { 694 | if (videoTypes.find(i => i === item.mimetype)) { 695 | const thumbFilename = item.filename.replace(path.extname(item.filename), '.jpg') 696 | 697 | await createThumb(item.path, 'forum', thumbFilename) 698 | 699 | files.push({ 700 | file: `/forum/${item.filename}`, 701 | thumb: `/forum/thumbnails/${thumbFilename}`, 702 | type: item.mimetype, 703 | size: item.size 704 | }) 705 | } else { 706 | files.push({ 707 | file: `/forum/${item.filename}`, 708 | thumb: null, 709 | type: item.mimetype, 710 | size: item.size 711 | }) 712 | } 713 | })) 714 | } 715 | 716 | const newAnswer = new Answer({ 717 | boardId: thread.boardId, 718 | threadId, 719 | answeredTo, 720 | body: body.substring(0, 1000), 721 | createdAt: now, 722 | author: req.payload.id, 723 | attach: files 724 | }) 725 | 726 | const answer = await newAnswer.save() 727 | 728 | await Board.updateOne({ _id: Types.ObjectId(thread.boardId) }, { $inc: { answersCount: 1 }, newestAnswer: now }) 729 | await Thread.updateOne({ _id: Types.ObjectId(threadId) }, { $inc: { answersCount: 1 }, newestAnswer: now }) 730 | 731 | const populate = [{ 732 | path: 'author', 733 | select: '_id name displayName onlineAt picture role ban' 734 | }, { 735 | path: 'likes', 736 | select: '_id name displayName picture' 737 | }] 738 | const populatedAnswer = await Answer.findById(answer._id).populate(populate) 739 | 740 | await User.updateOne({ _id: Types.ObjectId(req.payload.id) }, { 741 | $inc: { 742 | karma: populatedAnswer.author._id === req.payload.id ? 1 : 2 743 | } 744 | }) 745 | 746 | res.json(populatedAnswer) 747 | 748 | req.io.to('thread:' + threadId).emit('answerCreated', populatedAnswer) 749 | 750 | let type = 'answerToThread' 751 | let to = thread.author 752 | if (answeredTo && answeredTo !== threadId) { 753 | const answerTo = await Answer.findById(answeredTo) 754 | type = 'answerToAnswer' 755 | to = answerTo.author 756 | } 757 | 758 | if (!answeredTo && req.payload.id === thread.author.toString()) return 759 | 760 | const newNotification = new Notification({ 761 | type, 762 | to, 763 | from: req.payload.id, 764 | pageId: threadId, 765 | title: thread.title, 766 | body: body.substring(0, 1000), 767 | createdAt: new Date().toISOString(), 768 | read: false 769 | }) 770 | const notification = await newNotification.save() 771 | 772 | const populateNotification = [{ 773 | path: 'to', 774 | select: '_id name displayName onlineAt picture role ban' 775 | }, { 776 | path: 'from', 777 | select: '_id name displayName onlineAt picture role ban' 778 | }] 779 | const populatedNotification = await Notification.findById(notification._id).populate(populateNotification) 780 | 781 | req.io.to('notification:' + to).emit('newNotification', populatedNotification) 782 | }) 783 | } catch(err) { 784 | next(createError.InternalServerError(err)) 785 | } 786 | } 787 | 788 | module.exports.deleteAnswer = async (req, res, next) => { 789 | try { 790 | const { answerId } = req.body 791 | const moder = req.payload.role >= 2 792 | 793 | if (!moder) return next(createError.Unauthorized('Action not allowed')) 794 | if (!answerId) return next(createError.BadRequest('answerId must not be empty')) 795 | 796 | const answer = await Answer.findById(answerId).populate({ path: 'author', select: 'role' }) 797 | 798 | if (!answer.author) { 799 | answer.author = { 800 | role: 1 801 | } 802 | } 803 | if (req.payload.role < answer.author.role) return next(createError.Unauthorized('Action not allowed')) 804 | 805 | if (answer.attach && answer.attach.length) { 806 | const files = answer.attach.reduce((array, item) => { 807 | if (item.thumb) { 808 | return [ 809 | ...array, 810 | path.join(__dirname, '..', '..', '..', 'public', 'forum', path.basename(item.file)), 811 | path.join(__dirname, '..', '..', '..', 'public', 'forum', 'thumbnails', path.basename(item.thumb)) 812 | ] 813 | } 814 | 815 | return [ 816 | ...array, 817 | path.join(__dirname, '..', '..', '..', 'public', 'forum', path.basename(item.file)) 818 | ] 819 | }, []) 820 | 821 | deleteFiles(files, (err) => { 822 | if (err) console.error(err) 823 | }) 824 | } 825 | 826 | await answer.delete() 827 | 828 | await Board.updateOne({ _id: Types.ObjectId(answer.boardId) }, { $inc: { answersCount: -1 } }) 829 | await Thread.updateOne({ _id: Types.ObjectId(answer.threadId) }, { $inc: { answersCount: -1 } }) 830 | 831 | res.json({ message: 'Answer successfully deleted' }) 832 | 833 | req.io.to('thread:' + answer.threadId).emit('answerDeleted', { id: answerId }) 834 | } catch(err) { 835 | next(createError.InternalServerError(err)) 836 | } 837 | } 838 | 839 | module.exports.editAnswer = async (req, res, next) => { 840 | try { 841 | upload(req, res, async (err) => { 842 | if (err) return next(createError.BadRequest(err.message)) 843 | 844 | const { answerId, body } = JSON.parse(req.body.postData) 845 | 846 | if (!answerId) return next(createError.BadRequest('answerId must not be empty')) 847 | if (body.trim() === '') return next(createError.BadRequest('Answer body must not be empty')) 848 | 849 | const answer = await Answer.findById(answerId).populate({ path: 'author', select: 'role' }) 850 | 851 | if (!answer.author) { 852 | answer.author = { 853 | role: 1 854 | } 855 | } 856 | if (req.payload.id === answer.author._id || req.payload.role < answer.author.role) { 857 | return next(createError.Unauthorized('Action not allowed')) 858 | } 859 | 860 | if (req.files.length && answer.attach && answer.attach.length) { 861 | const files = answer.attach.reduce((array, item) => { 862 | if (item.thumb) { 863 | return [ 864 | ...array, 865 | path.join(__dirname, '..', '..', '..', 'public', 'forum', path.basename(item.file)), 866 | path.join(__dirname, '..', '..', '..', 'public', 'forum', 'thumbnails', path.basename(item.thumb)) 867 | ] 868 | } 869 | 870 | return [ 871 | ...array, 872 | path.join(__dirname, '..', '..', '..', 'public', 'forum', path.basename(item.file)) 873 | ] 874 | }, []) 875 | 876 | deleteFiles(files, (err) => { 877 | if (err) console.error(err) 878 | }) 879 | } 880 | 881 | let files = answer.attach 882 | if (req.files.length) { 883 | files = [] 884 | await Promise.all(req.files.map(async (item) => { 885 | if (videoTypes.find(i => i === item.mimetype)) { 886 | const thumbFilename = item.filename.replace(path.extname(item.filename), '.jpg') 887 | 888 | await createThumb(item.path, 'forum', thumbFilename) 889 | 890 | files.push({ 891 | file: `/forum/${item.filename}`, 892 | thumb: `/forum/thumbnails/${thumbFilename}`, 893 | type: item.mimetype, 894 | size: item.size 895 | }) 896 | } else { 897 | files.push({ 898 | file: `/forum/${item.filename}`, 899 | thumb: null, 900 | type: item.mimetype, 901 | size: item.size 902 | }) 903 | } 904 | })) 905 | } 906 | 907 | await Answer.updateOne({ _id: Types.ObjectId(answerId) }, { 908 | body: body.substring(0, 1000), 909 | edited: { 910 | createdAt: new Date().toISOString() 911 | }, 912 | attach: files 913 | }) 914 | 915 | const populate = [{ 916 | path: 'author', 917 | select: '_id name displayName onlineAt picture role ban' 918 | }, { 919 | path: 'likes', 920 | select: '_id name displayName picture' 921 | }] 922 | const editedAnswer = await Answer.findById(answerId).populate(populate) 923 | 924 | res.json(editedAnswer) 925 | 926 | req.io.to('thread:' + answer.threadId).emit('answerEdited', editedAnswer) 927 | }) 928 | } catch(err) { 929 | next(createError.InternalServerError(err)) 930 | } 931 | } 932 | 933 | module.exports.likeAnswer = async (req, res, next) => { 934 | try { 935 | const { answerId } = req.body 936 | 937 | if (!answerId) return next(createError.BadRequest('answerId must not be empty')) 938 | 939 | const answer = await Answer.findById(answerId) 940 | 941 | if (answer.likes.find(like => like.toString() === req.payload.id)) { 942 | answer.likes = answer.likes.filter(like => like.toString() !== req.payload.id) // unlike 943 | } else { 944 | answer.likes.push(req.payload.id) // like 945 | } 946 | await answer.save() 947 | 948 | const populate = [{ 949 | path: 'author', 950 | select: '_id name displayName onlineAt picture role ban' 951 | }, { 952 | path: 'likes', 953 | select: '_id name displayName picture' 954 | }] 955 | const likedAnswer = await Answer.findById(answerId).populate(populate) 956 | 957 | res.json(likedAnswer) 958 | 959 | req.io.to('thread:' + answer.threadId).emit('answerLiked', likedAnswer) 960 | } catch(err) { 961 | next(createError.InternalServerError(err)) 962 | } 963 | } 964 | -------------------------------------------------------------------------------- /src/modules/controllers/generalController.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const { Types } = require('mongoose'); 3 | const createError = require('http-errors'); 4 | 5 | const User = require('../models/User'); 6 | const Board = require('../models/Board'); 7 | const Thread = require('../models/Thread'); 8 | const Answer = require('../models/Answer'); 9 | const Ban = require('../models/Ban'); 10 | const Report = require('../models/Report'); 11 | const File = require('../models/File'); 12 | const Comment = require('../models/Comment'); 13 | const Dialogue = require('../models/Dialogue'); 14 | const Message = require('../models/Message'); 15 | const AuthHistory = require('../models/AuthHistory'); 16 | 17 | const deleteFiles = require('../utils/deleteFiles'); 18 | 19 | module.exports.getStats = async (req, res, next) => { 20 | try { 21 | const bans = await User.find({ ban: { $ne: null } }) 22 | 23 | res.json([{ 24 | _id: 1, 25 | title: 'Users', 26 | count: await User.countDocuments() 27 | }, { 28 | _id: 2, 29 | title: 'Boards', 30 | count: await Board.countDocuments() 31 | }, { 32 | _id: 3, 33 | title: 'Threads', 34 | count: await Thread.countDocuments() 35 | }, { 36 | _id: 4, 37 | title: 'Answers', 38 | count: await Answer.countDocuments() 39 | }, { 40 | _id: 5, 41 | title: 'Bans', 42 | count: bans.length 43 | }, { 44 | _id: 6, 45 | title: 'Files', 46 | count: await File.countDocuments() 47 | }]) 48 | } catch(err) { 49 | next(createError.InternalServerError(err)) 50 | } 51 | } 52 | 53 | module.exports.getUsers = async (req, res, next) => { 54 | try { 55 | const { limit = 10, page = 1, sort } = req.query 56 | 57 | let users 58 | const select = '_id name displayName createdAt onlineAt picture karma role ban' 59 | if (sort === 'online') { 60 | const date = new Date() 61 | date.setMinutes(date.getMinutes() - 5) 62 | users = await User.paginate({ onlineAt: { $gte: date.toISOString() } }, { sort: { onlineAt: -1 }, page, limit, select }) 63 | } else if (sort === 'admin') { 64 | users = await User.paginate({ role: { $gte: 2 } }, { sort: { onlineAt: -1 }, page, limit, select }) 65 | } else if (sort === 'old') { 66 | users = await User.paginate({}, { sort: { createdAt: 1 }, page, limit, select }) 67 | } else if (sort === 'karma') { 68 | users = await User.paginate({}, { sort: { karma: -1, onlineAt: -1 }, page, limit, select }) 69 | } else { 70 | users = await User.paginate({}, { sort: { createdAt: -1 }, page, limit, select }) 71 | } 72 | 73 | res.json(users) 74 | } catch(err) { 75 | next(createError.InternalServerError(err)) 76 | } 77 | } 78 | 79 | module.exports.getAdmins = async (req, res, next) => { 80 | try { 81 | const { limit = 10, page = 1 } = req.query 82 | 83 | const select = '_id name displayName createdAt onlineAt picture role ban' 84 | const admins = await User.paginate({ role: { $gte: 2 } }, { sort: { createdAt: -1 }, page, limit, select }) 85 | 86 | res.json(admins) 87 | } catch(err) { 88 | next(createError.InternalServerError(err)) 89 | } 90 | } 91 | 92 | module.exports.getUser = async (req, res, next) => { 93 | try { 94 | const { userName } = req.query 95 | 96 | if (!userName) return next(createError.BadRequest('userName must not be empty')) 97 | 98 | const select = '_id name displayName createdAt onlineAt picture karma role ban' 99 | const populate = { 100 | path: 'ban', 101 | select: '_id admin reason body createdAt expiresAt', 102 | populate: { 103 | path: 'admin', 104 | select: '_id name displayName onlineAt picture role' 105 | } 106 | } 107 | const user = await User.findOne({ name: userName }, select).populate(populate) 108 | 109 | if (!user) return next(createError.BadRequest('User not found')) 110 | 111 | if (user.ban) { 112 | if (user.ban.expiresAt < new Date().toISOString()) { 113 | await User.updateOne({ _id: Types.ObjectId(user._id) }, { ban: null }) 114 | user.ban = null 115 | } 116 | } 117 | 118 | res.json(user) 119 | } catch(err) { 120 | next(createError.InternalServerError(err)) 121 | } 122 | } 123 | 124 | module.exports.getBans = async (req, res, next) => { 125 | try { 126 | const { limit = 10, page = 1, sort } = req.query 127 | 128 | let bans 129 | if (sort === 'all') { 130 | const populate = [{ 131 | path: 'user', 132 | select: '_id name displayName onlineAt picture role ban' 133 | }, { 134 | path: 'admin', 135 | select: '_id name displayName onlineAt picture role' 136 | }] 137 | bans = await Ban.paginate({}, { sort: { createdAt: -1 }, page, limit, populate }) 138 | } else { 139 | const select = '_id name displayName createdAt onlineAt picture role ban' 140 | const populate = { 141 | path: 'ban', 142 | select: '_id admin reason body createdAt expiresAt', 143 | populate: { 144 | path: 'admin', 145 | select: '_id name displayName onlineAt picture role' 146 | } 147 | } 148 | bans = await User.paginate({ ban: { $ne: null } }, { page, limit, select, populate }) 149 | } 150 | 151 | res.json(bans) 152 | } catch(err) { 153 | next(createError.InternalServerError(err)) 154 | } 155 | } 156 | 157 | module.exports.getUserBans = async (req, res, next) => { 158 | try { 159 | const { userId, limit = 10, page = 1 } = req.query 160 | 161 | if (!userId) return next(createError.BadRequest('userId must not be empty')) 162 | 163 | const populate = [{ 164 | path: 'user', 165 | select: '_id name displayName onlineAt picture role ban' 166 | }, { 167 | path: 'admin', 168 | select: '_id name displayName onlineAt picture role' 169 | }] 170 | const bans = await Ban.paginate({ user: userId }, { sort: { createdAt: -1 }, page, limit, populate }) 171 | 172 | res.json(bans) 173 | } catch(err) { 174 | next(createError.InternalServerError(err)) 175 | } 176 | } 177 | 178 | module.exports.getBan = async (req, res, next) => { 179 | try { 180 | const { userId } = req.query 181 | 182 | if (!userId) return next(createError.BadRequest('userId must not be empty')) 183 | 184 | const select = '_id name displayName createdAt onlineAt picture role ban' 185 | const populate = { 186 | path: 'ban', 187 | select: '_id admin reason body createdAt expiresAt', 188 | populate: { 189 | path: 'admin', 190 | select: '_id name displayName onlineAt picture role' 191 | } 192 | } 193 | const user = await User.findOne({ _id: Types.ObjectId(userId) }, select).populate(populate) 194 | 195 | res.json(user) 196 | } catch(err) { 197 | next(createError.InternalServerError(err)) 198 | } 199 | } 200 | 201 | module.exports.createBan = async (req, res, next) => { 202 | try { 203 | const { userId, reason, body = '', expiresAt } = req.body 204 | const moder = req.payload.role >= 2 205 | 206 | if (!moder) return next(createError.Unauthorized('Action not allowed')) 207 | if (!userId) return next(createError.BadRequest('userId must not be empty')) 208 | if (reason.trim() === '') return next(createError.BadRequest('Reason must not be empty')) 209 | if (!expiresAt) return next(createError.BadRequest('expiresAt must not be empty')) 210 | 211 | const user = await User.findById(userId).select('role') 212 | 213 | if (!user) return next(createError.BadRequest('User not found')) 214 | if (req.payload.role < user.role) return next(createError.Unauthorized('Action not allowed')) 215 | 216 | const now = new Date().toISOString() 217 | 218 | const newBan = new Ban({ 219 | user: userId, 220 | admin: req.payload.id, 221 | reason, 222 | body: body.substring(0, 100), 223 | createdAt: now, 224 | expiresAt 225 | }) 226 | 227 | const ban = await newBan.save() 228 | 229 | const diff = new Date(expiresAt) - new Date(now) 230 | const minutes = diff / 60000 231 | await User.updateOne({ _id: Types.ObjectId(userId) }, { $inc: { karma: minutes > 43799 ? -50 : -20 }, ban: ban._id }) 232 | 233 | res.json(ban) 234 | 235 | req.io.to('notification:' + userId).emit('ban', ban) 236 | } catch(err) { 237 | next(createError.InternalServerError(err)) 238 | } 239 | } 240 | 241 | module.exports.unBan = async (req, res, next) => { 242 | try { 243 | const { userId } = req.body 244 | const moder = req.payload.role >= 2 245 | 246 | if (!moder) return next(createError.Unauthorized('Action not allowed')) 247 | if (!userId) return next(createError.BadRequest('userId must not be empty')) 248 | 249 | await User.updateOne({ _id: Types.ObjectId(userId) }, { $inc: { karma: 10 }, ban: null }) 250 | 251 | res.json('User unbanned') 252 | 253 | req.io.to('banned:' + userId).emit('unban', { message: 'Unbanned' }) 254 | } catch(err) { 255 | next(createError.InternalServerError(err)) 256 | } 257 | } 258 | 259 | module.exports.deleteBan = async (req, res, next) => { 260 | try { 261 | const { banId } = req.body 262 | const moder = req.payload.role >= 2 263 | 264 | if (!moder) return next(createError.Unauthorized('Action not allowed')) 265 | if (!banId) return next(createError.BadRequest('banId must not be empty')) 266 | 267 | const ban = await Ban.findById(banId) 268 | await ban.delete() 269 | 270 | res.json('Ban successfully deleted') 271 | } catch(err) { 272 | next(createError.InternalServerError(err)) 273 | } 274 | } 275 | 276 | module.exports.getUserStats = async (req, res, next) => { 277 | try { 278 | const { userId } = req.query 279 | 280 | if (!userId) return next(createError.BadRequest('userId must not be empty')) 281 | 282 | const user = await User.findById(userId) 283 | 284 | if (!user) return next(createError.BadRequest('User not found')) 285 | 286 | const threads = await Thread.find({ author: Types.ObjectId(userId) }) 287 | const answers = await Answer.find({ author: Types.ObjectId(userId) }) 288 | const bans = await Ban.find({ user: Types.ObjectId(userId) }) 289 | const files = await File.find({ author: Types.ObjectId(userId) }) 290 | const comments = await Comment.find({ author: Types.ObjectId(userId) }) 291 | 292 | res.json({ 293 | threadsCount: threads.length, 294 | answersCount: answers.length, 295 | bansCount: bans.length, 296 | filesCount: files.length, 297 | fileCommentsCount: comments.length, 298 | karma: user.karma 299 | }) 300 | } catch(err) { 301 | next(createError.InternalServerError(err)) 302 | } 303 | } 304 | 305 | module.exports.getUserThreads = async (req, res, next) => { 306 | try { 307 | const { userId, limit = 10, page = 1 } = req.query 308 | 309 | if (!userId) return next(createError.BadRequest('userId must not be empty')) 310 | 311 | const populate = [{ 312 | path: 'author', 313 | select: '_id name displayName onlineAt picture role ban' 314 | }, { 315 | path: 'likes', 316 | select: '_id name displayName picture' 317 | }] 318 | const threads = await Thread.paginate({ author: userId }, { sort: { createdAt: -1 }, page, limit, populate }) 319 | 320 | res.json(threads) 321 | } catch(err) { 322 | next(createError.InternalServerError(err)) 323 | } 324 | } 325 | 326 | module.exports.getUserAnswers = async (req, res, next) => { 327 | try { 328 | const { userId, limit = 10, page = 1 } = req.query 329 | 330 | if (!userId) return next(createError.BadRequest('userId must not be empty')) 331 | 332 | const populate = [{ 333 | path: 'author', 334 | select: '_id name displayName onlineAt picture role ban' 335 | }, { 336 | path: 'likes', 337 | select: '_id name displayName picture' 338 | }] 339 | const answers = await Answer.paginate({ author: userId }, { sort: { createdAt: -1 }, page, limit, populate }) 340 | 341 | res.json(answers) 342 | } catch(err) { 343 | next(createError.InternalServerError(err)) 344 | } 345 | } 346 | 347 | module.exports.getAuthHistory = async (req, res, next) => { 348 | try { 349 | const { userId, limit = 10, page = 1 } = req.query 350 | const moder = req.payload.role >= 2 351 | 352 | if (!userId) return next(createError.BadRequest('userId must not be empty')) 353 | if (req.payload.id !== userId ) { 354 | if (!moder) { 355 | return next(createError.Unauthorized('Action not allowed')) 356 | } 357 | } 358 | 359 | const populate = { 360 | path: 'user', 361 | select: '_id name displayName onlineAt picture role ban' 362 | } 363 | const authHistory = await AuthHistory.paginate({ user: userId }, { sort: { loginAt: -1 }, page, limit, populate }) 364 | 365 | res.json(authHistory) 366 | } catch(err) { 367 | next(createError.InternalServerError(err)) 368 | } 369 | } 370 | 371 | module.exports.searchAuthHistory = async (req, res, next) => { 372 | try { 373 | const { ip, limit = 10, page = 1 } = req.query 374 | const moder = req.payload.role >= 2 375 | 376 | if (!moder) return next(createError.Unauthorized('Action not allowed')) 377 | if (!ip) return next(createError.BadRequest('ip must not be empty')) 378 | 379 | const populate = { 380 | path: 'user', 381 | select: '_id name displayName onlineAt picture role ban' 382 | } 383 | const authHistory = await AuthHistory.paginate( 384 | { $text: { $search: ip } }, 385 | { sort: { ip: -1, ua: -1, loginAt: -1 }, page, limit, populate } 386 | ) 387 | 388 | res.json(authHistory) 389 | } catch(err) { 390 | next(createError.InternalServerError(err)) 391 | } 392 | } 393 | 394 | module.exports.getReports = async (req, res, next) => { 395 | try { 396 | const { limit = 10, page = 1, sort } = req.query 397 | const moder = req.payload.role >= 2 398 | 399 | if (!moder) return next(createError.Unauthorized('Action not allowed')) 400 | 401 | const populate = { 402 | path: 'from', 403 | select: '_id name displayName onlineAt picture role ban' 404 | } 405 | const read = sort === 'read' ? { read: true } : { read: false } 406 | const reports = await Report.paginate(read, { sort: { createdAt: -1 }, page, limit, populate }) 407 | 408 | if (reports.totalDocs) { 409 | await Report.updateMany({ read: false }, { read: true }) 410 | } 411 | 412 | res.json(reports) 413 | } catch(err) { 414 | next(createError.InternalServerError(err)) 415 | } 416 | } 417 | 418 | module.exports.createReport = async (req, res, next) => { 419 | try { 420 | const { threadId, postId, body } = req.body 421 | 422 | if (!threadId) return next(createError.BadRequest('threadId must not be empty')) 423 | if (!postId) return next(createError.BadRequest('postId must not be empty')) 424 | if (body.trim() === '') return next(createError.BadRequest('Report body must not be empty')) 425 | 426 | const reportExist = await Report.find({ postId: Types.ObjectId(postId) }) 427 | if (reportExist.length) return next(createError.BadRequest('Report to the post already has')) 428 | 429 | const thread = await Thread.findById(threadId) 430 | 431 | const newReport = new Report({ 432 | from: req.payload.id, 433 | threadId, 434 | postId, 435 | title: thread.title, 436 | body: body.substring(0, 1000), 437 | createdAt: new Date().toISOString(), 438 | read: false 439 | }) 440 | const report = await newReport.save() 441 | 442 | const populate = { 443 | path: 'from', 444 | select: '_id name displayName onlineAt picture role ban' 445 | } 446 | const populatedReport = await Report.findById(report._id).populate(populate) 447 | 448 | res.json(populatedReport) 449 | 450 | req.io.to('adminNotification').emit('newAdminNotification', { type: 'report' }) 451 | } catch(err) { 452 | next(createError.InternalServerError(err)) 453 | } 454 | } 455 | 456 | module.exports.deleteReports = async (req, res, next) => { 457 | try { 458 | const moder = req.payload.role >= 2 459 | 460 | if (!moder) return next(createError.Unauthorized('Action not allowed')) 461 | 462 | await Report.deleteMany({ read: true }) 463 | 464 | res.json({ message: 'Reports successfully deleted' }) 465 | } catch(err) { 466 | next(createError.InternalServerError(err)) 467 | } 468 | } 469 | 470 | module.exports.search = async (req, res, next) => { 471 | try { 472 | const { limit = 10, page = 1, query, type } = req.query 473 | 474 | if (!query) return next(createError.BadRequest('query must not be empty')) 475 | 476 | const populate = [{ 477 | path: 'author', 478 | select: '_id name displayName onlineAt picture role ban' 479 | }, { 480 | path: 'likes', 481 | select: '_id name displayName picture' 482 | }] 483 | let results 484 | if (type === 'answers') { 485 | results = await Answer.paginate({ $text: { $search: query } }, { sort: { createdAt: -1 }, page, limit, populate }) 486 | } else if (type === 'users') { 487 | const select = '_id name displayName createdAt onlineAt picture role ban' 488 | results = await User.paginate({ $text: { $search: query } }, { sort: { onlineAt: -1 }, page, limit, select }) 489 | } else if (type === 'boards') { 490 | results = await Board.paginate({ $text: { $search: query } }, { sort: { newestAnswer: -1 }, page, limit }) 491 | } else { 492 | results = await Thread.paginate({ $text: { $search: query } }, { sort: { createdAt: -1 }, page, limit, populate }) 493 | } 494 | 495 | res.json(results) 496 | } catch(err) { 497 | next(createError.InternalServerError(err)) 498 | } 499 | } 500 | 501 | module.exports.editRole = async (req, res, next) => { 502 | try { 503 | const { userId, role = 1 } = req.body 504 | const admin = req.payload.role === 3 505 | 506 | if (!admin) return next(createError.Unauthorized('Action not allowed')) 507 | if (!role || !Number.isInteger(role) || role < 1) return next(createError.BadRequest('Role must be number')) 508 | if (!role > 2) return next(createError.BadRequest('Max role number: 2')) 509 | if (!userId) return next(createError.BadRequest('userId must not be empty')) 510 | 511 | await User.updateOne({ _id: Types.ObjectId(userId) }, { role }) 512 | 513 | res.json({ message: 'User role updated' }) 514 | } catch(err) { 515 | next(createError.InternalServerError(err)) 516 | } 517 | } 518 | 519 | module.exports.deleteUser = async (req, res, next) => { 520 | try { 521 | const { userId } = req.body 522 | const admin = req.payload.role === 3 523 | 524 | if (!admin) return next(createError.Unauthorized('Action not allowed')) 525 | if (!userId) return next(createError.BadRequest('userId must not be empty')) 526 | 527 | const user = await User.findById(userId) 528 | 529 | await Ban.deleteMany({ user: userId }) 530 | 531 | const dialogues = await Dialogue.find({ 532 | $or: [{ 533 | to: Types.ObjectId(userId) 534 | }, { 535 | from: Types.ObjectId(userId) 536 | }] 537 | }) 538 | await Promise.all(dialogues.map(async (item) => { 539 | const dialogue = await Dialogue.findById(item._id) 540 | await dialogue.delete() 541 | })) 542 | 543 | const messages = await Message.find({ 544 | $or: [{ 545 | to: Types.ObjectId(userId) 546 | }, { 547 | from: Types.ObjectId(userId) 548 | }] 549 | }) 550 | await Promise.all(messages.map(async (item) => { 551 | const message = await Message.findById(item._id) 552 | 553 | if (message.file && message.file.length) { 554 | const files = message.file.reduce((array, item) => { 555 | if (item.thumb) { 556 | return [ 557 | ...array, 558 | path.join(__dirname, '..', '..', '..', 'public', 'message', path.basename(item.file)), 559 | path.join(__dirname, '..', '..', '..', 'public', 'message', 'thumbnails', path.basename(item.thumb)) 560 | ] 561 | } 562 | 563 | return [ 564 | ...array, 565 | path.join(__dirname, '..', '..', '..', 'public', 'message', path.basename(item.file)) 566 | ] 567 | }, []) 568 | 569 | deleteFiles(files, (err) => { 570 | if (err) console.error(err) 571 | }) 572 | } 573 | 574 | await messages.delete() 575 | })) 576 | 577 | await user.delete() 578 | 579 | res.json({ message: 'User successfully deleted' }) 580 | } catch(err) { 581 | next(createError.InternalServerError(err)) 582 | } 583 | } 584 | -------------------------------------------------------------------------------- /src/modules/controllers/messagesController.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const { Types } = require('mongoose'); 3 | const createError = require('http-errors'); 4 | const multer = require('multer'); 5 | 6 | const User = require('../models/User'); 7 | const Dialogue = require('../models/Dialogue'); 8 | const Message = require('../models/Message'); 9 | 10 | const deleteFiles = require('../utils//deleteFiles'); 11 | const { checkFileExec, videoTypes } = require('../utils/checkFileExec'); 12 | const storage = require('../utils/storage'); 13 | const createThumb = require('../utils/createThumbnail'); 14 | 15 | const upload = multer({ 16 | storage: storage('message', 'file'), 17 | fileFilter: (req, file, callback) => checkFileExec(file, callback), 18 | limits: { fields: 1, fileSize: 1048576 * 24 } // 24Mb 19 | }).array('file', 4) // 24 * 4 = 96Mb 20 | 21 | module.exports.getDialogues = async (req, res, next) => { 22 | try { 23 | const { limit = 10, page = 1 } = req.query 24 | 25 | const populate = [{ 26 | path: 'from', 27 | select: '_id name displayName onlineAt picture role ban' 28 | }, { 29 | path: 'to', 30 | select: '_id name displayName onlineAt picture role ban' 31 | }, { 32 | path: 'lastMessage' 33 | }] 34 | const dialogues = await Dialogue.paginate({ 35 | $or: [{ 36 | to: Types.ObjectId(req.payload.id) 37 | }, { 38 | from: Types.ObjectId(req.payload.id) 39 | }] 40 | }, { 41 | sort: { updatedAt: -1 }, 42 | page, 43 | limit, 44 | populate 45 | }) 46 | 47 | if (dialogues.length) { 48 | if (req.payload.id !== dialogues[0].to) return next(createError.Unauthorized('Action not allowed')) 49 | } 50 | 51 | res.json(dialogues) 52 | } catch(err) { 53 | next(createError.InternalServerError(err)) 54 | } 55 | } 56 | 57 | module.exports.getDialogue = async (req, res, next) => { 58 | try { 59 | const { userName } = req.query 60 | 61 | if (!userName) return next(createError.BadRequest('userName must not be empty')) 62 | 63 | const user = await User.findOne({ name: userName }) 64 | 65 | if (!user) return next(createError.BadRequest('User not found')) 66 | 67 | const dialogue = await Dialogue.findOne({ 68 | $or: [{ 69 | to: Types.ObjectId(user._id), 70 | from: Types.ObjectId(req.payload.id) 71 | }, { 72 | to: Types.ObjectId(req.payload.id), 73 | from: Types.ObjectId(user._id) 74 | }] 75 | }) 76 | 77 | res.json(dialogue) 78 | } catch(err) { 79 | next(createError.InternalServerError(err)) 80 | } 81 | } 82 | 83 | module.exports.getMessages = async (req, res, next) => { 84 | try { 85 | const { dialogueId, limit = 10, page = 1 } = req.query 86 | 87 | if (!dialogueId) return next(createError.BadRequest('dialogueId must not be empty')) 88 | 89 | const dialogue = await Dialogue.findById(dialogueId) 90 | 91 | if (dialogue.from.toString() !== req.payload.id) { 92 | if (dialogue.to.toString() !== req.payload.id) { 93 | return next(createError.Unauthorized('Action not allowed')) 94 | } 95 | } 96 | 97 | const populate = [{ 98 | path: 'from', 99 | select: '_id name displayName onlineAt picture role ban' 100 | }, { 101 | path: 'to', 102 | select: '_id name displayName onlineAt picture role ban' 103 | }] 104 | const messages = await Message.paginate({ dialogueId }, { sort: { createdAt: -1 }, page, limit, populate }) 105 | 106 | const groups = messages.docs.reduce((groups, item) => { 107 | const date = new Date(item.createdAt).toISOString().split('T')[0] 108 | if (!groups[date]) { 109 | groups[date] = [] 110 | } 111 | groups[date].push(item) 112 | return groups 113 | }, {}) 114 | 115 | const grouped = Object.keys(groups).map((date, index) => { 116 | const msgList = groups[date].reverse() 117 | return { 118 | groupId: date, 119 | date, 120 | messages: msgList 121 | } 122 | }) 123 | 124 | messages.docs = grouped 125 | 126 | res.json(messages) 127 | } catch(err) { 128 | next(createError.InternalServerError(err)) 129 | } 130 | } 131 | 132 | module.exports.createMessage = async (req, res, next) => { 133 | try { 134 | upload(req, res, async (err) => { 135 | if (err) return next(createError.BadRequest(err.message)) 136 | 137 | const { dialogueId, body = '', to } = JSON.parse(req.body.postData) 138 | 139 | if (!to) return next(createError.BadRequest('"to" must not be empty')) 140 | 141 | let files = null 142 | if (req.files.length) { 143 | files = [] 144 | await Promise.all(req.files.map(async (item) => { 145 | if (videoTypes.find(i => i === item.mimetype)) { 146 | const thumbFilename = item.filename.replace(path.extname(item.filename), '.jpg') 147 | 148 | await createThumb(item.path, 'message', thumbFilename) 149 | 150 | files.push({ 151 | file: `/message/${item.filename}`, 152 | thumb: `/message/thumbnails/${thumbFilename}`, 153 | type: item.mimetype, 154 | size: item.size 155 | }) 156 | } else { 157 | files.push({ 158 | file: `/message/${item.filename}`, 159 | thumb: null, 160 | type: item.mimetype, 161 | size: item.size 162 | }) 163 | } 164 | })) 165 | } 166 | 167 | let isNewDialogue = false 168 | let dId 169 | const dialogueExist = await Dialogue.findOne({ _id: Types.ObjectId(dialogueId) }) 170 | 171 | if (!dialogueExist) { 172 | isNewDialogue = true 173 | 174 | const newDialogue = new Dialogue({ 175 | from: req.payload.id, 176 | to 177 | }) 178 | 179 | const dialogue = await newDialogue.save() 180 | dId = dialogue._id 181 | 182 | req.io.emit('joinToDialogue', dialogue) 183 | } else { 184 | dId = dialogueExist._id 185 | } 186 | 187 | const now = new Date().toISOString() 188 | 189 | const newMessage = new Message({ 190 | dialogueId: dId, 191 | body: body.substring(0, 1000), 192 | createdAt: now, 193 | from: req.payload.id, 194 | to, 195 | file: files, 196 | read: false 197 | }) 198 | 199 | const message = await newMessage.save() 200 | await Dialogue.updateOne({ _id: Types.ObjectId(dId) }, { lastMessage: message._id, updatedAt: now }) 201 | 202 | res.json(message) 203 | 204 | const populate = [{ 205 | path: 'from', 206 | select: '_id name displayName onlineAt picture role ban' 207 | }, { 208 | path: 'to', 209 | select: '_id name displayName onlineAt picture role ban' 210 | }] 211 | const populatedMessage = await Message.findById(message._id).populate(populate) 212 | 213 | req.io.to('pm:' + dId).emit('newMessage', populatedMessage) 214 | 215 | const populatedDialogue = [{ 216 | path: 'from', 217 | select: '_id name displayName onlineAt picture role ban' 218 | }, { 219 | path: 'to', 220 | select: '_id name displayName onlineAt picture role ban' 221 | }, { 222 | path: 'lastMessage' 223 | }] 224 | const newOrUpdatedDialogue = await Dialogue.findById(dId).populate(populatedDialogue) 225 | 226 | if (isNewDialogue) { 227 | req.io.to('dialogues:' + to).emit('newDialogue', newOrUpdatedDialogue) 228 | } else { 229 | req.io.to('dialogues:' + to).emit('updateDialogue', newOrUpdatedDialogue) 230 | } 231 | 232 | const dialogues = await Dialogue.find({ 233 | $or: [{ 234 | to: Types.ObjectId(req.payload.id) 235 | }, { 236 | from: Types.ObjectId(req.payload.id) 237 | }] 238 | }).populate({ path: 'lastMessage' }) 239 | 240 | const noRead = dialogues.filter(item => item.lastMessage && !item.lastMessage.read && item.lastMessage.to.toString() === to) 241 | 242 | req.io.to('pmCount:' + to).emit('messagesCount', { count: noRead.length }) 243 | }) 244 | } catch(err) { 245 | next(createError.InternalServerError(err)) 246 | } 247 | } 248 | 249 | module.exports.deleteMessage = async (req, res, next) => { 250 | try { 251 | const { dialogueId, groupId, messageId } = req.body 252 | 253 | if (!dialogueId) return next(createError.BadRequest('dialogueId must not be empty')) 254 | if (!groupId) return next(createError.BadRequest('groupId must not be empty')) 255 | if (!messageId) return next(createError.BadRequest('messageId must not be empty')) 256 | 257 | const message = await Message.findById(messageId) 258 | 259 | if (message.file && message.file.length) { 260 | const files = message.file.reduce((array, item) => { 261 | if (item.thumb) { 262 | return [ 263 | ...array, 264 | path.join(__dirname, '..', '..', '..', 'public', 'message', path.basename(item.file)), 265 | path.join(__dirname, '..', '..', '..', 'public', 'message', 'thumbnails', path.basename(item.thumb)) 266 | ] 267 | } 268 | 269 | return [ 270 | ...array, 271 | path.join(__dirname, '..', '..', '..', 'public', 'message', path.basename(item.file)) 272 | ] 273 | }, []) 274 | 275 | deleteFiles(files, (err) => { 276 | if (err) console.error(err) 277 | }) 278 | } 279 | 280 | await message.delete() 281 | 282 | const messages = await Message.find({ dialogueId: Types.ObjectId(dialogueId) }).sort({ createdAt: -1 }) 283 | if (messages.length) { 284 | await Dialogue.updateOne({ _id: Types.ObjectId(dialogueId) }, { lastMessage: messages[0]._id, updatedAt: messages[0].createdAt }) 285 | } else { 286 | const dialogue = await Dialogue.findById(dialogueId) 287 | await dialogue.delete() 288 | } 289 | 290 | res.json({ message: 'Message successfully deleted' }) 291 | 292 | req.io.to('pm:' + dialogueId).emit('messageDeleted', { id: messageId, groupId }) 293 | } catch(err) { 294 | next(createError.InternalServerError(err)) 295 | } 296 | } 297 | -------------------------------------------------------------------------------- /src/modules/controllers/profileController.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = require('path'); 3 | const { Types } = require('mongoose'); 4 | const createError = require('http-errors'); 5 | const multer = require('multer'); 6 | const sharp = require('sharp'); 7 | const bcrypt = require('bcrypt'); 8 | 9 | const User = require('../models/User'); 10 | const Notification = require('../models/Notification'); 11 | 12 | const storage = require('../utils/storage'); 13 | 14 | const checkFileType = (file, callback) => { 15 | const filetypes = /jpeg|jpg|png|gif/ 16 | const extname = filetypes.test(path.extname(file.originalname).toLowerCase()) 17 | const mimetype = filetypes.test(file.mimetype) 18 | 19 | if (mimetype && extname) return callback(null, true) 20 | else callback('It\'s not image', false) 21 | } 22 | 23 | const upload = multer({ 24 | storage: storage('users', 'picture'), 25 | fileFilter: (req, file, callback) => checkFileType(file, callback), 26 | limits: { fileSize: 1048576 * 8 } // 8Mb 27 | }).single('picture') 28 | 29 | module.exports.getProfile = async (req, res, next) => { 30 | try { 31 | const select = '_id name displayName email createdAt onlineAt picture karma role ban' 32 | const user = await User.findOne({ _id: Types.ObjectId(req.payload.id) }, select) 33 | 34 | if (!user) return next(createError.BadRequest('User not found')) 35 | 36 | res.json(user) 37 | } catch(err) { 38 | next(createError.InternalServerError(err)) 39 | } 40 | } 41 | 42 | module.exports.uploadUserPicture = (req, res, next) => { 43 | try { 44 | upload(req, res, (err) => { 45 | if (err) return next(createError.BadRequest(err.message)) 46 | 47 | if (req.file) { 48 | sharp(req.file.path) 49 | .resize(300, 300) 50 | .toBuffer() 51 | .then(async data => { 52 | fs.writeFileSync(req.file.path, data) 53 | const picture = { picture: `/users/${req.file.filename}` } 54 | 55 | await User.updateOne({ _id: Types.ObjectId(req.payload.id) }, picture) 56 | 57 | res.json(picture) 58 | }) 59 | .catch(err => { 60 | next(createError.InternalServerError()) 61 | }) 62 | } else { 63 | next(createError.BadRequest()) 64 | } 65 | }) 66 | } catch(err) { 67 | next(err) 68 | } 69 | } 70 | 71 | module.exports.editPassword = async (req, res, next) => { 72 | try { 73 | const { password, newPassword } = req.body 74 | 75 | if (!password) return next(createError.BadRequest('Password must not be empty')) 76 | if (!newPassword) return next(createError.BadRequest('newPassword must not be empty')) 77 | 78 | const user = await User.findOne({ _id: Types.ObjectId(req.payload.id) }) 79 | 80 | if (!user) return next(createError.BadRequest('User not found')) 81 | 82 | const isMatch = await user.isValidPassword(password) 83 | if (!isMatch) return next(createError.BadRequest('Password not valid')) 84 | 85 | const salt = await bcrypt.genSalt(10) 86 | const hashedPassword = await bcrypt.hash(newPassword, salt) 87 | 88 | await User.updateOne({ _id: Types.ObjectId(req.payload.id) }, { password: hashedPassword }) 89 | 90 | res.json({ message: 'Password successfully changed' }) 91 | } catch (err) { 92 | next(createError.InternalServerError(err)) 93 | } 94 | } 95 | 96 | module.exports.setOnline = async (req, res, next) => { 97 | try { 98 | await User.updateOne({ _id: Types.ObjectId(req.payload.id) }, { onlineAt: new Date().toISOString() }) 99 | 100 | res.json({ success: true }) 101 | } catch(err) { 102 | next(createError.InternalServerError(err)) 103 | } 104 | } 105 | 106 | module.exports.getNotifications = async (req, res, next) => { 107 | try { 108 | const { limit = 10, page = 1, sort } = req.query 109 | 110 | let createdAt 111 | if (sort === 'old') { 112 | createdAt = 1 113 | } else { 114 | createdAt = -1 115 | } 116 | 117 | const populate = [{ 118 | path: 'to', 119 | select: '_id name displayName onlineAt picture role ban' 120 | }, { 121 | path: 'from', 122 | select: '_id name displayName onlineAt picture role ban' 123 | }] 124 | const notifications = await Notification.paginate({ to: req.payload.id }, { sort: { createdAt }, page, limit, populate }) 125 | 126 | if (notifications.totalDocs) { 127 | await Notification.updateMany({ to: req.payload.id, read: false }, { read: true }) 128 | } 129 | 130 | res.json(notifications) 131 | } catch(err) { 132 | next(createError.InternalServerError(err)) 133 | } 134 | } 135 | 136 | module.exports.deleteNotifications = async (req, res, next) => { 137 | try { 138 | await Notification.deleteMany({ to: req.payload.id, read: true }) 139 | 140 | res.json({ message: 'Notifications successfully deleted' }) 141 | } catch(err) { 142 | next(createError.InternalServerError(err)) 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /src/modules/controllers/uploadsController.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const { Types } = require('mongoose'); 3 | const createError = require('http-errors'); 4 | const multer = require('multer'); 5 | 6 | const User = require('../models/User'); 7 | const Folder = require('../models/Folder'); 8 | const File = require('../models/File'); 9 | const Comment = require('../models/Comment'); 10 | const Notification = require('../models/Notification'); 11 | 12 | const deleteFiles = require('../utils//deleteFiles'); 13 | const { checkFileExec, videoTypes } = require('../utils/checkFileExec'); 14 | const storage = require('../utils/storage'); 15 | const createThumb = require('../utils/createThumbnail'); 16 | 17 | const upload = multer({ 18 | storage: storage('uploads', 'file'), 19 | fileFilter: (req, file, callback) => checkFileExec(file, callback), 20 | limits: { fields: 1, fileSize: 1048576 * 80 } // 80Mb 21 | }).single('file') 22 | 23 | module.exports.getFolders = async (req, res, next) => { 24 | try { 25 | const { limit = 10, page = 1, pagination = true } = req.query 26 | 27 | const folders = await Folder.paginate({}, { sort: { position: -1 }, page, limit, pagination: JSON.parse(pagination) }) 28 | 29 | res.json(folders) 30 | } catch(err) { 31 | next(createError.InternalServerError(err)) 32 | } 33 | } 34 | 35 | module.exports.getFolder = async (req, res, next) => { 36 | try { 37 | const { name, folderId } = req.query 38 | 39 | let folder 40 | if (name) { 41 | folder = await Folder.findOne({ name }) 42 | } else if (folderId) { 43 | folder = await Folder.findById(folderId) 44 | } else { 45 | return next(createError.BadRequest('Folder name or folderId must not be empty')) 46 | } 47 | 48 | res.json(folder) 49 | } catch(err) { 50 | next(createError.InternalServerError(err)) 51 | } 52 | } 53 | 54 | module.exports.createFolder = async (req, res, next) => { 55 | try { 56 | const { name, title, body, position } = req.body 57 | const admin = req.payload.role === 3 58 | 59 | if (!admin) return next(createError.Unauthorized('Action not allowed')) 60 | if (name.trim() === '') return next(createError.BadRequest('Folder name must not be empty')) 61 | if (title.trim() === '') return next(createError.BadRequest('Folder title must not be empty')) 62 | if (!position || !Number.isInteger(position) || position < 0) return next(createError.BadRequest('Position must be number')) 63 | 64 | const nameUrl = name.trim().toLowerCase().substring(0, 12).replace(/[^a-z0-9-_]/g, '') 65 | 66 | const nameExist = await Folder.findOne({ name: nameUrl }) 67 | if (nameExist) return next(createError.Conflict('Folder with this short name is already been created')) 68 | 69 | const newFolder = new Folder({ 70 | name: nameUrl, 71 | title: title.trim().substring(0, 21), 72 | body: body.substring(0, 100), 73 | position, 74 | createdAt: new Date().toISOString(), 75 | filesCount: 0 76 | }) 77 | 78 | const folder = await newFolder.save() 79 | 80 | res.json(folder) 81 | } catch(err) { 82 | next(createError.InternalServerError(err)) 83 | } 84 | } 85 | 86 | module.exports.deleteFolder = async (req, res, next) => { 87 | try { 88 | const { folderId } = req.body 89 | const admin = req.payload.role === 3 90 | 91 | if (!admin) return next(createError.Unauthorized('Action not allowed')) 92 | if (!folderId) return next(createError.BadRequest('folderId must not be empty')) 93 | 94 | const folder = await Folder.findById(folderId) 95 | await folder.delete() 96 | 97 | res.json({ message: 'Folder successfully deleted' }) 98 | } catch(err) { 99 | next(createError.InternalServerError(err)) 100 | } 101 | } 102 | 103 | module.exports.editFolder = async (req, res, next) => { 104 | try { 105 | const { folderId, name, title, body, position } = req.body 106 | const admin = req.payload.role === 3 107 | 108 | if (!admin) return next(createError.Unauthorized('Action not allowed')) 109 | if (!folderId) return next(createError.BadRequest('folderId must not be empty')) 110 | if (name.trim() === '') return next(createError.BadRequest('Folder name must not be empty')) 111 | if (title.trim() === '') return next(createError.BadRequest('Folder title must not be empty')) 112 | if (!position || !Number.isInteger(position) || position < 0) return next(createError.BadRequest('Position must be number')) 113 | 114 | const nameUrl = name.trim().toLowerCase().substring(0, 12).replace(/[^a-z0-9-_]/g, '') 115 | 116 | const nameExist = await Folder.findOne({ name: nameUrl }) 117 | if (nameExist) return next(createError.Conflict('Folder with this short name is already been created')) 118 | 119 | await Folder.updateOne({ _id: Types.ObjectId(folderId) }, { 120 | name: nameUrl, 121 | title: title.trim().substring(0, 21), 122 | body: body.substring(0, 100), 123 | position 124 | }) 125 | const folder = await Folder.findById(folderId) 126 | 127 | res.json(folder) 128 | } catch(err) { 129 | next(createError.InternalServerError(err)) 130 | } 131 | } 132 | 133 | module.exports.getAdminAllFiles = async (req, res, next) => { 134 | try { 135 | const { limit = 10, page = 1, sort } = req.query 136 | const moder = req.payload.role >= 2 137 | 138 | if (!moder) return next(createError.Unauthorized('Action not allowed')) 139 | 140 | const populate = [{ 141 | path: 'author', 142 | select: '_id name displayName onlineAt picture role ban' 143 | }, { 144 | path: 'likes', 145 | select: '_id name displayName picture' 146 | }] 147 | const moderated = sort === 'moderated' ? { moderated: true } : { moderated: false } 148 | const files = await File.paginate(moderated, { sort: { createdAt: -1 }, page, limit, populate }) 149 | 150 | res.json(files) 151 | } catch(err) { 152 | next(createError.InternalServerError(err)) 153 | } 154 | } 155 | 156 | module.exports.getAllFiles = async (req, res, next) => { 157 | try { 158 | const { limit = 10, page = 1 } = req.query 159 | 160 | const populate = [{ 161 | path: 'author', 162 | select: '_id name displayName onlineAt picture role ban' 163 | }, { 164 | path: 'likes', 165 | select: '_id name displayName picture' 166 | }] 167 | const files = await File.paginate({ moderated: true }, { sort: { createdAt: -1 }, page, limit, populate }) 168 | 169 | res.json(files) 170 | } catch(err) { 171 | next(createError.InternalServerError(err)) 172 | } 173 | } 174 | 175 | module.exports.getFiles = async (req, res, next) => { 176 | try { 177 | const { folderId, limit = 10, page = 1 } = req.query 178 | 179 | if (!folderId) return next(createError.BadRequest('folderId must not be empty')) 180 | 181 | const populate = [{ 182 | path: 'author', 183 | select: '_id name displayName onlineAt picture role ban' 184 | }, { 185 | path: 'likes', 186 | select: '_id name displayName picture' 187 | }] 188 | const files = await File.paginate({ folderId, moderated: true }, { sort: { createdAt: -1 }, page, limit, populate }) 189 | 190 | res.json(files) 191 | } catch(err) { 192 | next(createError.InternalServerError(err)) 193 | } 194 | } 195 | 196 | module.exports.getFile = async (req, res, next) => { 197 | try { 198 | const { fileId } = req.query 199 | 200 | if (!fileId) return next(createError.BadRequest('fileId must not be empty')) 201 | 202 | const populate = [{ 203 | path: 'author', 204 | select: '_id name displayName onlineAt picture role ban' 205 | }, { 206 | path: 'likes', 207 | select: '_id name displayName picture' 208 | }] 209 | const file = await File.findById(fileId).populate(populate) 210 | 211 | const folder = await Folder.findById(file.folderId).select('_id name title') 212 | 213 | if (!file.moderated) return res.json({ folder, message: 'File on moderation' }) 214 | 215 | res.json({ folder, file }) 216 | } catch(err) { 217 | next(createError.InternalServerError(err)) 218 | } 219 | } 220 | 221 | module.exports.createFile = async (req, res, next) => { 222 | try { 223 | upload(req, res, async (err) => { 224 | if (err) return next(createError.BadRequest(err.message)) 225 | 226 | const { folderId, title, body } = JSON.parse(req.body.postData) 227 | 228 | if (!folderId) return next(createError.BadRequest('folderId must not be empty')) 229 | if (title.trim() === '') return next(createError.BadRequest('File title must not be empty')) 230 | if (body.trim() === '') return next(createError.BadRequest('File body must not be empty')) 231 | 232 | const now = new Date().toISOString() 233 | 234 | let thumb = null 235 | if (videoTypes.find(i => i === req.file.mimetype)) { 236 | const thumbFilename = req.file.filename.replace(path.extname(req.file.filename), '.jpg') 237 | 238 | await createThumb(req.file.path, 'uploads', thumbFilename) 239 | 240 | thumb = `/uploads/thumbnails/${thumbFilename}` 241 | } 242 | 243 | const newFile = new File({ 244 | folderId, 245 | title: title.trim().substring(0, 100), 246 | body: body.substring(0, 1000), 247 | createdAt: now, 248 | author: req.payload.id, 249 | file: { 250 | url: `/uploads/${req.file.filename}`, 251 | thumb, 252 | type: req.file.mimetype, 253 | size: req.file.size 254 | }, 255 | downloads: 0, 256 | commentsCount: 0, 257 | moderated: false 258 | }) 259 | 260 | const file = await newFile.save() 261 | 262 | await Folder.updateOne({ _id: Types.ObjectId(file.folderId) }, { $inc: { filesCount: 1 } }) 263 | 264 | res.json(file) 265 | 266 | req.io.to('adminNotification').emit('newAdminNotification', { type: 'file' }) 267 | }) 268 | } catch(err) { 269 | next(createError.InternalServerError(err)) 270 | } 271 | } 272 | 273 | module.exports.deleteFile = async (req, res, next) => { 274 | try { 275 | const { fileId } = req.body 276 | const moder = req.payload.role >= 2 277 | 278 | if (!moder) return next(createError.Unauthorized('Action not allowed')) 279 | if (!fileId) return next(createError.BadRequest('fileId must not be empty')) 280 | 281 | const file = await File.findById(fileId).populate({ path: 'author', select: 'role' }) 282 | 283 | if (!file.author) { 284 | file.author = { 285 | role: 1 286 | } 287 | } 288 | if (req.payload.role < file.author.role) return next(createError.Unauthorized('Action not allowed')) 289 | 290 | const deleteArray = [] 291 | deleteArray.push(path.join(__dirname, '..', '..', '..', 'public', 'uploads', path.basename(file.file.url))) 292 | if (file.file.thumb) { 293 | deleteArray.push(path.join(__dirname, '..', '..', '..', 'public', 'uploads', 'thumbnails', path.basename(file.file.thumb))) 294 | } 295 | 296 | deleteFiles(deleteArray, (err) => { 297 | if (err) console.error(err) 298 | }) 299 | 300 | await file.delete() 301 | 302 | await Comment.deleteMany({ fileId }) 303 | await Folder.updateOne({ _id: Types.ObjectId(file.folderId) }, { $inc: { filesCount: -1 } }) 304 | 305 | res.json({ message: 'File successfully deleted' }) 306 | 307 | req.io.to('file:' + fileId).emit('fileDeleted', { id: fileId }) 308 | } catch(err) { 309 | next(createError.InternalServerError(err)) 310 | } 311 | } 312 | 313 | module.exports.editFile = async (req, res, next) => { 314 | try { 315 | const { fileId, title, body } = req.body 316 | 317 | if (!fileId) return next(createError.BadRequest('fileId must not be empty')) 318 | if (title.trim() === '') return next(createError.BadRequest('File title must not be empty')) 319 | if (body.trim() === '') return next(createError.BadRequest('File body must not be empty')) 320 | 321 | const file = await File.findById(fileId).populate({ path: 'author', select: 'role' }) 322 | 323 | if (!file.author) { 324 | file.author = { 325 | role: 1 326 | } 327 | } 328 | if (req.payload.id !== file.author._id) { 329 | if (req.payload.role < file.author.role) { 330 | return next(createError.Unauthorized('Action not allowed')) 331 | } 332 | } 333 | 334 | await File.updateOne({ _id: Types.ObjectId(fileId) }, { 335 | title: title.trim().substring(0, 100), 336 | body: body.substring(0, 1000) 337 | }) 338 | 339 | const populate = [{ 340 | path: 'author', 341 | select: '_id name displayName onlineAt picture role ban' 342 | }, { 343 | path: 'likes', 344 | select: '_id name displayName picture' 345 | }] 346 | const editedFile = await File.findById(fileId).populate(populate) 347 | 348 | res.json(editedFile) 349 | 350 | req.io.to('file:' + fileId).emit('fileEdited', editedFile) 351 | } catch(err) { 352 | next(createError.InternalServerError(err)) 353 | } 354 | } 355 | 356 | module.exports.likeFile = async (req, res, next) => { 357 | try { 358 | const { fileId } = req.body 359 | 360 | if (!fileId) return next(createError.BadRequest('fileId must not be empty')) 361 | 362 | const file = await File.findById(fileId) 363 | 364 | if (file.likes.find(like => like.toString() === req.payload.id)) { 365 | file.likes = file.likes.filter(like => like.toString() !== req.payload.id) // unlike 366 | } else { 367 | file.likes.push(req.payload.id) // like 368 | } 369 | await file.save() 370 | 371 | const populate = [{ 372 | path: 'author', 373 | select: '_id name displayName onlineAt picture role ban' 374 | }, { 375 | path: 'likes', 376 | select: '_id name displayName picture' 377 | }] 378 | const likedFile = await File.findById(fileId).populate(populate) 379 | 380 | res.json(likedFile) 381 | 382 | req.io.to('file:' + fileId).emit('fileLiked', likedFile) 383 | } catch(err) { 384 | next(createError.InternalServerError(err)) 385 | } 386 | } 387 | 388 | module.exports.moderateFile = async (req, res, next) => { 389 | try { 390 | const { fileId } = req.body 391 | const moder = req.payload.role >= 2 392 | 393 | if (!moder) return next(createError.Unauthorized('Action not allowed')) 394 | if (!fileId) return next(createError.BadRequest('fileId must not be empty')) 395 | 396 | await File.updateOne({ _id: Types.ObjectId(fileId) }, { moderated: true }) 397 | 398 | const file = File.findById(fileId) 399 | 400 | await User.updateOne({ _id: Types.ObjectId(file.author) }, { $inc: { karma: 3 } }) 401 | 402 | res.json({ message: 'File successfully moderated' }) 403 | } catch(err) { 404 | next(createError.InternalServerError(err)) 405 | } 406 | } 407 | 408 | module.exports.download = async (req, res, next) => { 409 | try { 410 | const { fileId } = req.body 411 | 412 | if (!fileId) return next(createError.BadRequest('fileId must not be empty')) 413 | 414 | await File.updateOne({ _id: Types.ObjectId(fileId) }, { $inc: { downloads: 1 } }) 415 | 416 | const populate = [{ 417 | path: 'author', 418 | select: '_id name displayName onlineAt picture role ban' 419 | }, { 420 | path: 'likes', 421 | select: '_id name displayName picture' 422 | }] 423 | const file = await File.findById(fileId).populate(populate) 424 | 425 | res.json(file) 426 | 427 | req.io.to('file:' + fileId).emit('fileDownloaded', file) 428 | } catch(err) { 429 | next(createError.InternalServerError(err)) 430 | } 431 | } 432 | 433 | module.exports.getComments = async (req, res, next) => { 434 | try { 435 | const { fileId, limit = 10, page = 1, pagination = true } = req.query 436 | 437 | if (!fileId) return next(createError.BadRequest('fileId must not be empty')) 438 | 439 | const populate = [{ 440 | path: 'author', 441 | select: '_id name displayName onlineAt picture role ban' 442 | }, { 443 | path: 'likes', 444 | select: '_id name displayName picture' 445 | }] 446 | const comments = await Comment.paginate({ fileId }, { page, limit, populate, pagination: JSON.parse(pagination) }) 447 | 448 | res.json(comments) 449 | } catch(err) { 450 | next(createError.InternalServerError(err)) 451 | } 452 | } 453 | 454 | module.exports.createComment = async (req, res, next) => { 455 | try { 456 | const { fileId, commentedTo, body } = req.body 457 | 458 | if (!fileId) return next(createError.BadRequest('fileId must not be empty')) 459 | if (body.trim() === '') return next(createError.BadRequest('Comment body must not be empty')) 460 | 461 | const now = new Date().toISOString() 462 | 463 | const file = await File.findById(fileId) 464 | 465 | const newComment = new Comment({ 466 | fileId, 467 | commentedTo, 468 | body: body.substring(0, 1000), 469 | createdAt: now, 470 | author: req.payload.id 471 | }) 472 | 473 | const comment = await newComment.save() 474 | 475 | await File.updateOne({ _id: Types.ObjectId(fileId) }, { $inc: { commentsCount: 1 } }) 476 | 477 | const populate = [{ 478 | path: 'author', 479 | select: '_id name displayName onlineAt picture role ban' 480 | }, { 481 | path: 'likes', 482 | select: '_id name displayName picture' 483 | }] 484 | const populatedComment = await Comment.findById(comment._id).populate(populate) 485 | 486 | await User.updateOne({ _id: Types.ObjectId(req.payload.id) }, { 487 | $inc: { 488 | karma: populatedComment.author._id === req.payload.id ? 1 : 2 489 | } 490 | }) 491 | 492 | res.json(populatedComment) 493 | 494 | req.io.to('file:' + fileId).emit('commentCreated', populatedComment) 495 | 496 | let type = 'commentToFile' 497 | let to = file.author 498 | if (commentedTo && commentedTo !== fileId) { 499 | const commentTo = await Comment.findById(commentedTo) 500 | type = 'commentToComment' 501 | to = commentTo.author 502 | } 503 | 504 | if (!commentedTo && req.payload.id === file.author.toString()) return 505 | 506 | const newNotification = new Notification({ 507 | type, 508 | to, 509 | from: req.payload.id, 510 | pageId: fileId, 511 | title: file.title, 512 | body: body.substring(0, 1000), 513 | createdAt: new Date().toISOString(), 514 | read: false 515 | }) 516 | const notification = await newNotification.save() 517 | 518 | const populateNotification = [{ 519 | path: 'to', 520 | select: '_id name displayName onlineAt picture role ban' 521 | }, { 522 | path: 'from', 523 | select: '_id name displayName onlineAt picture role ban' 524 | }] 525 | const populatedNotification = await Notification.findById(notification._id).populate(populateNotification) 526 | 527 | req.io.to('notification:' + to).emit('newNotification', populatedNotification) 528 | } catch(err) { 529 | next(createError.InternalServerError(err)) 530 | } 531 | } 532 | 533 | module.exports.deleteComment = async (req, res, next) => { 534 | try { 535 | const { commentId } = req.body 536 | 537 | if (!commentId) return next(createError.BadRequest('commentId must not be empty')) 538 | 539 | const comment = await Comment.findById(commentId).populate({ path: 'author', select: 'role' }) 540 | 541 | if (!comment.author) { 542 | comment.author = { 543 | role: 1 544 | } 545 | } 546 | if (req.payload.id === comment.author._id || req.payload.role >= comment.author.role) { 547 | await comment.delete() 548 | 549 | await File.updateOne({ _id: Types.ObjectId(comment.fileId) }, { $inc: { commentsCount: -1 } }) 550 | 551 | res.json({ message: 'Comment successfully deleted' }) 552 | 553 | req.io.to('file:' + comment.fileId).emit('commentDeleted', { id: commentId }) 554 | } else { 555 | return next(createError.Unauthorized('Action not allowed')) 556 | } 557 | } catch(err) { 558 | next(createError.InternalServerError(err)) 559 | } 560 | } 561 | 562 | module.exports.likeComment = async (req, res, next) => { 563 | try { 564 | const { commentId } = req.body 565 | 566 | if (!commentId) return next(createError.BadRequest('commentId must not be empty')) 567 | 568 | const comment = await Comment.findById(commentId) 569 | 570 | if (comment.likes.find(like => like.toString() === req.payload.id)) { 571 | comment.likes = comment.likes.filter(like => like.toString() !== req.payload.id) // unlike 572 | } else { 573 | comment.likes.push(req.payload.id) // like 574 | } 575 | await comment.save() 576 | 577 | const populate = [{ 578 | path: 'author', 579 | select: '_id name displayName onlineAt picture role ban' 580 | }, { 581 | path: 'likes', 582 | select: '_id name displayName picture' 583 | }] 584 | const likedComment = await Comment.findById(commentId).populate(populate) 585 | 586 | res.json(likedComment) 587 | 588 | req.io.to('file:' + comment.fileId).emit('commentLiked', likeComment) 589 | } catch(err) { 590 | next(createError.InternalServerError(err)) 591 | } 592 | } 593 | -------------------------------------------------------------------------------- /src/modules/models/Answer.js: -------------------------------------------------------------------------------- 1 | const { model, Schema, Types } = require('mongoose'); 2 | const mongoosePaginate = require('mongoose-paginate-v2'); 3 | 4 | const attachSchema = new Schema({ 5 | file: String, 6 | thumb: String, 7 | type: String, 8 | size: String 9 | }) 10 | 11 | const answerSchema = new Schema({ 12 | boardId: Types.ObjectId, 13 | threadId: Types.ObjectId, 14 | answeredTo: Types.ObjectId, 15 | body: String, 16 | createdAt: Date, 17 | author: { 18 | type: Types.ObjectId, 19 | ref: 'User' 20 | }, 21 | edited: { 22 | createdAt: Date 23 | }, 24 | likes: [{ 25 | type: Types.ObjectId, 26 | ref: 'User' 27 | }], 28 | attach: [attachSchema] 29 | }) 30 | answerSchema.plugin(mongoosePaginate) 31 | answerSchema.index({ body: 'text' }) 32 | 33 | module.exports = model('Answer', answerSchema); 34 | -------------------------------------------------------------------------------- /src/modules/models/AuthHistory.js: -------------------------------------------------------------------------------- 1 | const { model, Schema, Types } = require('mongoose'); 2 | const mongoosePaginate = require('mongoose-paginate-v2'); 3 | 4 | const authHistorySchema = new Schema({ 5 | user: { 6 | type: Types.ObjectId, 7 | ref: 'User' 8 | }, 9 | loginAt: Date, 10 | ip: String, 11 | ua: String 12 | }) 13 | authHistorySchema.plugin(mongoosePaginate) 14 | authHistorySchema.index({ ip: 'text' }) 15 | 16 | module.exports = model('AuthHistory', authHistorySchema); 17 | -------------------------------------------------------------------------------- /src/modules/models/Ban.js: -------------------------------------------------------------------------------- 1 | const { model, Schema, Types } = require('mongoose'); 2 | const mongoosePaginate = require('mongoose-paginate-v2'); 3 | 4 | const banSchema = new Schema({ 5 | user: { 6 | type: Types.ObjectId, 7 | ref: 'User' 8 | }, 9 | admin: { 10 | type: Types.ObjectId, 11 | ref: 'User' 12 | }, 13 | reason: String, 14 | body: String, 15 | createdAt: Date, 16 | expiresAt: Date 17 | }) 18 | banSchema.plugin(mongoosePaginate) 19 | 20 | module.exports = model('Ban', banSchema); 21 | -------------------------------------------------------------------------------- /src/modules/models/Board.js: -------------------------------------------------------------------------------- 1 | const { model, Schema } = require('mongoose'); 2 | const mongoosePaginate = require('mongoose-paginate-v2'); 3 | 4 | const boardSchema = new Schema({ 5 | name: { 6 | type: String, 7 | lowercase: true, 8 | required: true 9 | }, 10 | title: String, 11 | body: String, 12 | position: Number, 13 | createdAt: Date, 14 | threadsCount: Number, 15 | answersCount: Number, 16 | newestThread: Date, 17 | newestAnswer: Date 18 | }) 19 | boardSchema.plugin(mongoosePaginate) 20 | boardSchema.index({ name: 'text', title: 'text', body: 'text' }) 21 | 22 | module.exports = model('Board', boardSchema); 23 | -------------------------------------------------------------------------------- /src/modules/models/Comment.js: -------------------------------------------------------------------------------- 1 | const { model, Schema, Types } = require('mongoose'); 2 | const mongoosePaginate = require('mongoose-paginate-v2'); 3 | 4 | const commentSchema = new Schema({ 5 | fileId: Types.ObjectId, 6 | commentedTo: Types.ObjectId, 7 | body: String, 8 | createdAt: Date, 9 | author: { 10 | type: Types.ObjectId, 11 | ref: 'User' 12 | }, 13 | edited: { 14 | createdAt: Date 15 | }, 16 | likes: [{ 17 | type: Types.ObjectId, 18 | ref: 'User' 19 | }] 20 | }) 21 | commentSchema.plugin(mongoosePaginate) 22 | commentSchema.index({ body: 'text' }) 23 | 24 | module.exports = model('Comment', commentSchema); 25 | -------------------------------------------------------------------------------- /src/modules/models/Dialogue.js: -------------------------------------------------------------------------------- 1 | const { model, Schema, Types } = require('mongoose'); 2 | const mongoosePaginate = require('mongoose-paginate-v2'); 3 | 4 | const dialogueSchema = new Schema({ 5 | from: { 6 | type: Types.ObjectId, 7 | ref: 'User' 8 | }, 9 | to: { 10 | type: Types.ObjectId, 11 | ref: 'User' 12 | }, 13 | lastMessage: { 14 | type: Types.ObjectId, 15 | ref: 'Message' 16 | }, 17 | updatedAt: Date 18 | }) 19 | dialogueSchema.plugin(mongoosePaginate) 20 | 21 | module.exports = model('Dialogue', dialogueSchema); 22 | -------------------------------------------------------------------------------- /src/modules/models/File.js: -------------------------------------------------------------------------------- 1 | const { model, Schema, Types } = require('mongoose'); 2 | const mongoosePaginate = require('mongoose-paginate-v2'); 3 | 4 | const fileObjectSchema = new Schema({ 5 | url: String, 6 | thumb: String, 7 | type: String, 8 | size: String 9 | }) 10 | 11 | const fileSchema = new Schema({ 12 | folderId: Types.ObjectId, 13 | title: String, 14 | body: String, 15 | createdAt: Date, 16 | author: { 17 | type: Types.ObjectId, 18 | ref: 'User' 19 | }, 20 | file: fileObjectSchema, 21 | likes: [{ 22 | type: Types.ObjectId, 23 | ref: 'User' 24 | }], 25 | downloads: Number, 26 | commentsCount: Number, 27 | moderated: { 28 | type: Boolean, 29 | default: false 30 | } 31 | }) 32 | fileSchema.plugin(mongoosePaginate) 33 | fileSchema.index({ title: 'text', body: 'text' }) 34 | 35 | module.exports = model('File', fileSchema); 36 | -------------------------------------------------------------------------------- /src/modules/models/Folder.js: -------------------------------------------------------------------------------- 1 | const { model, Schema } = require('mongoose'); 2 | const mongoosePaginate = require('mongoose-paginate-v2'); 3 | 4 | const pathSchema = new Schema({ 5 | name: { 6 | type: String, 7 | lowercase: true, 8 | required: true 9 | }, 10 | title: String, 11 | body: String, 12 | position: Number, 13 | createdAt: Date, 14 | filesCount: Number 15 | }) 16 | pathSchema.plugin(mongoosePaginate) 17 | pathSchema.index({ name: 'text', title: 'text', body: 'text' }) 18 | 19 | module.exports = model('Folder', pathSchema); 20 | -------------------------------------------------------------------------------- /src/modules/models/Message.js: -------------------------------------------------------------------------------- 1 | const { model, Schema, Types } = require('mongoose'); 2 | const mongoosePaginate = require('mongoose-paginate-v2'); 3 | 4 | const fileSchema = new Schema({ 5 | file: String, 6 | thumb: String, 7 | type: String, 8 | size: String 9 | }) 10 | 11 | const messageSchema = new Schema({ 12 | dialogueId: Types.ObjectId, 13 | body: String, 14 | createdAt: Date, 15 | from: { 16 | type: Types.ObjectId, 17 | ref: 'User' 18 | }, 19 | to: { 20 | type: Types.ObjectId, 21 | ref: 'User' 22 | }, 23 | edited: { 24 | createdAt: Date 25 | }, 26 | file: [fileSchema], 27 | read: { 28 | type: Boolean, 29 | default: false 30 | } 31 | }) 32 | messageSchema.plugin(mongoosePaginate) 33 | messageSchema.index({ body: 'text' }) 34 | 35 | module.exports = model('Message', messageSchema); 36 | -------------------------------------------------------------------------------- /src/modules/models/Notification.js: -------------------------------------------------------------------------------- 1 | const { model, Schema, Types } = require('mongoose'); 2 | const mongoosePaginate = require('mongoose-paginate-v2'); 3 | 4 | const notificationSchema = new Schema({ 5 | type: String, 6 | to: { 7 | type: Types.ObjectId, 8 | ref: 'User' 9 | }, 10 | from: { 11 | type: Types.ObjectId, 12 | ref: 'User' 13 | }, 14 | pageId: Types.ObjectId, 15 | title: String, 16 | body: String, 17 | createdAt: Date, 18 | read: { 19 | type: Boolean, 20 | default: false 21 | } 22 | }) 23 | notificationSchema.plugin(mongoosePaginate) 24 | 25 | module.exports = model('Notification', notificationSchema); 26 | -------------------------------------------------------------------------------- /src/modules/models/Report.js: -------------------------------------------------------------------------------- 1 | const { model, Schema, Types } = require('mongoose'); 2 | const mongoosePaginate = require('mongoose-paginate-v2'); 3 | 4 | const reportSchema = new Schema({ 5 | from: { 6 | type: Types.ObjectId, 7 | ref: 'User' 8 | }, 9 | threadId: Types.ObjectId, 10 | postId: Types.ObjectId, 11 | title: String, 12 | body: String, 13 | createdAt: Date, 14 | read: { 15 | type: Boolean, 16 | default: false 17 | } 18 | }) 19 | reportSchema.plugin(mongoosePaginate) 20 | 21 | module.exports = model('Report', reportSchema); 22 | -------------------------------------------------------------------------------- /src/modules/models/Thread.js: -------------------------------------------------------------------------------- 1 | const { model, Schema, Types } = require('mongoose'); 2 | const mongoosePaginate = require('mongoose-paginate-v2'); 3 | 4 | const attachSchema = new Schema({ 5 | file: String, 6 | thumb: String, 7 | type: String, 8 | size: String 9 | }) 10 | 11 | const threadSchema = new Schema({ 12 | boardId: Types.ObjectId, 13 | pined: Boolean, 14 | closed: Boolean, 15 | title: String, 16 | body: String, 17 | createdAt: Date, 18 | author: { 19 | type: Types.ObjectId, 20 | ref: 'User' 21 | }, 22 | edited: { 23 | createdAt: Date 24 | }, 25 | likes: [{ 26 | type: Types.ObjectId, 27 | ref: 'User' 28 | }], 29 | attach: [attachSchema], 30 | answersCount: Number, 31 | newestAnswer: Date 32 | }) 33 | threadSchema.plugin(mongoosePaginate) 34 | threadSchema.index({ title: 'text', body: 'text' }) 35 | 36 | module.exports = model('Thread', threadSchema); 37 | -------------------------------------------------------------------------------- /src/modules/models/User.js: -------------------------------------------------------------------------------- 1 | const { model, Schema, Types } = require('mongoose'); 2 | const mongoosePaginate = require('mongoose-paginate-v2'); 3 | const bcrypt = require('bcrypt'); 4 | 5 | const userSchema = new Schema({ 6 | name: { 7 | type: String, 8 | lowercase: true, 9 | required: true 10 | }, 11 | displayName: { 12 | type: String, 13 | required: true 14 | }, 15 | email: { 16 | type: String, 17 | lowercase: true, 18 | required: true 19 | }, 20 | password: { 21 | type: String, 22 | required: true 23 | }, 24 | createdAt: Date, 25 | onlineAt: Date, 26 | picture: String, 27 | karma: { 28 | type: Number, 29 | default: 0 30 | }, 31 | role: { 32 | type: Number, 33 | default: 1 34 | }, 35 | ban: { 36 | type: Types.ObjectId, 37 | ref: 'Ban' 38 | } 39 | }) 40 | userSchema.plugin(mongoosePaginate) 41 | userSchema.index({ name: 'text', displayName: 'text' }) 42 | 43 | userSchema.pre('save', async function(next) { 44 | try { 45 | if (this.isNew) { 46 | const salt = await bcrypt.genSalt(10) 47 | const hashedPassword = await bcrypt.hash(this.password, salt) 48 | this.password = hashedPassword 49 | } 50 | next() 51 | } catch(err) { 52 | next(err) 53 | } 54 | }) 55 | 56 | userSchema.methods.isValidPassword = async function(password) { 57 | try { 58 | return await bcrypt.compare(password, this.password) 59 | } catch(err) { 60 | throw err 61 | } 62 | } 63 | 64 | module.exports = model('User', userSchema); 65 | -------------------------------------------------------------------------------- /src/modules/socket/index.js: -------------------------------------------------------------------------------- 1 | const socketIO = require('socket.io'); 2 | const { Types } = require('mongoose'); 3 | const createError = require('http-errors'); 4 | 5 | const Notification = require('../models/Notification'); 6 | const Report = require('../models/Report'); 7 | const File = require('../models/File'); 8 | const Message = require('../models/Message'); 9 | const Dialogue = require('../models/Dialogue'); 10 | 11 | const { verifyAccessTokenIO } = require('../utils/jwt'); 12 | 13 | module.exports = (server) => { 14 | const io = socketIO(server, { 15 | cors: { 16 | origin: process.env.CLIENT, 17 | } 18 | }) 19 | 20 | const joinedList = [] 21 | 22 | io.on('connection', (socket) => { 23 | socket.on('join', async (data) => { 24 | let jwtData = null 25 | if (data.payload && data.payload.token) { 26 | jwtData = verifyAccessTokenIO(data.payload.token) 27 | } 28 | 29 | socket.join(data.room) 30 | 31 | if (/thread:/.test(data.room)) { 32 | if (!jwtData) return 33 | 34 | const threadId = data.room.replace('thread:', '') 35 | const { id, name, displayName, picture, role } = jwtData 36 | const joinedObj = { 37 | threadId, 38 | socket: socket.id, 39 | user: { 40 | _id: id, 41 | name, 42 | displayName, 43 | picture, 44 | role 45 | } 46 | } 47 | if (joinedList.findIndex(item => item.user._id === id) === -1) { 48 | joinedList.push(joinedObj) 49 | } 50 | 51 | const listByThreadId = joinedList.filter(item => item.threadId === threadId) 52 | 53 | io.to(data.room).emit('joinedList', listByThreadId) 54 | } 55 | 56 | if (/notification:/.test(data.room)) { 57 | if (!jwtData || jwtData.id !== data.room.replace('notification:', '')) { 58 | socket.emit('error', createError.Unauthorized()) 59 | socket.leave(data.room) 60 | return 61 | } 62 | 63 | const notifications = await Notification.find({ to: Types.ObjectId(jwtData.id), read: false }) 64 | 65 | io.to(data.room).emit('notificationsCount', { count: notifications.length }) 66 | } 67 | 68 | if (data.room === 'adminNotification') { 69 | if (!jwtData || jwtData.role < 2) { 70 | socket.emit('error', createError.Unauthorized()) 71 | socket.leave(data.room) 72 | return 73 | } 74 | 75 | const reports = await Report.find({ read: false }) 76 | if (reports.length) { 77 | io.to(data.room).emit('newAdminNotification', { type: 'report' }) 78 | } 79 | 80 | const files = await File.find({ moderated: false }) 81 | if (files.length) { 82 | io.to(data.room).emit('newAdminNotification', { type: 'file' }) 83 | } 84 | } 85 | 86 | if (/pmCount:/.test(data.room)) { 87 | if (!jwtData || jwtData.id !== data.room.replace('pmCount:', '')) { 88 | socket.emit('error', createError.Unauthorized()) 89 | socket.leave(data.room) 90 | return 91 | } 92 | 93 | const dialogues = await Dialogue.find({ 94 | $or: [{ 95 | to: Types.ObjectId(jwtData.id) 96 | }, { 97 | from: Types.ObjectId(jwtData.id) 98 | }] 99 | }).populate({ path: 'lastMessage' }) 100 | 101 | const noRead = dialogues.filter(item => item.lastMessage && !item.lastMessage.read && item.lastMessage.to.toString() === jwtData.id) 102 | 103 | io.to(data.room).emit('messagesCount', { count: noRead.length }) 104 | } 105 | 106 | if (/dialogues:/.test(data.room)) { 107 | if (!jwtData || jwtData.id !== data.room.replace('dialogues:', '')) { 108 | socket.emit('error', createError.Unauthorized()) 109 | socket.leave(data.room) 110 | return 111 | } 112 | } 113 | 114 | if (/pm:/.test(data.room)) { 115 | if (!jwtData || jwtData.id !== data.payload.userId) { 116 | socket.emit('error', createError.Unauthorized()) 117 | socket.leave(data.room) 118 | return 119 | } 120 | } 121 | }) 122 | 123 | socket.on('leave', (data) => { 124 | socket.leave(data.room) 125 | 126 | if (/thread:/.test(data.room)) { 127 | const threadId = data.room.replace('thread:', '') 128 | 129 | const removeIndex = joinedList.findIndex(item => item.socket === socket.id) 130 | if (removeIndex !== -1) { 131 | joinedList.splice(removeIndex, 1) 132 | } 133 | 134 | const listByThreadId = joinedList.filter(item => item.threadId === threadId) 135 | 136 | io.to(data.room).emit('joinedList', listByThreadId) 137 | } 138 | }) 139 | 140 | socket.on('disconnect', (reason) => { 141 | const removeIndex = joinedList.findIndex(item => item.socket === socket.id) 142 | if (removeIndex !== -1) { 143 | joinedList.splice(removeIndex, 1) 144 | } 145 | }) 146 | 147 | socket.on('createMessage', async (data) => { 148 | const { token, dialogueId, body, to } = data 149 | 150 | let jwtData = null 151 | if (token) { 152 | jwtData = verifyAccessTokenIO(token) 153 | } 154 | if (!jwtData) { 155 | socket.emit('error', createError.Unauthorized()) 156 | socket.leave('pm:' + dialogueId) 157 | return 158 | } 159 | 160 | try { 161 | let isNewDialogue = false 162 | let dId 163 | const dialogueExist = await Dialogue.findOne({ _id: Types.ObjectId(dialogueId) }) 164 | 165 | if (!dialogueExist) { 166 | isNewDialogue = true 167 | 168 | const newDialogue = new Dialogue({ 169 | from: jwtData.id, 170 | to 171 | }) 172 | 173 | const dialogue = await newDialogue.save() 174 | dId = dialogue._id 175 | 176 | socket.emit('joinToDialogue', dialogue) 177 | } else { 178 | dId = dialogueExist._id 179 | } 180 | 181 | const now = new Date().toISOString() 182 | 183 | const newMessage = new Message({ 184 | dialogueId: dId, 185 | body: body.substring(0, 1000), 186 | createdAt: now, 187 | from: jwtData.id, 188 | to, 189 | read: false 190 | }) 191 | 192 | const message = await newMessage.save() 193 | await Dialogue.updateOne({ _id: Types.ObjectId(dId) }, { lastMessage: message._id, updatedAt: now }) 194 | 195 | const populate = [{ 196 | path: 'from', 197 | select: '_id name displayName onlineAt picture role ban' 198 | }, { 199 | path: 'to', 200 | select: '_id name displayName onlineAt picture role ban' 201 | }] 202 | const populatedMessage = await Message.findById(message._id).populate(populate) 203 | 204 | io.to('pm:' + dId).emit('newMessage', populatedMessage) 205 | 206 | const populatedDialogue = [{ 207 | path: 'from', 208 | select: '_id name displayName onlineAt picture role ban' 209 | }, { 210 | path: 'to', 211 | select: '_id name displayName onlineAt picture role ban' 212 | }, { 213 | path: 'lastMessage' 214 | }] 215 | const newOrUpdatedDialogue = await Dialogue.findById(dId).populate(populatedDialogue) 216 | 217 | if (isNewDialogue) { 218 | io.to('dialogues:' + to).emit('newDialogue', newOrUpdatedDialogue) 219 | } else { 220 | io.to('dialogues:' + to).emit('updateDialogue', newOrUpdatedDialogue) 221 | } 222 | 223 | const dialogues = await Dialogue.find({ 224 | $or: [{ 225 | to: Types.ObjectId(jwtData.id) 226 | }, { 227 | from: Types.ObjectId(jwtData.id) 228 | }] 229 | }).populate({ path: 'lastMessage' }) 230 | 231 | const noRead = dialogues.filter(item => item.lastMessage && !item.lastMessage.read && item.lastMessage.to.toString() === to) 232 | 233 | socket.to('pmCount:' + to).emit('messagesCount', { count: noRead.length }) 234 | } catch(err) { 235 | socket.emit('error', err) 236 | } 237 | }) 238 | 239 | socket.on('readMessages', async (data) => { 240 | const { token, dialogueId, from } = data 241 | 242 | let jwtData = null 243 | if (token) { 244 | jwtData = verifyAccessTokenIO(token) 245 | } 246 | if (!jwtData) { 247 | socket.emit('error', createError.Unauthorized()) 248 | socket.leave('pm:' + dialogueId) 249 | return 250 | } 251 | 252 | try { 253 | await Message.updateMany({ dialogueId: Types.ObjectId(dialogueId), from }, { read: true }) 254 | 255 | socket.to('pm:' + dialogueId).emit('messagesRead') 256 | 257 | const dialogues = await Dialogue.find({ 258 | $or: [{ 259 | to: Types.ObjectId(jwtData.id) 260 | }, { 261 | from: Types.ObjectId(jwtData.id) 262 | }] 263 | }).populate({ path: 'lastMessage' }) 264 | 265 | const noRead = dialogues.filter(item => item.lastMessage && !item.lastMessage.read && item.lastMessage.to.toString() === jwtData.id) 266 | 267 | io.to('pmCount:' + jwtData.id).emit('messagesCount', { count: noRead.length }) 268 | } catch(err) { 269 | io.to('pm:' + dialogueId).emit('error', err) 270 | } 271 | }) 272 | 273 | socket.on('startType', (data) => { 274 | const { token, dialogueId } = data 275 | 276 | let jwtData = null 277 | if (token) { 278 | jwtData = verifyAccessTokenIO(token) 279 | } 280 | if (!jwtData) { 281 | socket.emit('error', createError.Unauthorized()) 282 | socket.leave('pm:' + dialogueId) 283 | return 284 | } 285 | 286 | socket.to('pm:' + dialogueId).emit('startTyping', { userName: jwtData.displayName }) 287 | }) 288 | 289 | socket.on('stopType', (data) => { 290 | const { token, dialogueId } = data 291 | 292 | let jwtData = null 293 | if (token) { 294 | jwtData = verifyAccessTokenIO(token) 295 | } 296 | if (!jwtData) { 297 | socket.emit('error', createError.Unauthorized()) 298 | socket.leave('pm:' + dialogueId) 299 | return 300 | } 301 | 302 | socket.to('pm:' + dialogueId).emit('stopTyping', { userName: jwtData.displayName }) 303 | }) 304 | }) 305 | 306 | return io 307 | } 308 | -------------------------------------------------------------------------------- /src/modules/utils/checkFileExec.js: -------------------------------------------------------------------------------- 1 | module.exports.checkFileExec = (file, callback) => { 2 | if ( 3 | file.mimetype === 'text/javascript' || 4 | file.mimetype === 'text/html' || 5 | file.mimetype === 'text/css' || 6 | file.mimetype === 'application/json' || 7 | file.mimetype === 'application/ld+json' || 8 | file.mimetype === 'application/php' 9 | ) { 10 | callback('File format is not allowed', false) 11 | } 12 | else callback(null, true) 13 | } 14 | 15 | module.exports.videoTypes = ['video/mp4', 'video/webm', 'video/avi', 'video/msvideo', 'video/x-msvideo', 'video/mpeg', 'video/3gpp', 'video/quicktime'] 16 | -------------------------------------------------------------------------------- /src/modules/utils/createThumbnail.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const ffmpeg = require('fluent-ffmpeg'); 3 | const ffmpegStatic = require('ffmpeg-static'); 4 | const ffprobeStatic = require('ffprobe-static'); 5 | 6 | ffmpeg.setFfmpegPath(ffmpegStatic) 7 | ffmpeg.setFfprobePath(ffprobeStatic.path) 8 | 9 | module.exports = (file, dest, thumbFilename) => { 10 | return new Promise((resolve, reject) => { 11 | ffmpeg(file) 12 | .screenshots({ 13 | folder: path.join(__dirname, '..', '..', '..', 'public', dest, 'thumbnails'), 14 | filename: thumbFilename, 15 | timestamps: ['1%'], 16 | size: '480x?' 17 | }) 18 | .on('error', (err) => { 19 | reject(err) 20 | }) 21 | .on('end', () => { 22 | resolve(thumbFilename) 23 | }) 24 | }) 25 | } -------------------------------------------------------------------------------- /src/modules/utils/deleteFiles.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | 3 | module.exports = (files, callback) => { 4 | let i = files.length 5 | files.map(filepath => { 6 | fs.unlink(filepath, (err) => { 7 | i-- 8 | if (err) { 9 | return callback(err) 10 | } else if (i <= 0) { 11 | callback(null) 12 | } 13 | }) 14 | }) 15 | } 16 | -------------------------------------------------------------------------------- /src/modules/utils/generate_keys.js: -------------------------------------------------------------------------------- 1 | const crypto = require('crypto'); 2 | 3 | const secret = crypto.randomBytes(32).toString('hex') 4 | console.table({ secret }) 5 | -------------------------------------------------------------------------------- /src/modules/utils/jwt.js: -------------------------------------------------------------------------------- 1 | const JWT = require('jsonwebtoken'); 2 | const Mongoose = require('mongoose'); 3 | const createError = require('http-errors'); 4 | 5 | const User = require('../models/User'); 6 | 7 | const signAccessToken = (user) => { 8 | return new Promise((resolve, reject) => { 9 | const payload = { 10 | id: user.id, 11 | name: user.name, 12 | displayName: user.displayName, 13 | picture: user.picture, 14 | role: user.role 15 | } 16 | JWT.sign(payload, process.env.SECRET, { expiresIn: '24h' }, (err, token) => { 17 | if (err) { 18 | console.error(err.message) 19 | return reject(createError.InternalServerError()) 20 | } 21 | return resolve(token) 22 | }) 23 | }) 24 | } 25 | 26 | const verifyAccessToken = (req, res, next) => { 27 | if (!req.headers['authorization']) return next(createError.Unauthorized()) 28 | 29 | const authHeader = req.headers['authorization'] 30 | const bearerToken = authHeader.split(' ') 31 | const token = bearerToken[1] 32 | JWT.verify(token, process.env.SECRET, (err, payload) => { 33 | if (err) { 34 | const message = err.name === 'JsonWebTokenError' ? 'Unauthorized' : err.message 35 | return next(createError.Unauthorized(message)) 36 | } 37 | req.payload = payload 38 | next() 39 | }) 40 | } 41 | 42 | const verifyAccessTokenIO = (token) => { 43 | if (!token) return createError.Unauthorized() 44 | 45 | return JWT.verify(token, process.env.SECRET, (err, payload) => { 46 | if (err) { 47 | const message = err.name === 'JsonWebTokenError' ? 'Unauthorized' : err.message 48 | return createError.Unauthorized(message) 49 | } 50 | return payload 51 | }) 52 | } 53 | 54 | module.exports = { signAccessToken, verifyAccessToken, verifyAccessTokenIO } 55 | -------------------------------------------------------------------------------- /src/modules/utils/storage.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const multer = require('multer'); 3 | 4 | module.exports = (dest, name) => { 5 | return multer.diskStorage({ 6 | destination: path.join(__dirname, '..', '..', '..', 'public', dest), 7 | filename: (req, file, callback) => { 8 | callback(null, name + '_' + Date.now() + path.extname(file.originalname)) 9 | } 10 | }) 11 | } 12 | -------------------------------------------------------------------------------- /src/modules/utils/transliterate.js: -------------------------------------------------------------------------------- 1 | const chars = { 2 | "А": "a", 3 | "а": "a", 4 | "Б": "B", 5 | "б": "b", 6 | "В": "V", 7 | "в": "v", 8 | "Г": "G", 9 | "г": "g", 10 | "Д": "D", 11 | "д": "d", 12 | "Е": "E", 13 | "е": "e", 14 | "Ё": "YO", 15 | "ё": "yo", 16 | "Ж": "ZH", 17 | "ж": "zh", 18 | "З": "Z", 19 | "з": "z", 20 | "И": "I", 21 | "и": "i", 22 | "Й": "I", 23 | "й": "i", 24 | "К": "K", 25 | "к": "k", 26 | "Л": "L", 27 | "л": "l", 28 | "М": "M", 29 | "м": "m", 30 | "Н": "N", 31 | "н": "n", 32 | "О": "O", 33 | "о": "o", 34 | "П": "P", 35 | "п": "p", 36 | "Р": "R", 37 | "р": "r", 38 | "С": "S", 39 | "с": "s", 40 | "Т": "T", 41 | "т": "t", 42 | "У": "U", 43 | "у": "u", 44 | "Ф": "F", 45 | "ф": "f", 46 | "Х": "H", 47 | "х": "h", 48 | "Ц": "TS", 49 | "ц": "ts", 50 | "Ч": "CH", 51 | "ч": "ch", 52 | "Ш": "SH", 53 | "ш": "sh", 54 | "Щ": "SCH", 55 | "щ": "sch", 56 | "Ь": "'", 57 | "ь": "'", 58 | "Ы": "I", 59 | "ы": "i", 60 | "Ъ": "'", 61 | "ъ": "'", 62 | "Э": "E", 63 | "э": "e", 64 | "Ю": "YU", 65 | "ю": "yu", 66 | "Я": "Ya", 67 | "я": "ya", 68 | } 69 | 70 | module.exports = (word) => { 71 | return word.split('').map(char => chars[char] || char).join('') 72 | } 73 | -------------------------------------------------------------------------------- /src/modules/utils/validationSchema.js: -------------------------------------------------------------------------------- 1 | const Joi = require('joi'); 2 | 3 | const registerSchema = Joi.object({ 4 | username: Joi.string() 5 | .min(3) 6 | .max(21) 7 | .required() 8 | .regex(/^[a-zA-Zа-яА-Я0-9_\u3040-\u309f\u30a0-\u30ff]+$/) 9 | .messages({ 10 | 'string.pattern.base': 'Allowed: latin, cyrillic, hiragana, katakana, 0-9 and symbol _' 11 | }), 12 | email: Joi.string().email().lowercase().required(), 13 | password: Joi.string().min(6).max(50).required() 14 | }) 15 | 16 | const loginSchema = Joi.object({ 17 | username: Joi.string() 18 | .min(3) 19 | .max(21) 20 | .required() 21 | .regex(/^[a-zA-Zа-яА-Я0-9_\u3040-\u309f\u30a0-\u30ff]+$/) 22 | .messages({ 23 | 'string.pattern.base': 'Allowed: latin, cyrillic, hiragana, katakana, 0-9 and symbol _' 24 | }), 25 | password: Joi.string().min(6).max(50).required() 26 | }) 27 | 28 | module.exports = { registerSchema, loginSchema } 29 | -------------------------------------------------------------------------------- /src/routes/api.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | 3 | const router = express.Router(); 4 | const { verifyAccessToken } = require('../modules/utils/jwt'); 5 | const GeneralController = require('../modules/controllers/generalController'); 6 | const ProfileController = require('../modules/controllers/profileController'); 7 | const ForumController = require('../modules/controllers/forumController'); 8 | const UploadsController = require('../modules/controllers/uploadsController'); 9 | const MessagesController = require('../modules/controllers/messagesController'); 10 | 11 | router.get('/search', GeneralController.search) 12 | router.get('/stats', GeneralController.getStats) 13 | router.get('/users', GeneralController.getUsers) 14 | router.get('/admins', GeneralController.getAdmins) 15 | 16 | router.get('/user', verifyAccessToken, GeneralController.getUser) 17 | router.get('/user/stats', verifyAccessToken, GeneralController.getUserStats) 18 | router.get('/user/threads', verifyAccessToken, GeneralController.getUserThreads) 19 | router.get('/user/answers', verifyAccessToken, GeneralController.getUserAnswers) 20 | router.get('/user/bans', verifyAccessToken, GeneralController.getUserBans) 21 | router.get('/user/authHistory', verifyAccessToken, GeneralController.getAuthHistory) 22 | router.get('/user/authHistory/search', verifyAccessToken, GeneralController.searchAuthHistory) 23 | router.delete('/user/delete', verifyAccessToken, GeneralController.deleteUser) 24 | 25 | router.get('/bans', GeneralController.getBans) 26 | router.get('/ban', GeneralController.getBan) 27 | router.post('/ban/create', verifyAccessToken, GeneralController.createBan) 28 | router.delete('/ban/delete', verifyAccessToken, GeneralController.unBan) 29 | router.delete('/ban/history/delete', verifyAccessToken, GeneralController.deleteBan) 30 | 31 | router.put('/role/edit', verifyAccessToken, GeneralController.editRole) 32 | 33 | router.get('/reports', verifyAccessToken, GeneralController.getReports) 34 | router.post('/report/create', verifyAccessToken, GeneralController.createReport) 35 | router.delete('/reports/delete', verifyAccessToken, GeneralController.deleteReports) 36 | 37 | router.get('/profile', verifyAccessToken, ProfileController.getProfile) 38 | router.put('/profile/upload/picture', verifyAccessToken, ProfileController.uploadUserPicture) 39 | router.put('/profile/password/edit', verifyAccessToken, ProfileController.editPassword) 40 | router.put('/profile/setOnline', verifyAccessToken, ProfileController.setOnline) 41 | 42 | router.get('/notifications', verifyAccessToken, ProfileController.getNotifications) 43 | router.delete('/notifications/delete', verifyAccessToken, ProfileController.deleteNotifications) 44 | 45 | router.get('/boards', ForumController.getBoards) 46 | router.get('/board', ForumController.getBoard) 47 | router.post('/board/create', verifyAccessToken, ForumController.createBoard) 48 | router.delete('/board/delete', verifyAccessToken, ForumController.deleteBoard) 49 | router.put('/board/edit', verifyAccessToken, ForumController.editBoard) 50 | 51 | router.get('/threads', ForumController.getThreads) 52 | router.get('/threads/recently', ForumController.getRecentlyThreads) 53 | router.get('/thread', ForumController.getThread) 54 | router.post('/thread/create', verifyAccessToken, ForumController.createThread) 55 | router.delete('/thread/delete', verifyAccessToken, ForumController.deleteThread) 56 | router.delete('/thread/clear', verifyAccessToken, ForumController.clearThread) 57 | router.put('/thread/edit', verifyAccessToken, ForumController.editThread) 58 | router.put('/thread/adminedit', verifyAccessToken, ForumController.adminEditThread) 59 | router.put('/thread/like', verifyAccessToken, ForumController.likeThread) 60 | 61 | router.get('/answers', ForumController.getAnswers) 62 | router.post('/answer/create', verifyAccessToken, ForumController.createAnswer) 63 | router.delete('/answer/delete', verifyAccessToken, ForumController.deleteAnswer) 64 | router.put('/answer/edit', verifyAccessToken, ForumController.editAnswer) 65 | router.put('/answer/like', verifyAccessToken, ForumController.likeAnswer) 66 | 67 | router.get('/folders', UploadsController.getFolders) 68 | router.get('/folder', UploadsController.getFolder) 69 | router.post('/folder/create', verifyAccessToken, UploadsController.createFolder) 70 | router.delete('/folder/delete', verifyAccessToken, UploadsController.deleteFolder) 71 | router.put('/folder/edit', verifyAccessToken, UploadsController.editFolder) 72 | 73 | router.get('/files', UploadsController.getFiles) 74 | router.get('/files/all', UploadsController.getAllFiles) 75 | router.get('/files/all/admin', verifyAccessToken, UploadsController.getAdminAllFiles) 76 | router.get('/file', UploadsController.getFile) 77 | router.post('/file/create', verifyAccessToken, UploadsController.createFile) 78 | router.delete('/file/delete', verifyAccessToken, UploadsController.deleteFile) 79 | router.put('/file/edit', verifyAccessToken, UploadsController.editFile) 80 | router.put('/file/like', verifyAccessToken, UploadsController.likeFile) 81 | router.put('/file/moderate', verifyAccessToken, UploadsController.moderateFile) 82 | router.put('/file/download', UploadsController.download) 83 | 84 | router.get('/file/comments', UploadsController.getComments) 85 | router.post('/file/comment/create', verifyAccessToken, UploadsController.createComment) 86 | router.delete('/file/comment/delete', verifyAccessToken, UploadsController.deleteComment) 87 | router.put('/file/comment/like', verifyAccessToken, UploadsController.likeComment) 88 | 89 | router.get('/dialogues', verifyAccessToken, MessagesController.getDialogues) 90 | router.get('/dialogue', verifyAccessToken, MessagesController.getDialogue) 91 | 92 | router.get('/messages', verifyAccessToken, MessagesController.getMessages) 93 | router.post('/message/create', verifyAccessToken, MessagesController.createMessage) 94 | router.delete('/message/delete', verifyAccessToken, MessagesController.deleteMessage) 95 | 96 | module.exports = router 97 | -------------------------------------------------------------------------------- /src/routes/auth.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | 3 | const router = express.Router(); 4 | const AuthController = require('../modules/controllers/authController'); 5 | 6 | router.post('/register', AuthController.register) 7 | router.post('/login', AuthController.login) 8 | 9 | module.exports = router 10 | -------------------------------------------------------------------------------- /src/routes/index.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const express = require('express'); 3 | const createError = require('http-errors'); 4 | 5 | const router = express.Router() 6 | 7 | if (process.env.NODE_ENV === 'production') { 8 | router.get('*', (req, res) => { 9 | res.sendFile(path.join(__dirname, '..', '..', 'client', 'build', 'index.html')) 10 | }) 11 | } else { 12 | router.get('/', (req, res) => { 13 | res.json({ message: 'Welcome to the MERN Forum root path' }) 14 | }) 15 | router.get('*', (req, res, next) => { 16 | next(createError.NotFound()) 17 | }) 18 | } 19 | 20 | module.exports = router 21 | --------------------------------------------------------------------------------