diff --git a/inc/config.php b/inc/config.php index d90cebee..4fe77791 100644 --- a/inc/config.php +++ b/inc/config.php @@ -839,7 +839,9 @@ // Maximum image dimensions. $config['max_width'] = 10000; $config['max_height'] = $config['max_width']; - // Reject duplicate image uploads. + // Reject the same image being uploaded twice in one post. + $config['image_reject_multipost'] = true; + // Reject duplicate image uploads on each board. $config['image_reject_repost'] = true; // Reject duplicate image uploads within the same thread. Doesn't change anything if // $config['image_reject_repost'] is true. @@ -1173,8 +1175,11 @@ $config['error']['invalidwebm'] = _('Invalid webm uploaded.'); $config['error']['webmhasaudio'] = _('The uploaded webm contains an audio or another type of additional stream.'); $config['error']['webmtoolong'] =_('The uploaded webm is longer than %d seconds.'); + $config['error']['fileduplicate'] = _('You can\'t add duplicates of same file!'); + $config['error']['filebanned'] = _('Error posting file.'); $config['error']['fileexists'] = _('That file already exists!'); $config['error']['fileexistsinthread'] = _('That file already exists in this thread!'); + $config['error']['delete_too_soon'] = _('You\'ll have to wait another %s before deleting that.'); $config['error']['mime_exploit'] = _('MIME type detection XSS exploit (IE) detected; post discarded.'); $config['error']['invalid_embed'] = _('Couldn\'t make sense of the URL of the video you tried to embed.'); @@ -1371,6 +1376,7 @@ $config['mod']['link_ban'] = '[B]'; $config['mod']['link_bandelete'] = '[B&D]'; $config['mod']['link_deletefile'] = '[F]'; + $config['mod']['link_deletefilepermaban'] = '[FPb]'; $config['mod']['link_spoilerimage'] = '[S]'; $config['mod']['link_deletebyip'] = '[D+]'; $config['mod']['link_deletebyip_global'] = '[D++]'; diff --git a/inc/functions.php b/inc/functions.php index 1297c30c..a5cb9f07 100755 --- a/inc/functions.php +++ b/inc/functions.php @@ -1115,7 +1115,32 @@ function post(array $post) { error(db_error($query)); } - return $pdo->lastInsertId(); + // Save Post ID + $postID = $pdo->lastInsertId(); + + // Add file-hashes to database + if($post['has_file']) + { + // If OP then thread ID is same as post ID + $threadID = (!isset($post['thread']) || $post['op'])?$postID:$post['thread']; + + // Get all filehashes for post + $hashes = explode(":", $post['allhashes']); + $hc = count($hashes); + for($i=0; $i<$hc;$i++) + { + // Build entry for database + $query = prepare(sprintf("INSERT INTO ``filehashes`` VALUES ( NULL, '%s', :thread, :postid, :filehash)", $board['uri'])); + $query->bindValue(':thread', $threadID, PDO::PARAM_INT); + $query->bindValue(':postid', $postID, PDO::PARAM_INT); + $query->bindValue(':filehash', $hashes[$i]); + // Add entry to database + $query->execute() or error(db_error($query)); + } + } + + // Return Post ID + return $postID; } function bumpThread($id) { @@ -1151,6 +1176,76 @@ function deleteFile($id, $remove_entirely_if_already=true, $file=null) { if ($files[0]->file == 'deleted' && $post['num_files'] == 1 && !$post['thread']) return; // Can't delete OP's image completely. + // Delete filehash from filehashes table + if($file == null) + { + $query = prepare(sprintf("DELETE FROM ``filehashes`` WHERE `thread` = :thread AND `board` = '%s' AND `post`= :id", $board['uri'])); + $query->bindValue(':thread', $post['thread'], PDO::PARAM_INT); + $query->bindValue(':id', $id, PDO::PARAM_INT); + $query->execute() or error(db_error($query)); + } else { + $query = prepare(sprintf("DELETE FROM ``filehashes`` WHERE `thread` = :thread AND `board` = '%s' AND `filehash`= :hash", $board['uri'])); + $query->bindValue(':thread', $post['thread'], PDO::PARAM_INT); + $query->bindValue(':hash', $file_to_delete->hash, PDO::PARAM_STR); + $query->execute() or error(db_error($query)); + } + + $query = prepare(sprintf("UPDATE ``posts_%s`` SET `files` = :file WHERE `id` = :id", $board['uri'])); + if (($file && $file_to_delete->file == 'deleted') && $remove_entirely_if_already) { + // Already deleted; remove file fully + $files[$file] = null; + } else { + foreach ($files as $i => $f) { + if (($file !== false && $i == $file) || $file === null) { + // Delete thumbnail + if (isset ($f->thumb) && $f->thumb) { + file_unlink($board['dir'] . $config['dir']['thumb'] . $f->thumb); + unset($files[$i]->thumb); + } + + // Delete file + file_unlink($board['dir'] . $config['dir']['img'] . $f->file); + $files[$i]->file = 'deleted'; + } + } + } + + $query->bindValue(':file', json_encode($files), PDO::PARAM_STR); + + $query->bindValue(':id', $id, PDO::PARAM_INT); + $query->execute() or error(db_error($query)); + + if ($post['thread']) + buildThread($post['thread']); + else + buildThread($id); +} + +// Remove file from post +function deleteFilePermaban($id, $remove_entirely_if_already=true, $file=null) { + global $board, $config; + + $query = prepare(sprintf("SELECT `thread`, `files`, `num_files` FROM ``posts_%s`` WHERE `id` = :id LIMIT 1", $board['uri'])); + $query->bindValue(':id', $id, PDO::PARAM_INT); + $query->execute() or error(db_error($query)); + if (!$post = $query->fetch(PDO::FETCH_ASSOC)) + error($config['error']['invalidpost']); + $files = json_decode($post['files']); + $file_to_delete = $file !== false ? $files[(int)$file] : (object)array('file' => false); + + if (!$files[0]) error(_('That post has no files.')); + + if ($files[0]->file == 'deleted' && $post['num_files'] == 1 && !$post['thread']) + return; // Can't delete OP's image completely. + + + // Delete filehash from filehashes table + $query = prepare(sprintf("UPDATE ``filehashes`` SET `board` = '%s' WHERE `thread` = :thread AND `board` = '%s' AND `filehash`= :hash", "permaban", $board['uri'])); + $query->bindValue(':thread', $post['thread'], PDO::PARAM_INT); + $query->bindValue(':hash', $file_to_delete->hash, PDO::PARAM_STR); + $query->execute() or error(db_error($query)); + + $query = prepare(sprintf("UPDATE ``posts_%s`` SET `files` = :file WHERE `id` = :id", $board['uri'])); if (($file && $file_to_delete->file == 'deleted') && $remove_entirely_if_already) { // Already deleted; remove file fully @@ -1262,6 +1357,11 @@ function deletePost($id, $error_if_doesnt_exist=true, $rebuild_after=true) { $query->bindValue(':id', $id, PDO::PARAM_INT); $query->execute() or error(db_error($query)); + // Delete filehash entries for thread from filehash table + $query = prepare(sprintf("DELETE FROM ``filehashes`` WHERE ( `thread` = :id OR `post` = :id ) AND `board` = '%s'", $board['uri'])); + $query->bindValue(':id', $id, PDO::PARAM_INT); + $query->execute() or error(db_error($query)); + $query = prepare("SELECT `board`, `post` FROM ``cites`` WHERE `target_board` = :board AND (`target` = " . implode(' OR `target` = ', $ids) . ") ORDER BY `board`"); $query->bindValue(':board', $board['uri']); $query->execute() or error(db_error($query)); @@ -2618,30 +2718,78 @@ function fraction($numerator, $denominator, $sep) { return "{$numerator}{$sep}{$denominator}"; } -function getPostByHash($hash) { +function getHashPermabanned($hash) { global $board; - $query = prepare(sprintf("SELECT `id`,`thread` FROM ``posts_%s`` WHERE `filehash` = :hash", $board['uri'])); + $query = prepare(sprintf("SELECT `id` FROM ``filehashes`` WHERE `filehash` = :hash AND `board` = '%s'", "permaban")); $query->bindValue(':hash', $hash, PDO::PARAM_STR); $query->execute() or error(db_error($query)); - if ($post = $query->fetch(PDO::FETCH_ASSOC)) { - return $post; - } + return $query->fetch(PDO::FETCH_ASSOC); +} +// Function to check all posts on entire board for file hash +function getPostByAllHash($allhashes) +{ + global $board; + $hashes = explode(":", $allhashes); + foreach($hashes as $hash) + { + // Search for filehash + $query = prepare("SELECT `post` AS `post`, `thread` FROM `filehashes` WHERE `filehash` = :hash AND `board` = :board"); + $query->bindValue(':hash', $hash, PDO::PARAM_STR); + $query->bindValue(':board', $board['uri'], PDO::PARAM_STR); + $query->execute() or error(db_error($query)); + + // Return result if found + if ($post = $query->fetch(PDO::FETCH_ASSOC)) { + return $post; + } + } + // Return false if no matching hash found return false; } -function getPostByHashInThread($hash, $thread) { +// Function to check all posts in thread for file hash +function getPostByAllHashInThread($allhashes, $thread) +{ global $board; - $query = prepare(sprintf("SELECT `id`,`thread` FROM ``posts_%s`` WHERE `filehash` = :hash AND ( `thread` = :thread OR `id` = :thread )", $board['uri'])); - $query->bindValue(':hash', $hash, PDO::PARAM_STR); - $query->bindValue(':thread', $thread, PDO::PARAM_INT); - $query->execute() or error(db_error($query)); + $hashes = explode(":", $allhashes); + foreach($hashes as $hash) + { + $query = prepare("SELECT `post` AS `post`,`thread` FROM `filehashes` WHERE `filehash` = :hash AND `board` = :board AND `thread` = :thread"); + $query->bindValue(':hash', $hash, PDO::PARAM_STR); + $query->bindValue(':thread', $thread, PDO::PARAM_INT); + $query->bindValue(':board', $board['uri'], PDO::PARAM_STR); + $query->execute() or error(db_error($query)); - if ($post = $query->fetch(PDO::FETCH_ASSOC)) { - return $post; + // Return result if found + if ($post = $query->fetch(PDO::FETCH_ASSOC)) { + return $post; + } } + // Return false if no matching hash found + return false; +} +// Function to check all OP posts in board for file hash +function getPostByAllHashInOP($allhashes) +{ + global $board; + $hashes = explode(":", $allhashes); + foreach($hashes as $hash) + { + // Search for filehash amongst OP images + $query = prepare("SELECT `post` AS `post`,`thread` FROM `filehashes` WHERE `filehash` = :hash AND `board` = :board AND `thread` = `post`"); + $query->bindValue(':hash', $hash, PDO::PARAM_STR); + $query->bindValue(':board', $board['uri'], PDO::PARAM_STR); + $query->execute() or error(db_error($query)); + + // Return result if found + if ($post = $query->fetch(PDO::FETCH_ASSOC)) { + return $post; + } + } + // Return false if no matching hash found return false; } diff --git a/inc/mod/pages.php b/inc/mod/pages.php index 2595623f..86ea2d2a 100644 --- a/inc/mod/pages.php +++ b/inc/mod/pages.php @@ -1788,6 +1788,30 @@ function mod_deletefile($board, $post, $file) { header('Location: ?/' . sprintf($config['board_path'], $board) . $config['file_index'], true, $config['redirect_http']); } +// Delete and Permaban Image +function mod_deletefilepermaban($board, $post, $file) { + global $config, $mod; + + if (!openBoard($board)) + error($config['error']['noboard']); + + if (!hasPermission($config['mod']['deletefile'], $board)) + error($config['error']['noaccess']); + + // Delete file + deleteFilePermaban($post, TRUE, $file); + // Record the action + modLog("Deleted and Permabanned file from post #{$post}"); + + // Rebuild board + buildIndex(); + // Rebuild themes + rebuildThemes('post-delete', $board); + + // Redirect + header('Location: ?/' . sprintf($config['board_path'], $board) . $config['file_index'], true, $config['redirect_http']); +} + function mod_spoiler_image($board, $post, $file) { global $config, $mod; diff --git a/install.php b/install.php index 37fb2b42..4e86923b 100644 --- a/install.php +++ b/install.php @@ -1,7 +1,7 @@ vichan upgrade path. query("CREATE TABLE IF NOT EXISTS ``search_queries`` ( `ip` varchar(39) NOT NULL, `time` int(11) NOT NULL, `query` text NOT NULL) ENGINE=MyISAM DEFAULT CHARSET=utf8;") or error(db_error()); diff --git a/install.sql b/install.sql index 0c6e44eb..7ef1a072 100644 --- a/install.sql +++ b/install.sql @@ -260,6 +260,24 @@ CREATE TABLE IF NOT EXISTS `theme_settings` ( KEY `theme` (`theme`) ) ENGINE=MyISAM DEFAULT CHARSET=utf8mb4; + +-- -------------------------------------------------------- + +-- +-- Table structure for table `filehashes` +-- + +CREATE TABLE IF NOT EXISTS `filehashes` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `board` varchar(58) NOT NULL, + `thread` int(11) NOT NULL, + `post` int(11) NOT NULL, + `filehash` text CHARACTER SET ascii NOT NULL, + PRIMARY KEY (`id`), + KEY `thread_id` (`thread`), + KEY `post_id` (`post`) +) ENGINE=MyISAM DEFAULT CHARSET=utf8; + -- -------------------------------------------------------- -- diff --git a/mod.php b/mod.php index 35288c12..946d9c5b 100644 --- a/mod.php +++ b/mod.php @@ -79,6 +79,7 @@ $pages = array( '/(\%b)/edit(_raw)?/(\d+)' => 'secure_POST edit_post', // edit post '/(\%b)/delete/(\d+)' => 'secure delete', // delete post '/(\%b)/deletefile/(\d+)/(\d+)' => 'secure deletefile', // delete file from post + '/(\%b)/deletefilepermaban/(\d+)/(\d+)' => 'secure deletefilepermaban', // delete file from post and permaban it '/(\%b+)/spoiler/(\d+)/(\d+)' => 'secure spoiler_image', // spoiler file '/(\%b)/deletebyip/(\d+)(/global)?' => 'secure deletebyip', // delete all posts by IP address '/(\%b)/(un)?lock/(\d+)' => 'secure lock', // lock thread diff --git a/post.php b/post.php index 812f31c9..0cbd0567 100644 --- a/post.php +++ b/post.php @@ -860,9 +860,14 @@ if (isset($_POST['delete'])) { } $file['hash'] = $hash; - $allhashes .= $hash; + // Add Hashes as an imploded string + $allhashes .= $hash . ":"; } + // Remove exsessive ":" from imploded list + $allhashes = substr_replace($allhashes, "", -1); + $post['allhashes'] = $allhashes; + if (count ($post['files']) == 1) { $post['filehash'] = $hash; } @@ -1063,34 +1068,66 @@ if (isset($_POST['delete'])) { } } + // Check if multiple images attached and if same image is added more than once + if ($config['image_reject_multipost']) { + $hashArray = explode(":", $post['allhashes']); + $hashCount = count($hashArray); + for($i=0; $i<$hashCount; $i++) { + for($j=0; $j<$hashCount; $j++) { + if($i != $j && $hashArray[$i] == $hashArray[$j]) { + undoImage($post); + error($config['error']['fileduplicate']); + } + } + } + } + if (getHashPermabanned($post['allhashes'])) { + undoImage($post); + error($config['error']['filebanned']); + } + if ($config['image_reject_repost']) { - if ($p = getPostByHash($post['filehash'])) { + if ($p = getPostByAllHash($post['allhashes'])) { undoImage($post); error(sprintf($config['error']['fileexists'], ($post['mod'] ? $config['root'] . $config['file_mod'] . '?/' : $config['root']) . ($board['dir'] . $config['dir']['res'] . ($p['thread'] ? - $p['thread'] . '.html#' . $p['id'] + $p['thread'] . '.html#' . $p['post'] : - $p['id'] . '.html' + $p['post'] . '.html' )) )); } } else if (!$post['op'] && $config['image_reject_repost_in_thread']) { - if ($p = getPostByHashInThread($post['filehash'], $post['thread'])) { + if ($p = getPostByAllHashInThread($post['allhashes'], $post['thread'])) { undoImage($post); error(sprintf($config['error']['fileexistsinthread'], ($post['mod'] ? $config['root'] . $config['file_mod'] . '?/' : $config['root']) . ($board['dir'] . $config['dir']['res'] . ($p['thread'] ? - $p['thread'] . '.html#' . $p['id'] + $p['thread'] . '.html#' . $p['post'] : - $p['id'] . '.html' + $p['post'] . '.html' + )) + )); + } + } else if ($post['op'] && $config['image_reject_repost_in_thread']) { + // Check all OP images and see if any have been used before + if ($p = getPostByAllHashInOP($post['allhashes'])) { + undoImage($post); + error(sprintf($config['error']['fileexistsinthread'], + ($post['mod'] ? $config['root'] . $config['file_mod'] . '?/' : $config['root']) . + ($board['dir'] . $config['dir']['res'] . + ($p['thread'] ? + $p['thread'] . '.html#' . $p['post'] + : + $p['post'] . '.html' )) )); } } - } + } // Do filters again if OCRing if ($config['tesseract_ocr'] && !hasPermission($config['mod']['bypass_filters'], $board['uri']) && !$dropped_post) { diff --git a/templates/post/file_controls.html b/templates/post/file_controls.html index 6855a293..36011aea 100644 --- a/templates/post/file_controls.html +++ b/templates/post/file_controls.html @@ -3,6 +3,9 @@ {% if file.file != 'deleted' and mod|hasPermission(config.mod.deletefile, board.uri) %} {{ secure_link_confirm(config.mod.link_deletefile, 'Delete file'|trans, 'Are you sure you want to delete this file?'|trans, board.dir ~ 'deletefile/' ~ post.id ~ '/' ~ loop.index0 ) }}  {% endif %} +{% if file.file != 'deleted' and mod|hasPermission(config.mod.deletefile, board.uri) %} +{{ secure_link_confirm(config.mod.link_deletefilepermaban, 'Delete file and Permaban File'|trans, 'Are you sure you want to delete and permaban this file?'|trans, board.dir ~ 'deletefilepermaban/' ~ post.id ~ '/' ~ loop.index0 ) }}  +{% endif %} {% if file.file and file.file != 'deleted' and file.thumb != 'spoiler' and mod|hasPermission(config.mod.spoilerimage, board.uri) %} {{ secure_link_confirm(config.mod.link_spoilerimage, 'Spoiler file'|trans, 'Are you sure you want to spoiler this file?'|trans, board.dir ~ 'spoiler/' ~ post.id ~ '/' ~ loop.index0 ) }} {% endif %}