jschan - Anonymous imageboard software. Classic look, modern features and feel. Works without JavaScript and supports Tor, I2P, Lokinet, etc.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

564 lines
19 KiB

'use strict';
const { Posts, Boards, Modlogs } = require(__dirname+'/../../db/')
, Mongo = require(__dirname+'/../../db/db.js')
, banPoster = require(__dirname+'/banposter.js')
, deletePosts = require(__dirname+'/deletepost.js')
, spoilerPosts = require(__dirname+'/spoilerpost.js')
, stickyPosts = require(__dirname+'/stickyposts.js')
, bumplockPosts = require(__dirname+'/bumplockposts.js')
, lockPosts = require(__dirname+'/lockposts.js')
, cyclePosts = require(__dirname+'/cycleposts.js')
, deletePostsFiles = require(__dirname+'/deletepostsfiles.js')
, reportPosts = require(__dirname+'/reportpost.js')
, dismissReports = require(__dirname+'/dismissreport.js')
, movePosts = require(__dirname+'/moveposts.js')
, { remove } = require('fs-extra')
, uploadDirectory = require(__dirname+'/../../helpers/files/uploadDirectory.js')
, buildQueue = require(__dirname+'/../../queue.js')
, { postPasswordSecret } = require(__dirname+'/../../configs/main.js')
, { createHash, timingSafeEqual } = require('crypto');
module.exports = async (req, res, next) => {
const redirect = req.headers.referer || `/${req.params.board ? req.params.board+'/manage/reports' : 'globalmanage/recents'}.html`;
//if user isnt staff, and they put an action that requires password, e.g. delete/spoiler, then filter posts to only matching password
if (res.locals.permLevel >= 4 && res.locals.actions.numPasswords > 0) {
let passwordPosts = [];
if (req.body.password && req.body.password.length > 0) {
//hash their input and make it a buffer
const inputPasswordHash = createHash('sha256').update(postPasswordSecret + req.body.password).digest('base64');
const inputPasswordBuffer = Buffer.from(inputPasswordHash);
passwordPosts = res.locals.posts.filter(post => {
if (post.password != null) { //null password doesnt matter for timing attack, it cant be deleted by non-staff
const postBuffer = Buffer.from(post.password);
//returns true and passes filter if passwod matched. constant time compare
return timingSafeEqual(inputPasswordBuffer, postBuffer);
}
});
}
//no posts matched password, reject
if (passwordPosts.length === 0) {
return res.status(403).render('message', {
'title': 'Forbidden',
'error': 'Password did not match any selected posts',
redirect,
});
}
res.locals.posts = passwordPosts
}
//get a map of boards to threads affected
const boardThreadMap = {};
for (let i = 0; i < res.locals.posts.length; i++) {
const post = res.locals.posts[i];
if (!boardThreadMap[post.board]) {
boardThreadMap[post.board] = {
'directThreads': new Set(),
'threads': new Set()
};
}
if (!post.thread) {
//a thread was directly selected on this board, not just posts. so we handle deletes differently
boardThreadMap[post.board].directThreads.add(post.postId);
}
const threadId = post.thread || post.postId;
boardThreadMap[post.board].threads.add(threadId);
}
const beforePages = {};
const threadBoards = Object.keys(boardThreadMap);
//get number of pages for each before actions for deleting old pages and changing page nav numbers incase number of pages changes
if (req.body.delete || req.body.delete_ip_board || req.body.delete_ip_global) {
await Promise.all(threadBoards.map(async board => {
beforePages[board] = Math.ceil((await Posts.getPages(board)) / 10);
}));
}
const messages = [];
const modlogActions = []
const combinedQuery = {};
let aggregateNeeded = false;
// if getting global banned, board ban doesnt matter
if (req.body.ban || req.body.global_ban || req.body.report_ban || req.body.global_report_ban) {
const { message, action, query } = await banPoster(req, res, next);
if (req.body.ban) {
modlogActions.push('Ban');
} else if (req.body.global_ban) {
modlogActions.push('Global Ban');
}
if (req.body.report_ban) {
modlogActions.push('Ban reporter');
} else if (req.body.global_report_ban) {
modlogActions.push('Global ban reporter');
}
if (action) {
combinedQuery[action] = { ...combinedQuery[action], ...query}
}
messages.push(message);
}
if (req.body.delete || req.body.delete_ip_board || req.body.delete_ip_global) {
if (req.body.delete_ip_board || req.body.delete_ip_global) {
const deletePostIps = res.locals.posts.map(x => x.ip.hash);
let query = {
'ip': {
'$in': deletePostIps
}
};
if (req.body.delete_ip_board) {
query['board'] = req.params.board;
}
const deleteIpPosts = await Posts.db.find(query).toArray();
res.locals.posts = res.locals.posts.concat(deleteIpPosts);
}
if (req.body.delete_file) {
const { message } = await deletePostsFiles(res.locals.posts, false); //delete files, not just unlink
messages.push(message);
}
const { action, message } = await deletePosts(res.locals.posts, req.body.delete_ip_global ? null : req.params.board);
messages.push(message);
if (action) {
if (req.body.delete) {
modlogActions.push('Delete');
} else if (req.body.delete_ip_board) {
modlogActions.push('Delete by IP');
} else if (req.body.delete_ip_global) {
modlogActions.push('Global delete by IP');
}
aggregateNeeded = true;
}
} else if (req.body.move) {
if (boardThreadMap[req.params.board].directThreads.size > 0) {
const threadIds = [...boardThreadMap[req.params.board].directThreads];
const fetchMovePosts = await Posts.db.find({
'board': req.params.board,
'thread': {
'$in': threadIds
}
}).toArray();
res.locals.posts = res.locals.posts.concat(fetchMovePosts);
}
const { message, action } = await movePosts(req, res);
if (action) {
modlogActions.push('Moved');
aggregateNeeded = true;
}
messages.push(message);
} else {
// if it was getting deleted/moved, dont do these actions
if (req.body.unlink_file || req.body.delete_file) {
const { message, action, query } = await deletePostsFiles(res.locals.posts, req.body.unlink_file);
if (action) {
if (req.body.unlink_file) {
modlogActions.push('Unlink files');
} else if (req.body.delete_file) {
modlogActions.push('Delete files');
}
aggregateNeeded = true;
combinedQuery[action] = { ...combinedQuery[action], ...query}
}
messages.push(message);
} else if (req.body.spoiler) {
const { message, action, query } = spoilerPosts(res.locals.posts);
if (action) {
modlogActions.push('Spoiler files');
combinedQuery[action] = { ...combinedQuery[action], ...query}
}
messages.push(message);
}
//lock, sticky, bumplock, cyclic
if (req.body.bumplock) {
const { message, action, query } = bumplockPosts(res.locals.posts);
if (action) {
modlogActions.push('Bumplock');
combinedQuery[action] = { ...combinedQuery[action], ...query}
}
messages.push(message);
}
if (req.body.lock) {
const { message, action, query } = lockPosts(res.locals.posts);
if (action) {
modlogActions.push('Lock');
combinedQuery[action] = { ...combinedQuery[action], ...query}
}
messages.push(message);
}
if (req.body.sticky) {
const { message, action, query } = stickyPosts(res.locals.posts);
if (action) {
modlogActions.push('Sticky');
combinedQuery[action] = { ...combinedQuery[action], ...query}
}
messages.push(message);
}
if (req.body.cyclic) {
const { message, action, query } = cyclePosts(res.locals.posts);
if (action) {
modlogActions.push('Cycle');
combinedQuery[action] = { ...combinedQuery[action], ...query}
}
messages.push(message);
}
// cannot report and dismiss at same time
if (req.body.report || req.body.global_report) {
const { message, action, query } = reportPosts(req, res);
if (action) {
//no modlog entry for making reports
combinedQuery[action] = { ...combinedQuery[action], ...query}
}
messages.push(message);
} else if (req.body.dismiss || req.body.global_dismiss) {
const { message, action, query } = dismissReports(req, res);
if (action) {
if (req.body.dismiss) {
modlogActions.push('Dismiss reports');
} else if (req.body.global_dismiss) {
modlogActions.push('Dismiss global reports');
}
combinedQuery[action] = { ...combinedQuery[action], ...query}
}
messages.push(message);
}
}
if (Object.keys(combinedQuery).length > 0) {
await Posts.db.updateMany({
'_id': {
'$in': res.locals.posts.map(p => Mongo.ObjectId(p._id))
}
}, combinedQuery);
}
let buildBoards = {};
//get all affected boards for templates if necessary. can be multiple boards from global actions
if (modlogActions.length > 0 || res.locals.actions.numBuild > 0) {
buildBoards = (await Boards.db.find({
'_id': {
'$in': threadBoards
}
}).toArray()).reduce((acc, curr) => {
if (!acc[curr._id]) {
acc[curr._id] = curr;
}
return acc;
}, buildBoards);
}
const parallelPromises = [];
//modlog
if (modlogActions.length > 0) {
const modlog = {};
const logDate = new Date(); //all events current date
const message = req.body.log_message || null;
let logUser;
if (res.locals.permLevel < 4) { //if staff
logUser = req.session.user.username;
} else {
logUser = 'Unregistered User';
}
for (let i = 0; i < res.locals.posts.length; i++) {
const post = res.locals.posts[i];
if (!modlog[post.board]) {
//per board actions, all actions combined to one event
modlog[post.board] = {
postIds: [],
actions: modlogActions,
date: logDate,
user: logUser,
showUser: req.body.show_name || logUser === 'Unregistered User' ? true : false,
message: message,
};
}
//push each post id
modlog[post.board].postIds.push(post.postId);
}
const modlogDocuments = [];
for (let i = 0; i < threadBoards.length; i++) {
const boardName = threadBoards[i];
const boardLog = modlog[boardName];
//make it into documents for the db
modlogDocuments.push({
...boardLog,
'board': boardName
});
}
if (modlogDocuments.length > 0) {
//insert the modlog docs
await Modlogs.insertMany(modlogDocuments);
for (let i = 0; i < threadBoards.length; i++) {
const board = buildBoards[threadBoards[i]];
buildQueue.push({
'task': 'buildModLog',
'options': {
'board': board,
}
});
buildQueue.push({
'task': 'buildModLogList',
'options': {
'board': board,
}
});
}
}
}
//if there are actions that can cause some rebuilding
if (res.locals.actions.numBuild > 0) {
//make it into an OR query for the db
const queryOrs = [];
for (let i = 0; i < threadBoards.length; i++) {
const threadBoard = threadBoards[i];
//convert this to an array while we are here
boardThreadMap[threadBoard].threads = [...boardThreadMap[threadBoard].threads];
boardThreadMap[threadBoard].directThreads = [...boardThreadMap[threadBoard].directThreads];
queryOrs.push({
'board': threadBoard,
'postId': {
'$in': boardThreadMap[threadBoard].threads
}
})
}
//fetch threads per board that we only checked posts for
let threadsEachBoard = [];
if (queryOrs.length > 0) {
threadsEachBoard = await Posts.db.find({
'thread': null,
'$or': queryOrs
}).toArray();
}
//combine it with what we already had
const selectedThreads = res.locals.posts.filter(post => post.thread === null)
threadsEachBoard = threadsEachBoard.concat(selectedThreads)
//get the oldest and newest thread for each board to determine how to delete
let threadBounds = threadsEachBoard.reduce((acc, curr) => {
if (!acc[curr.board] || curr.bumped < acc[curr.board].bumped) {
acc[curr.board] = { oldest: null, newest: null};
}
if (!acc[curr.board].oldest || curr.bumped < acc[curr.board].oldest.bumped) {
acc[curr.board].oldest = curr;
}
if (!acc[curr.board].newest || curr.bumped > acc[curr.board].newest.bumped) {
acc[curr.board].newest = curr;
}
return acc;
}, {});
if (aggregateNeeded) {
//fix latest post timestamp on baords for webring/board list activity
await Posts.fixLatest(threadBoards);
//recalculate replies and image counts if necessary
const selectedPosts = res.locals.posts.filter(p => p.thread !== null);
if (selectedPosts.length > 0) {
let threadOrs = selectedPosts.map(p => {
return {
board: p.board,
thread: p.thread
}
});
//get replies, files, bump date, from threads
const threadAggregates = await Posts.getThreadAggregates(threadOrs);
const bulkWrites = [];
for (let i = 0; i < threadAggregates.length; i++) {
const threadAggregate = threadAggregates[i];
if (threadAggregate.bumped < threadBounds[threadAggregate._id.board].oldest.bumped) {
threadBounds[threadAggregate._id.board].oldest = { bumped: threadAggregate.bumped };
} else if (threadAggregate.bumped < threadBounds[threadAggregate._id.board].newest.bumped) {
threadBounds[threadAggregate._id.board].newest = { bumped: threadAggregate.bumped };
}
/*
note: the aggregate will not return any replies if the thread had no remaining replies (e.g. they are all deleted)
so here we filter them out of the original list, then afterwards the ones left in the list are set to 0 or the OP post date
*/
threadOrs = threadOrs.filter(t => t.thread !== threadAggregate._id.thread && t.board !== threadAggregate._id.board);
//use results from first aggregate for threads with replies still existing
bulkWrites.push({
'updateOne': {
'filter': {
'postId': threadAggregate._id.thread,
'board': threadAggregate._id.board
},
'update': {
'$set': {
'replyposts': threadAggregate.replyposts,
'replyfiles': threadAggregate.replyfiles,
'bumped': threadAggregate.bumped
}
}
}
});
}
if (threadOrs.length > 0) {
const threadOPOrs = threadOrs.map(t => {
return {
postId: t.thread,
board: t.board
};
});
//get post dates of OPS
const emptyThreadAggregates = await Posts.resetThreadAggregates(threadOPOrs);
if (emptyThreadAggregates.length > 0) {
for (let i = 0; i < emptyThreadAggregates.length; i++) {
const threadAggregate = emptyThreadAggregates[i];
if (threadAggregate.bumped < threadBounds[threadAggregate.board].oldest.bumped) {
threadBounds[threadAggregate.board].oldest = { bumped: threadAggregate.bumped };
} else if (threadAggregate.bumped < threadBounds[threadAggregate.board].newest.bumped) {
threadBounds[threadAggregate.board].newest = { bumped: threadAggregate.bumped };
}
//set them all
bulkWrites.push({
'updateOne': {
'filter': {
'_id': threadAggregate._id
},
'update': {
'$set': {
'replyposts': threadAggregate.replyposts,
'replyfiles': threadAggregate.replyfiles,
'bumped': threadAggregate.bumped
}
}
}
});
}
}
}
if (bulkWrites.length > 0) {
await Posts.db.bulkWrite(bulkWrites);
}
}
}
for (let i = 0; i < threadBoards.length; i++) {
const boardName = threadBoards[i];
const bounds = threadBounds[boardName];
const board = buildBoards[boardName];
//rebuild impacted threads
if (req.body.move) {
buildQueue.push({
'task': 'buildThread',
'options': {
'threadId': req.body.move_to_thread,
'board': board,
}
});
}
for (let j = 0; j < boardThreadMap[boardName].threads.length; j++) {
buildQueue.push({
'task': 'buildThread',
'options': {
'threadId': boardThreadMap[boardName].threads[j],
'board': board,
}
});
}
//refresh any pages affected
const afterPages = Math.ceil((await Posts.getPages(boardName)) / 10);
let catalogRebuild = true;
if ((beforePages[boardName] && beforePages[boardName] !== afterPages) || req.body.move) { //handle moves here since dates would change and not work in old/new page calculations
if (afterPages < beforePages[boardName]) {
//amount of pages changed, rebuild all pages and delete any further pages (if pages amount decreased)
for (let k = beforePages[boardName]; k > afterPages; k--) {
//deleting html for pages that no longer should exist
parallelPromises.push(remove(`${uploadDirectory}/html/${boardName}/${k}.html`));
parallelPromises.push(remove(`${uploadDirectory}/json/${boardName}/${k}.json`));
}
}
buildQueue.push({
'task': 'buildBoardMultiple',
'options': {
'board': board,
'startpage': 1,
'endpage': afterPages,
}
});
} else {
//number of pages did not change, only possibly building existing pages
const threadPageOldest = await Posts.getThreadPage(boardName, bounds.oldest);
const threadPageNewest = bounds.oldest.postId === bounds.newest.postId ? threadPageOldest : await Posts.getThreadPage(boardName, bounds.newest);
if (req.body.delete || req.body.delete_ip_board || req.body.delete_ip_global) {
if (boardThreadMap[boardName].directThreads.length === 0) {
//only deleting posts from threads, so thread order wont change, thus we dont delete all pages after
buildQueue.push({
'task': 'buildBoardMultiple',
'options': {
'board': board,
'startpage': threadPageNewest,
'endpage': threadPageOldest,
}
});
} else {
//deleting threads, so we delete all pages after
buildQueue.push({
'task': 'buildBoardMultiple',
'options': {
'board': board,
'startpage': threadPageNewest,
'endpage': afterPages,
}
});
}
} else if (req.body.sticky) { //else if -- if deleting, other actions are not executed/irrelevant
//rebuild current and newer pages
buildQueue.push({
'task': 'buildBoardMultiple',
'options': {
'board': board,
'startpage': 1,
'endpage': threadPageOldest,
}
});
} else if (req.body.lock || req.body.bumplock || req.body.cyclic || req.body.unlink_file) {
buildQueue.push({
'task': 'buildBoardMultiple',
'options': {
'board': board,
'startpage': threadPageNewest,
'endpage': threadPageOldest,
}
});
} else if (req.body.spoiler || req.body.ban || req.body.global_ban) {
buildQueue.push({
'task': 'buildBoardMultiple',
'options': {
'board': board,
'startpage': threadPageNewest,
'endpage': afterPages,
}
});
if (boardThreadMap[boardName].directThreads.length === 0) {
catalogRebuild = false;
//these actions dont affect the catalog tile since not on an OP and dont change reply/image counts
}
}
}
if (catalogRebuild) {
//the actions will affect the catalog, so we better rebuild it
buildQueue.push({
'task': 'buildCatalog',
'options': {
'board': board,
}
});
}
}
}
if (parallelPromises.length > 0) {
//since queue changes, this just removing old html files
await Promise.all(parallelPromises);
}
return res.render('message', {
'title': 'Success',
'messages': messages,
redirect,
});
}