'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' )
, getAffectedBoards = require ( _ _dirname + '/../../helpers/affectedboards.js' )
, dynamicResponse = require ( _ _dirname + '/../../helpers/dynamic.js' )
, buildQueue = require ( _ _dirname + '/../../queue.js' )
, { postPasswordSecret } = require ( _ _dirname + '/../../configs/main.js' )
, threadRegex = /\/[a-z0-9]+\/(?:manage\/)?thread\/(\d+)\.html/i
, { createHash , timingSafeEqual } = require ( 'crypto' ) ;
module . exports = async ( req , res , next ) => {
let redirect = req . headers . referer ;
if ( ! redirect ) {
if ( ! req . params . board ) {
redirect = '/globalmanage/recent.html' ;
} else {
redirect = ` / ${ req . params . board } / ${ req . path . endsWith ( 'modactions' ) ? 'manage/reports' : 'index' } .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 . postpassword && req . body . postpassword . length > 0 ) {
//hash their input and make it a buffer
const inputPasswordHash = createHash ( 'sha256' ) . update ( postPasswordSecret + req . body . postpassword ) . 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 dynamicResponse ( req , res , 403 , 'message' , {
'title' : 'Forbidden' ,
'error' : 'Password did not match any selected posts' ,
redirect ,
} ) ;
}
res . locals . posts = passwordPosts
}
//affected boards, list and page numbers
const deleting = req . body . delete || req . body . delete _ip _board || req . body . delete _ip _global || req . body . delete _ip _thread ;
let { boardThreadMap , beforePages , threadBoards } = await getAffectedBoards ( res . locals . posts , deleting ) ;
if ( deleting
&& req . params . board
&& req . headers . referer
&& boardThreadMap [ req . params . board ] ) {
const threadRefMatch = req . headers . referer . match ( threadRegex ) ;
if ( threadRefMatch && boardThreadMap [ req . params . board ] . directThreads . has ( + threadRefMatch [ 1 ] ) ) {
redirect = ` / ${ req . params . board } / ${ req . path . endsWith ( 'modactions' ) ? 'manage/' : '' } index.html ` ;
}
}
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 ( deleting ) {
const postsBefore = res . locals . posts . length ;
if ( req . body . delete _ip _board || req . body . delete _ip _global || req . body . delete _ip _thread ) {
const deletePostIps = res . locals . posts . map ( x => x . ip . single ) ;
const deletePostMongoIds = res . locals . posts . map ( x => x . _id )
let query = {
'_id' : {
'$nin' : deletePostMongoIds
} ,
'ip.single' : {
'$in' : deletePostIps
}
} ;
if ( req . body . delete _ip _thread ) {
const ips _threads = [ ... boardThreadMap [ req . params . board ] . threads ] ;
query [ 'board' ] = req . params . board ;
query [ '$or' ] = [
{
'thread' : {
'$in' : ips _threads
}
} ,
{
'postId' : {
'$in' : ips _threads
}
}
] ;
} else 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 ( res . locals . posts . length > postsBefore ) {
//recalc for extra fetched posts
const updatedAffected = await getAffectedBoards ( res . locals . posts , deleting ) ;
boardThreadMap = updatedAffected . boardThreadMap ;
beforePages = updatedAffected . beforePages ;
threadBoards = updatedAffected . threadBoards ;
}
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 ,
showUser : ! req . body . hide _name || logUser === 'Unregistered User' ? true : false ,
message : message ,
user : logUser ,
ip : res . locals . ip . single ,
} ;
}
//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 ) ;
//TODO: change query to fetch threads and group into bumplocked/normal, and only reset bump date on non-bumplocked and ignore sages
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
const aggregateSet = {
//todo here: check if thread bumplocked or beyond bump limit
'replyposts' : threadAggregate . replyposts ,
'replyfiles' : threadAggregate . replyfiles ,
'bumped' : threadAggregate . bumped
}
bulkWrites . push ( {
'updateOne' : {
'filter' : {
'postId' : threadAggregate . _id . thread ,
'board' : threadAggregate . _id . board
} ,
'update' : {
'$set' : aggregateSet ,
}
}
} ) ;
}
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 ( deleting ) {
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 dynamicResponse ( req , res , 200 , 'message' , {
'title' : 'Success' ,
'messages' : messages ,
redirect ,
} ) ;
}