diff --git a/inc/Data/ReportQueries.php b/inc/Data/ReportQueries.php new file mode 100644 index 00000000..3c58d945 --- /dev/null +++ b/inc/Data/ReportQueries.php @@ -0,0 +1,243 @@ +bindValue(':id', $post_id, \PDO::PARAM_INT); + $query->bindValue(':board', $board); + $query->execute(); + } + + private function joinReportPosts(array $raw_reports, ?int $limit): array { + // Group the reports rows by board. + $reports_by_boards = []; + foreach ($raw_reports as $report) { + if (!isset($reports_by_boards[$report['board']])) { + $reports_by_boards[$report['board']] = []; + } + $reports_by_boards[$report['board']][] = $report['post']; + } + + // Join the reports with the actual posts. + $report_posts = []; + foreach ($reports_by_boards as $board => $posts) { + $report_posts[$board] = []; + + $query = $this->pdo->prepare(\sprintf('SELECT * FROM `posts_%s` WHERE `id` IN (' . \implode(',', $posts) . ')', $board)); + $query->execute(); + while ($post = $query->fetch(\PDO::FETCH_ASSOC)) { + $report_posts[$board][$post['id']] = $post; + } + } + + // Filter out the reports without a valid post. + $valid = []; + foreach ($raw_reports as $report) { + if (isset($report_posts[$report['board']][$report['post']])) { + $report['post_data'] = $report_posts[$report['board']][$report['post']]; + $valid[] = $report; + + if ($limit !== null && \count($valid) >= $limit) { + return $valid; + } + } else { + // Invalid report (post has been deleted). + if ($this->auto_maintenance != false) { + $this->deleteReportImpl($report['board'], $report['post']); + } + } + } + return $valid; + } + + /** + * Filters out the invalid reports. + * + * @param array $raw_reports Array with the raw fetched reports. Must include a `board`, `post` and `id` fields. + * @param bool $get_invalid True to reverse the filter and get the invalid reports instead. + * @return array An array of filtered reports. + */ + private function filterReports(array $raw_reports, bool $get_invalid): array { + // Group the reports rows by board. + $reports_by_boards = []; + foreach ($raw_reports as $report) { + if (!isset($reports_by_boards[$report['board']])) { + $reports_by_boards[$report['board']] = []; + } + $reports_by_boards[$report['board']][] = $report['post']; + } + + // Join the reports with the actual posts. + $report_posts = []; + foreach ($reports_by_boards as $board => $posts) { + $report_posts[$board] = []; + + $query = $this->pdo->prepare(\sprintf('SELECT `id` FROM `posts_%s` WHERE `id` IN (' . \implode(',', $posts) . ')', $board)); + $query->execute(); + while ($post = $query->fetch(\PDO::FETCH_ASSOC)) { + $report_posts[$board][$post['id']] = $post; + } + } + + if ($get_invalid) { + // Get the reports without a post. + $invalid = []; + if (isset($report_posts[$report['board']][$report['post']])) { + $invalid[] = $report; + } + return $invalid; + } else { + // Filter out the reports without a valid post. + $valid = []; + foreach ($raw_reports as $report) { + if (isset($report_posts[$report['board']][$report['post']])) { + $valid[] = $report; + } else { + // Invalid report (post has been deleted). + if ($this->auto_maintenance != false) { + $this->deleteReportImpl($report['board'], $report['post']); + } + } + } + return $valid; + } + } + + /** + * @param \PDO $pdo PDO connection. + * @param CacheDriver $cache Cache driver. + * @param bool $auto_maintenance If the auto maintenance should be enabled. + */ + public function __construct(\PDO $pdo, CacheDriver $cache, bool $auto_maintenance) { + $this->pdo = $pdo; + $this->cache = $cache; + $this->auto_maintenance = $auto_maintenance; + } + + /** + * Get the number of reports. + * + * @return int The number of reports. + */ + public function getCount(): int { + $ret = $this->cache->get(self::CACHE_KEY); + if ($ret === null) { + $query = $this->pdo->prepare("SELECT `board`, `post`, `id` FROM `reports`"); + $query->execute(); + $raw_reports = $query->fetchAll(\PDO::FETCH_ASSOC); + $valid_reports = $this->filterReports($raw_reports, false, null); + $count = \count($valid_reports); + + $this->cache->set(self::CACHE_KEY, $count); + return $count; + } + return $ret; + } + + /** + * Get the report with the given id. DOES NOT PERFORM VALIDITY CHECK. + * + * @param int $id The id of the report to fetch. + * @return ?array An array of the given report with the `board` and `ip` fields. Null if no such report exists. + */ + public function getReportById(int $id): array { + $query = prepare("SELECT `board`, `ip` FROM ``reports`` WHERE `id` = :id"); + $query->bindValue(':id', $id); + $query->execute(); + + $ret = $query->fetch(\PDO::FETCH_ASSOC); + if ($ret !== false) { + return $ret; + } else { + return null; + } + } + + /** + * Get the reports with the associated post data. + * + * @param int $count The maximum number of rows in the return array. + * @return array The reports with the associated post data. + */ + public function getReportsWithPosts(int $count): array { + $query = $this->pdo->prepare("SELECT * FROM `reports` ORDER BY `time`"); + $query->execute(); + $raw_reports = $query->fetchAll(\PDO::FETCH_ASSOC); + return $this->joinReportPosts($raw_reports, $count); + } + + /** + * Purge the invalid reports. + * + * @return int The number of reports deleted. + */ + public function purge(): int { + $query = $this->pdo->prepare("SELECT `board`, `post`, `id` FROM `reports`"); + $query->execute(); + $raw_reports = $query->fetchAll(\PDO::FETCH_ASSOC); + $invalid_reports = $this->filterReports($raw_reports, true, null); + + foreach ($invalid_reports as $report) { + $this->deleteReportImpl($report['board'], $report['post']); + } + return \count($invalid_reports); + } + + /** + * Deletes the given report. + * + * @param int $id The report id. + */ + public function deleteById(int $id) { + // The caller may actually delete a valid post, so we need to invalidate the cache. + $this->cache->delete(self::CACHE_KEY); + $query = $this->pdo->prepare("DELETE FROM `reports` WHERE `id` = :id"); + $query->bindValue(':id', $id, \PDO::PARAM_INT); + $query->execute(); + } + + /** + * Deletes all reports from the given ip. + * + * @param int $ip The reporter ip. + */ + public function deleteByIp(string $ip) { + // The caller may actually delete a valid post, so we need to invalidate the cache. + $this->cache->delete(self::CACHE_KEY); + $query = $this->pdo->prepare("DELETE FROM `reports` WHERE `ip` = :ip"); + $query->bindValue(':ip', $ip); + $query->execute(); + } + + /** + * Inserts a new report. + * + * @param string $ip Ip of the user sending the report. + * @param string $board_uri Board uri of the reported thread. MUST ALREADY BE SANITIZED. + * @param int $post_id Post reported. + * @param string $reason Reason of the report. + * @return void + */ + public function add(string $ip, string $board_uri, int $post_id, string $reason) { + $query = $this->pdo->prepare('INSERT INTO `reports` VALUES (NULL, :time, :ip, :board, :post, :reason)'); + $query->bindValue(':time', time(), \PDO::PARAM_INT); + $query->bindValue(':ip', $ip); + $query->bindValue(':board', $board_uri); + $query->bindValue(':post', $post_id, \PDO::PARAM_INT); + $query->bindValue(':reason', $reason); + $query->execute(); + + $this->cache->delete(self::CACHE_KEY); + } +} diff --git a/inc/context.php b/inc/context.php index 137f6ebd..6ff656fe 100644 --- a/inc/context.php +++ b/inc/context.php @@ -2,6 +2,7 @@ namespace Vichan; use Vichan\Data\Driver\CacheDriver; +use Vichan\Data\ReportQueries; defined('TINYBOARD') or exit; @@ -33,6 +34,18 @@ function build_context(array $config): Context { CacheDriver::class => function($c) { // Use the global for backwards compatibility. return \cache::getCache(); + }, + \PDO::class => function($c) { + global $pdo; + // Ensure the PDO is initialized. + sql_open(); + return $pdo; + }, + ReportQueries::class => function($c) { + $auto_maintenance = (bool)$c->get('config')['auto_maintenance']; + $pdo = $c->get(\PDO::class); + $cache = $c->get(CacheDriver::class); + return new ReportQueries($pdo, $cache, $auto_maintenance); } ]); } diff --git a/inc/mod/pages.php b/inc/mod/pages.php index 050e1216..9f569c77 100644 --- a/inc/mod/pages.php +++ b/inc/mod/pages.php @@ -3,6 +3,7 @@ * Copyright (c) 2010-2013 Tinyboard Development Group */ use Vichan\Context; +use Vichan\Data\ReportQueries; use Vichan\Functions\Format; use Vichan\Functions\Net; @@ -2891,58 +2892,21 @@ function mod_reports(Context $ctx) { error($config['error']['noaccess']); $reports_limit = $config['mod']['recent_reports']; + $report_queries = $ctx->get(ReportQueries::class); + $report_rows = $report_queries->getReportsWithPosts($reports_limit); - $query = prepare("SELECT * FROM ``reports`` ORDER BY `time` DESC LIMIT :limit"); - $query->bindValue(':limit', $reports_limit + 1, PDO::PARAM_INT); - $query->execute() or error(db_error($query)); - $reports = $query->fetchAll(PDO::FETCH_ASSOC); - - $report_queries = []; - foreach ($reports as $report) { - if (!isset($report_queries[$report['board']])) { - $report_queries[$report['board']] = []; - } - $report_queries[$report['board']][] = $report['post']; - } - - $report_posts = []; - foreach ($report_queries as $board => $posts) { - $report_posts[$board] = []; - - $query = query(\sprintf('SELECT * FROM ``posts_%s`` WHERE `id` IN (' . \implode(',', $posts) . ')', $board)) or error(db_error()); - while ($post = $query->fetch(PDO::FETCH_ASSOC)) { - $report_posts[$board][$post['id']] = $post; - } - } - - $to_build = []; - foreach ($reports as $report) { - if (isset($report_posts[$report['board']][$report['post']])) { - $to_build[] = $report; - } else { - // Invalid report (post has since been deleted) - if ($config['auto_maintenance'] != false) { - $query = prepare("DELETE FROM ``reports`` WHERE `post` = :id AND `board` = :board"); - $query->bindValue(':id', $report['post'], PDO::PARAM_INT); - $query->bindValue(':board', $report['board']); - $query->execute() or error(db_error($query)); - } - continue; - } - } - - if (\count($to_build) > $reports_limit) { - \array_pop($to_build); + if (\count($report_rows) > $reports_limit) { + \array_pop($report_rows); $has_extra = true; } else { $has_extra = false; } $body = ''; - foreach ($to_build as $report) { + foreach ($report_rows as $report) { openBoard($report['board']); - $post = &$report_posts[$report['board']][$report['post']]; + $post = $report['post_data']; if (!$post['thread']) { // Still need to fix this: @@ -2951,7 +2915,7 @@ function mod_reports(Context $ctx) { $po = new Post($post, '?/', $mod); } - // a little messy and inefficient + // A little messy and inefficient. $append_html = Element('mod/report.html', array( 'report' => $report, 'config' => $config, @@ -2978,7 +2942,7 @@ function mod_reports(Context $ctx) { } } - $count = \count($to_build); + $count = \count($report_rows); $header_count = $has_extra ? "{$count}+" : (string)$count; mod_page( @@ -2991,36 +2955,31 @@ function mod_reports(Context $ctx) { function mod_report_dismiss(Context $ctx, $id, $all = false) { global $config; - $query = prepare("SELECT `post`, `board`, `ip` FROM ``reports`` WHERE `id` = :id"); - $query->bindValue(':id', $id); - $query->execute() or error(db_error($query)); - if ($report = $query->fetch(PDO::FETCH_ASSOC)) { - $ip = $report['ip']; - $board = $report['board']; - $post = $report['post']; - } else + $report_queries = $ctx->get(ReportQueries::class); + $report = $report_queries->getReportById($id); + + if ($report === null) { error($config['error']['404']); + } - if (!$all && !hasPermission($config['mod']['report_dismiss'], $board)) - error($config['error']['noaccess']); + $ip = $report['ip']; + $board = $report['board']; - if ($all && !hasPermission($config['mod']['report_dismiss_ip'], $board)) + if (!$all && !hasPermission($config['mod']['report_dismiss'], $board)) { error($config['error']['noaccess']); + } + + if ($all && !hasPermission($config['mod']['report_dismiss_ip'], $board)) { + error($config['error']['noaccess']); + } if ($all) { - $query = prepare("DELETE FROM ``reports`` WHERE `ip` = :ip"); - $query->bindValue(':ip', $ip); - } else { - $query = prepare("DELETE FROM ``reports`` WHERE `id` = :id"); - $query->bindValue(':id', $id); - } - $query->execute() or error(db_error($query)); - - - if ($all) + $report_queries->deleteByIp($ip); modLog("Dismissed all reports by $ip"); - else + } else { + $report_queries->deleteById($id); modLog("Dismissed a report for post #{$id}", $board); + } header('Location: ?/reports', true, $config['redirect_http']); } diff --git a/post.php b/post.php index 78a1bb90..642e8057 100644 --- a/post.php +++ b/post.php @@ -4,6 +4,7 @@ */ use Vichan\Context; +use Vichan\Data\ReportQueries; require_once 'inc/bootstrap.php'; @@ -318,26 +319,6 @@ function db_select_post_minimal($board, $id) return $post; } -/** - * Inserts a new report. - * - * @param string $ip Ip of the user sending the report. - * @param string $board Board of the reported thread. MUST ALREADY BE SANITIZED. - * @param int $post_id Post reported. - * @param string $reason Reason of the report. - * @return void - */ -function db_insert_report($ip, $board, $post_id, $reason) -{ - $query = prepare("INSERT INTO ``reports`` VALUES (NULL, :time, :ip, :board, :post, :reason)"); - $query->bindValue(':time', time(), PDO::PARAM_INT); - $query->bindValue(':ip', $ip, PDO::PARAM_STR); - $query->bindValue(':board', $board, PDO::PARAM_STR); - $query->bindValue(':post', $post_id, PDO::PARAM_INT); - $query->bindValue(':reason', $reason, PDO::PARAM_STR); - $query->execute() or error(db_error($query)); -} - /** * Inserts a new ban appeal into the database. * @@ -711,6 +692,8 @@ function handle_report(Context $ctx) $reason = escape_markup_modifiers($_POST['reason']); markup($reason); + $report_queries = $ctx->get(ReportQueries::class); + foreach ($report as $id) { $post = db_select_post_minimal($board['uri'], $id); if ($post === false) { @@ -739,7 +722,7 @@ function handle_report(Context $ctx) ' for "' . $reason . '"' ); - db_insert_report($_SERVER['REMOTE_ADDR'], $board['uri'], $id, $reason); + $report_queries->add($_SERVER['REMOTE_ADDR'], $board['uri'], $id, $reason); if ($config['slack']) { function slack($message, $room = "reports", $icon = ":no_entry_sign:")