diff --git a/inc/Controller/FloodManager.php b/inc/Controller/FloodManager.php new file mode 100644 index 00000000..c3231f33 --- /dev/null +++ b/inc/Controller/FloodManager.php @@ -0,0 +1,92 @@ +filterService = $filterService; + $this->floodService = $floodService; + $this->notes = $notes; + $this->logger = $logger; + } + + /** + * Processes an incoming post through the filter and flood system. + * + * Applies filters first. If a filter matches, returns the filter result + * If no filter matches, records a flood entry, purges old entries, and returns null. + * Handles critical errors internally. + * + * @param array $post The post data array. + * @return array|null The matching filter configuration if found, otherwise null. + * Returns null also if a critical error occurs during processing. + */ + public function processPost(array $post): ?array { + try { + $filterResult = $this->filterService->applyFilters($post); + + if ($filterResult !== null) { + if (isset($filterResult['add_note']) && $filterResult['add_note']) { + $this->notes->add( + $post['ip'], -1, 'Autoban message: ' . $post['body'] + ); + } + return $filterResult; + } + + $this->floodService->recordFloodEntry($post); + + $this->floodService->purgeOldEntries(); + + return null; + } catch (\Throwable $e) { + $this->logger->log( + LogDriver::ERROR, + "Critical error in flood filtering system: " . $e->getMessage() + ); + return null; + } + } +} diff --git a/inc/Data/FloodQueries.php b/inc/Data/FloodQueries.php new file mode 100644 index 00000000..0f6621b4 --- /dev/null +++ b/inc/Data/FloodQueries.php @@ -0,0 +1,96 @@ +pdo = $pdo; + } + + /** + * Retrieves recent flood entries matching IP, post hash, or optionally file hash if a post has files. + * + * @param string $ip The poster's IP address. + * @param string $postHash The hash of the post body content. + * @param string|null $fileHash Optional hash of the attached file. + * @return array> An array of matching flood records. + */ + public function getRecentFloodEntries(string $ip, string $postHash, ?string $fileHash = null): array { + $sql = "SELECT * FROM `flood` WHERE `ip` = :ip OR `posthash` = :posthash"; + + if ($fileHash) { + $sql .= " OR `filehash` = :filehash"; + } + + $stmt = $this->pdo->prepare($sql); + $stmt->bindValue(':ip', $ip); + $stmt->bindValue(':posthash', $postHash); + + if ($fileHash) { + $stmt->bindValue(':filehash', $fileHash); + } + + $stmt->execute(); + return $stmt->fetchAll(\PDO::FETCH_ASSOC); + } + + /** + * Inserts a new record into the flood table. + * + * @param string $ip The poster's IP address. + * @param string $posthash The hash of the post body content. + * @param string|null $filehash The hash of the attached file, or null if post has no files. + * @param string $board The board URI where the post was made. + * @param int $time The unix timestamp of when the post was made. + * @param bool $isreply True if the post was a reply, false if it was a new thread. + */ + public function addFloodEntry( + string $ip, + string $posthash, + ?string $filehash, + string $board, + int $time, + bool $isreply + ): void { + $sql = "INSERT INTO `flood` (`ip`, `posthash`, `filehash`, `board`, `time`, `isreply`) + VALUES (:ip, :posthash, :filehash, :board, :time, :isreply)"; + + $stmt = $this->pdo->prepare($sql); + $stmt->bindValue(':ip', $ip); + $stmt->bindValue(':posthash', $posthash); + $stmt->bindValue(':filehash', $filehash, $filehash === null ? \PDO::PARAM_NULL : \PDO::PARAM_STR); + $stmt->bindValue(':board', $board); + $stmt->bindValue(':time', $time, \PDO::PARAM_INT); + $stmt->bindValue(':isreply', $isreply, \PDO::PARAM_BOOL); + + $stmt->execute(); + } + + /** + * Deletes flood records older than a specified time window. + * + * @param int $maxTime The maximum age (in seconds) an entry should have to be kept. + * Entries older than (current_time - maxTime) will be deleted. + */ + public function purgeOldEntries(int $maxTime): void { + $time = \time() - $maxTime; + $sql = "DELETE FROM `flood` WHERE `time` < :time"; + + $stmt = $this->pdo->prepare($sql); + $stmt->bindValue(':time', $time, \PDO::PARAM_INT); + $stmt->execute(); + } +} diff --git a/inc/Service/FilterService.php b/inc/Service/FilterService.php new file mode 100644 index 00000000..b90abfdd --- /dev/null +++ b/inc/Service/FilterService.php @@ -0,0 +1,500 @@ +> Loaded filter configurations. + */ + private array $filters; + + /** + * @var FloodService Handles retrieval of flood data and checking flood conditions for filters. + */ + private FloodService $floodService; + + /** + * @var LogDriver Logger instance. + */ + private LogDriver $logger; + + + /** + * Filter service constructor + * + * @param array> $filters The config filters. + * @param FloodService $floodService The FloodService. + * @param LogDriver $logger The LogDriver. + */ + public function __construct(array $filters, FloodService $floodService, LogDriver $logger) { + $this->filters = $filters; + $this->floodService = $floodService; + $this->logger = $logger; + } + + /** + * Applies all filters to a post and returns the first matching filter. + * + * @param array $post The post array. + * @return array|null The matching filter configuration or null if no filter matched. + * @throws \Throwable Only if there's a critical error that can't be handled internally + */ + public function applyFilters(array $post): ?array { + $floodEntries = $this->floodService->getFloodEntries($post); + + foreach ($this->filters as $index => $filter) { + try { + if ($this->checkFilter($filter['condition'], $post, $floodEntries)) { + return $filter; + } + } catch (\Throwable $e) { + $this->logger->log( + LogDriver::ERROR, + "Filter #{$index} failed: {$e->getMessage()}\nDetails: " . + \json_encode($filter, \JSON_PRETTY_PRINT) + ); + } + } + + return null; + } + + /** + * Checks if a post matches all conditions defined within a single filter. + * + * @param array $conditions The conditions for this specific filter. + * @param array $post The post data array. + * @param array> $floodEntries Recent post history for flood checks. + * @return bool True if the post matches all conditions, false otherwise. + * @throws \RuntimeException If evaluation of a specific condition fails. + */ + private function checkFilter(array $conditions, array $post, array $floodEntries): bool { + foreach ($conditions as $condition => $value) { + try { + $this->validateType($condition, 'string', 'filter condition'); + + $negate = $condition[0] === '!'; + $conditionName = $negate ? \substr($condition, 1) : $condition; + + if (empty($conditionName)) { + throw new \InvalidArgumentException("Empty condition name after negation"); + } + + $result = $this->matchCondition($conditionName, $value, $post, $floodEntries, $conditions); + + if ($result === $negate) { + return false; + } + } catch (\Throwable $e) { + throw new \RuntimeException( + "Error in condition '$condition': {$e->getMessage()}", + 0, + $e + ); + } + } + + return true; + } + + /** + * Validates that a value is of a expected type. + * + * @param mixed $value The value to check. + * @param string $expectedType The required type ('string', 'array', 'numeric'). + * @param string $fieldName A descriptive name of the field/value being checked for error messages. + * @throws \InvalidArgumentException If the type of $value does not match $expectedType. + */ + private function validateType(mixed $value, string $expectedType, string $fieldName): void { + $isValid = false; + + switch ($expectedType) { + case 'string': + $isValid = \is_string($value); + break; + case 'array': + $isValid = \is_array($value); + break; + case 'numeric': + $isValid = \is_numeric($value); + break; + default: + break; + } + + if (!$isValid) { + throw new \InvalidArgumentException( + "Filter configuration error: '$fieldName' must be type '{$expectedType}', but got '" . \gettype($value) . "'." + ); + } + } + + /** + * Performs a regex match and throws an exception on PCRE errors. + * + * @param string $pattern The regex pattern. + * @param string $subject The string to match against. + * @param string $fieldName Contextual name for error messages (e.g., 'IP', 'body'). + * @return bool True if the pattern matches, false otherwise. + * @throws \InvalidArgumentException If the regex pattern is invalid. + */ + private function checkRegex(string $pattern, string $subject, string $fieldName): bool { + $result = @\preg_match($pattern, $subject); + + if ($result === false) { + throw new \InvalidArgumentException( + "Invalid regex pattern for '{$fieldName}' ('{$pattern}'): " . $this->getPregErrorMessage() + ); + } + + return (bool)$result; + } + + /** + * Returns a human-readable description of the last regex error. + * + * @return string The error description. + */ + private function getPregErrorMessage(): string { + $error = \preg_last_error(); + switch ($error) { + case \PREG_NO_ERROR: + return "No error"; + case \PREG_INTERNAL_ERROR: + return "Internal PCRE error"; + case \PREG_BACKTRACK_LIMIT_ERROR: + return "Backtrack limit exceeded"; + case \PREG_RECURSION_LIMIT_ERROR: + return "Recursion limit exceeded"; + case \PREG_BAD_UTF8_ERROR: + return "Invalid UTF-8 data"; + case \PREG_BAD_UTF8_OFFSET_ERROR: + return "Invalid UTF-8 offset"; + case \PREG_JIT_STACKLIMIT_ERROR: + return "JIT stack limit exceeded"; + default: + return "Unknown PCRE error ($error)"; + } + } + + /** + * Dispatches the condition check to the appropriate matching method. + * + * @param string $condition The name of the condition to check (e.g., 'ip', 'body', 'flood-count'). + * @param mixed $value The value associated with the condition in the filter config. + * @param array $post The post data array. + * @param array> $floodEntries The recent post history. + * @param array $allConditions All conditions for the current filter (needed for dependencies like flood-count/flood-time). + * @return bool True if the condition matches, false otherwise. + * @throws \InvalidArgumentException If regex is invalid. + * @throws \RuntimeException For errors during custom callbacks or flood condition checks. + */ + private function matchCondition( + string $condition, + mixed $value, + array $post, + array $floodEntries, + array $allConditions + ): bool { + $conditionLower = \strtolower($condition); + + switch ($conditionLower) { + case 'custom': + + return $this->matchCustom($value, $post); + case 'flood-match': + $this->validateType($value, 'array', 'flood-match condition value'); + + return $this->matchFloodCondition($value, $post, $floodEntries); + case 'flood-time': + $this->validateType($value, 'numeric', 'flood-time condition value'); + + return $this->checkFloodTime((int)$value, $floodEntries); + case 'flood-count': + $this->validateType($value, 'numeric', 'flood-count condition value'); + $timeLimit = $allConditions['flood-time'] ?? 0; + $this->validateType($timeLimit, 'numeric', 'flood-time condition value'); + + return $this->checkFloodCount((int)$value, $floodEntries, (int)$timeLimit); + case 'name': + case 'trip': + case 'email': + case 'subject': + case 'body': + case 'filehash': + $this->validateType($value, 'string', "'{$conditionLower}' condition pattern"); + if (!isset($post[$condition]) && $condition !== 'filehash') { + return false; + } + + return $this->checkRegex($value, $post[$condition] ?? '', $condition); + case 'body_reg': + $this->validateType($value, 'array', 'body_reg condition patterns'); + + return $this->matchBodyReg($value, $post); + case 'filename': + case 'extension': + $this->validateType($value, 'string', "'{$conditionLower}' condition pattern"); + + return $this->matchFileCondition($value, $condition, $post); + case 'ip': + $this->validateType($value, 'string', 'IP condition pattern'); + + if (!isset($post['ip'])) { + $this->logger->log(LogDriver::WARNING, "Post missing 'ip' field for IP condition"); + return false; + } + + return $this->checkRegex($value, $post['ip'], 'IP'); + case 'rdns': + $this->validateType($value, 'string', 'RDNS condition pattern'); + + if (!isset($post['ip'])) { + $this->logger->log(LogDriver::WARNING, "Post missing 'ip' field for RDNS condition"); + return false; + } + + $hostname = \rDNS($post['ip']); + if ($hostname === $post['ip']) { + $this->logger->log(LogDriver::WARNING, "RDNS lookup failed for IP: {$post['ip']}"); + return false; + } + + return $this->checkRegex($value, $hostname, 'RDNS'); + case 'agent': + $this->validateType($value, 'array', 'Agent condition list'); + return $this->matchAgentCondition($value); + case 'op': + case 'has_file': + case 'board': + case 'password': + if (!isset($post[$condition])) { + $this->logger->log(LogDriver::WARNING, "Post missing field '{$conditionLower}' required for condition."); + return false; + } + + return $post[$condition] === $value; + default: + $this->logger->log(LogDriver::WARNING, "Encountered unknown filter condition type: '{$condition}'."); + + return false; + } + } + + /** + * Checks if the current User-Agent is present in a given list of strings. + * + * @param array $value The list of user-agent strings from the filter config. + * @return bool True if the current User-Agent is found (strictly) in the list, false otherwise. + */ + private function matchAgentCondition(array $value): bool { + $userAgent = $_SERVER['HTTP_USER_AGENT'] ?? null; + if ($userAgent === null) { + $this->logger->log( + LogDriver::WARNING, + "HTTP_USER_AGENT server variable not available for 'agent' condition." + ); + } + + $validAgents = \array_filter($value, 'is_string'); + + if (\count($validAgents) !== \count($value)) { + $this->logger->log( + LogDriver::WARNING, + "Non-string value found in 'agent' condition list. Only string values will be checked." + ); + } + + return \in_array($userAgent, $validAgents, true); + } + /** + * Executes a custom filter callback provided in the filter configuration. + * + * @param callable $callback The user-defined filter function (must accept post array, return bool). + * @param array $post The post data array. + * @return bool The boolean result returned by the callback. + * @throws \RuntimeException Wraps any exception thrown by the callback for context. + * @throws \Throwable Original exception is wrapped in RuntimeException + */ + private function matchCustom(callable $callback, array $post): bool { + try { + $result = $callback($post); + if (!\is_bool($result)) { + throw new \UnexpectedValueException("Custom filter callback did not return a boolean value."); + } + return $result; + } catch (\Throwable $e) { + $functionName = 'anonymous function'; + + if (\is_string($callback)) { + $functionName = $callback; + } elseif (\is_array($callback) && \count($callback) == 2) { + $class = \is_object($callback[0]) ? \get_class($callback[0]) : (string)$callback[0]; + $method = (string)$callback[1]; + $separator = \is_object($callback[0]) ? '->' : '::'; + $functionName = $class . $separator . $method; + } + + throw new \RuntimeException( + "Error executing custom filter function '{$functionName}': " . $e->getMessage(), + 0, + $e + ); + } + } + + /** + * Checks if the post body matches any of the provided regex patterns. + * + * @param array $patterns An array of regex patterns. + * @param array $post The post data array, expects 'body_nomarkup'. + * @return bool True if any pattern matches the 'body_nomarkup', false otherwise. + * @throws \InvalidArgumentException If any pattern is not a string or is an invalid regex. + */ + private function matchBodyReg(array $patterns, array $post): bool { + if (!isset($post['body_nomarkup'])) { + $this->logger->log(LogDriver::WARNING, "Post missing 'body_nomarkup' for 'body_reg' condition."); + return false; + } + + foreach ($patterns as $index => $pattern) { + $this->validateType($pattern, 'string', "body_reg pattern #{$index}"); + + if ($this->checkRegex($pattern, $post['body_nomarkup'], "body_reg pattern #{$index}")) { + return true; + } + } + return false; + } + + /** + * Checks if any recent post matches a set of flood conditions relative to the current post. + * + * @param array $match An array of conditions to check against each flood entry. + * @param array $post The current post data. + * @param array> $floodEntries Recent post history. + * @return bool True if any flood entry satisfies all specified $match conditions. + */ + private function matchFloodCondition(array $match, array $post, array $floodEntries): bool { + if (empty($match)) { + $this->logger->log(LogDriver::WARNING, "Empty condition list provided for 'flood-match'."); + return false; + } + + foreach ($floodEntries as $floodPost) { + if ($this->matchesAllConditions($match, $floodPost, $post)) { + return true; + } + } + + return false; + } + + /** + * Determines if a flood post matches all given conditions. + * + * @param array $match Conditions to verify. + * @param array $floodPost Previous post to compare against. + * @param array $post Current post data. + * @return bool True if all conditions match. + * @throws \RuntimeException When condition check fails. + */ + private function matchesAllConditions(array $match, array $floodPost, array $post): bool { + foreach ($match as $condition) { + try { + if (!$this->floodService->checkFloodCondition($condition, $floodPost, $post)) { + return false; + } + } catch (\Throwable $e) { + $conditionStr = \is_scalar($condition) ? (string)$condition : \json_encode($condition); + throw new \RuntimeException("Error checking flood-match sub-condition '{$conditionStr}': " . $e->getMessage(), 0, $e); + } + } + + return true; + } + + /** + * Checks if any recent post was made within the specified time window. + * + * @param int $time The time window in seconds. + * @param array> $floodEntries Recent post history, expecting 'time' key. + * @return bool True if any flood entry's timestamp is within the window, false otherwise. + */ + private function checkFloodTime(int $time, array $floodEntries): bool { + if ($time < 0) { + $this->logger->log(LogDriver::WARNING, "Negative time value '{$time}' provided for 'flood-time'."); + return false; + } + + foreach ($floodEntries as $floodPost) { + if (!isset($floodPost['time'])) { + $this->logger->log(LogDriver::WARNING, "Flood entry missing 'time' field"); + continue; + } + + if (\time() - $floodPost['time'] <= $time) { + return true; + } + } + return false; + } + + /** + * Checks if the count of recent posts within a time limit meets or exceeds a treshold. + * + * @param int $threshold The minimum number of posts required. + * @param array> $floodEntries Recent post history, expecting 'time' key. + * @param int $timeLimit The time window (in seconds) to count posts within. + * @return bool True if the count meets or exceeds the treshold, false otherwise. + */ + private function checkFloodCount(int $threshold, array $floodEntries, int $timeLimit): bool { + $count = \count(\array_filter( + $floodEntries, + function ($floodPost) use ($timeLimit) { + return isset($floodPost['time']) && \time() - $floodPost['time'] <= $timeLimit; + } + )); + + return $count >= $threshold; + } + + /** + * Checks if any file attached to the post matches a regex pattern on a specific field. + * + * @param string $value The regex pattern. + * @param string $condition The file field to check ('filename', 'extension', 'hash'). + * @param array $post The post data array, expects 'files' array. + * @return bool True if any file matches the condition, false otherwise. + * @throws \InvalidArgumentException If the pattern is an invalid regex. + */ + private function matchFileCondition(string $value, string $condition, array $post): bool { + if (empty($post['files']) || !\is_array($post['files'])) { + return false; + } + + foreach ($post['files'] as $file) { + if (!\is_array($file)) { + $this->logger->log(LogDriver::WARNING, "File entry is not an array"); + continue; + } + + if (!isset($file[$condition])) { + $this->logger->log(LogDriver::WARNING, "File missing '{$condition}' field"); + continue; + } + + if ($this->checkRegex($value, $file[$condition], "file '{$condition}'")) { + return true; + } + } + + return false; + } +} diff --git a/inc/Service/FloodService.php b/inc/Service/FloodService.php new file mode 100644 index 00000000..9e803684 --- /dev/null +++ b/inc/Service/FloodService.php @@ -0,0 +1,133 @@ +> Filter configurations. + */ + private array $filters; + + /** + * @var int Cache for the calculated maximum flood time (-1 means not calculated). + */ + private int $flood_cache; + + /** + * Constructor for FloodService. + * + * @param FloodQueries $floodQueries The database query handler for flood data. + * @param array> $filters Filter configurations. + * @param int $flood_cache Optional pre-calculated max flood time. + */ + public function __construct(FloodQueries $floodQueries, array $filters, int $flood_cache = -1) { + $this->floodQueries = $floodQueries; + $this->filters = $filters; + $this->flood_cache = $flood_cache; + } + + /** + * Calculates or retrieves the maximum flood time window from filters. + * + * @return int The maximum 'flood-time' value found in filters, or 0 if none. + */ + public function getMaxFloodTime(): int { + if (isset($this->flood_cache) && $this->flood_cache !== -1) { + return $this->flood_cache; + } + + $maxTime = 0; + foreach ($this->filters as $filter) { + if (isset($filter['condition']['flood-time']) && $filter['condition']['flood-time'] > $maxTime) { + $maxTime = $filter['condition']['flood-time']; + } + } + $this->flood_cache = $maxTime; + + return $maxTime; + } + + /** + * Removes flood entries older than the maximum relevant flood time. + */ + public function purgeOldEntries(): void { + $maxTime = $this->getMaxFloodTime(); + + if ($maxTime > 0) { + $this->floodQueries->purgeOldEntries($maxTime); + } + } + + /** + * Retrieves recent flood entries relevant to the given post's IP, body hash, or file hash. + * + * @param array $post The post data array. Expects 'ip', 'body_nomarkup', optionally 'filehash', 'has_file'. + * @return array> List of matching flood entries. + */ + public function getFloodEntries(array $post): array { + $bodyHash = \make_comment_hex($post['body_nomarkup'] ?? ''); + $fileHash = isset($post['has_file']) && $post['has_file'] ? $post['filehash'] : null; + + return $this->floodQueries->getRecentFloodEntries($post['ip'], $bodyHash, $fileHash); + } + + /** + * Compares a specific field between a historical flood entry and the current post. + * + * Valid conditions: 'ip', 'body', 'file', 'board', 'isreply'. + * + * @param string $condition The condition to check. + * @param array $floodPost A single historical flood entry. + * @param array $post The current post data. + * @return bool True if the condition matches between the two posts. + * @throws \InvalidArgumentException If the condition string is not recognized. + */ + public function checkFloodCondition(string $condition, array $floodPost, array $post): bool { + switch ($condition) { + case 'ip': + return isset($floodPost['ip'], $post['ip']) && $floodPost['ip'] === $post['ip']; + case 'body': + $currentBodyHash = \make_comment_hex($post['body_nomarkup'] ?? ''); + return isset($floodPost['posthash']) && $floodPost['posthash'] === $currentBodyHash; + case 'file': + $hasFile = $post['has_file'] ?? false; + $currentFileHash = ($hasFile && isset($post['filehash'])) ? $post['filehash'] : null; + return isset($floodPost['filehash']) && $currentFileHash !== null && $floodPost['filehash'] === $currentFileHash; + case 'board': + return isset($floodPost['board'], $post['board']) && $floodPost['board'] === $post['board']; + case 'isreply': + $currentIsReply = isset($post['thread']); + return isset($floodPost['isreply']) && (bool)$floodPost['isreply'] === $currentIsReply; + default: + throw new \InvalidArgumentException('Invalid filter flood condition: ' . $condition); + } + } + + /** + * Records a new entry in the flood table for the given post. + * + * @param array $post The post data array. Expects 'ip', 'body_nomarkup', 'board', 'thread' optionally 'filehash', 'has_file'. + */ + public function recordFloodEntry(array $post): void { + $this->floodQueries->addFloodEntry( + $post['ip'], + \make_comment_hex($post['body_nomarkup'] ?? ''), + isset($post['has_file']) && $post['has_file'] ? $post['filehash'] : null, + $post['board'], + \time(), + isset($post['thread']) + ); + } + +} diff --git a/inc/context.php b/inc/context.php index b594220a..8c7eff9c 100644 --- a/inc/context.php +++ b/inc/context.php @@ -1,8 +1,11 @@ fn(Context $c): UserPostQueries => new UserPostQueries( $c->get(\PDO::class) - ) + ), + FloodQueries::class => fn(Context $c): FloodQueries => new FloodQueries( + $c->get(\PDO::class) + ), + FloodService::class => fn(Context $c): FloodService => new FloodService( + $c->get(FloodQueries::class), + $c->get('config')['filters'], + $c->get('config')['flood_cache'] + ), + FilterService::class => fn(Context $c): FilterService => new FilterService( + $c->get('config')['filters'], + $c->get(FloodService::class), + $c->get(LogDriver::class) + ), + FloodManager::class => fn(Context $c): FloodManager => new FloodManager( + $c->get(FilterService::class), + $c->get(FloodService::class), + $c->get(IpNoteQueries::class), + $c->get(LogDriver::class) + ), ]); } diff --git a/inc/filters.php b/inc/filters.php index 2a7c1554..9fe0fe37 100644 --- a/inc/filters.php +++ b/inc/filters.php @@ -5,271 +5,41 @@ */ use Vichan\Context; -use Vichan\Data\IpNoteQueries; +use Vichan\Controller\FloodManager; defined('TINYBOARD') or exit; -class Filter { - public $flood_check; - private $condition; - private $post; +function do_filters(Context $ctx, array $post): void { + $config = $ctx->get('config'); - - public function __construct(array $arr) { - foreach ($arr as $key => $value) { - $this->$key = $value; - } - } - - public function match($condition, $match) { - $condition = strtolower($condition); - $post = &$this->post; - - switch($condition) { - case 'custom': - if (!is_callable($match)) { - error('Custom condition for filter is not callable!'); - } - return $match($post); - case 'flood-match': - if (!is_array($match)) { - error('Filter condition "flood-match" must be an array.'); - } - - // Filter out "flood" table entries which do not match this filter. - - $flood_check_matched = array(); - - foreach ($this->flood_check as $flood_post) { - foreach ($match as $flood_match_arg) { - switch ($flood_match_arg) { - case 'ip': - if ($flood_post['ip'] != $_SERVER['REMOTE_ADDR']) { - continue 3; - } - break; - case 'body': - if ($flood_post['posthash'] != make_comment_hex($post['body_nomarkup'])) { - continue 3; - } - break; - case 'file': - if (!isset($post['filehash'])) { - return false; - } - if ($flood_post['filehash'] != $post['filehash']) { - continue 3; - } - break; - case 'board': - if ($flood_post['board'] != $post['board']) { - continue 3; - } - break; - case 'isreply': - if ($flood_post['isreply'] == $post['op']) { - continue 3; - } - break; - default: - error('Invalid filter flood condition: ' . $flood_match_arg); - } - } - $flood_check_matched[] = $flood_post; - } - - $this->flood_check = $flood_check_matched; - return !empty($this->flood_check); - case 'flood-time': - foreach ($this->flood_check as $flood_post) { - if (time() - $flood_post['time'] <= $match) { - return true; - } - } - return false; - case 'flood-count': - $count = 0; - foreach ($this->flood_check as $flood_post) { - if (time() - $flood_post['time'] <= $this->condition['flood-time']) { - ++$count; - } - } - return $count >= $match; - case 'name': - return preg_match($match, $post['name']); - case 'trip': - return $match === $post['trip']; - case 'email': - return preg_match($match, $post['email']); - case 'subject': - return preg_match($match, $post['subject']); - case 'body': - return preg_match($match, $post['body_nomarkup']); - case 'filehash': - return $match === $post['filehash']; - case 'filename': - if (!$post['files']) { - return false; - } - - foreach ($post['files'] as $file) { - if (preg_match($match, $file['filename'])) { - return true; - } - } - return false; - case 'extension': - if (!$post['files']) { - return false; - } - - foreach ($post['files'] as $file) { - if (preg_match($match, $file['extension'])) { - return true; - } - } - return false; - case 'ip': - return preg_match($match, $_SERVER['REMOTE_ADDR']); - case 'op': - return $post['op'] == $match; - case 'has_file': - return $post['has_file'] == $match; - case 'board': - return $post['board'] == $match; - case 'password': - return $post['password'] == $match; - case 'unshorten': - $extracted_urls = get_urls($post['body_nomarkup']); - foreach ($extracted_urls as $url) { - if (preg_match($match, trace_url($url))) { - return true; - } - } - return false; - default: - error('Unknown filter condition: ' . $condition); - } - } - - public function action(Context $ctx) { - global $board; - - $this->add_note = isset($this->add_note) ? $this->add_note : false; - if ($this->add_note) { - $note_queries = $ctx->get(IpNoteQueries::class); - $note_queries->add($_SERVER['REMOTE_ADDR'], -1, 'Autoban message: ' . $this->post['body']); - } - if (isset($this->action)) { - switch($this->action) { - case 'reject': - error(isset($this->message) ? $this->message : 'Posting throttled by filter.'); - case 'ban': - if (!isset($this->reason)) { - error('The ban action requires a reason.'); - } - - $this->expires = isset($this->expires) ? $this->expires : false; - $this->reject = isset($this->reject) ? $this->reject : true; - $this->all_boards = isset($this->all_boards) ? $this->all_boards : false; - - Bans::new_ban($_SERVER['REMOTE_ADDR'], $this->reason, $this->expires, $this->all_boards ? false : $board['uri'], -1); - - if ($this->reject) { - if (isset($this->message)) { - error($message); - } - - checkBan($board['uri']); - exit; - } - - break; - default: - error('Unknown filter action: ' . $this->action); - } - } - } - - public function check(array $post) { - $this->post = $post; - foreach ($this->condition as $condition => $value) { - if ($condition[0] == '!') { - $NOT = true; - $condition = substr($condition, 1); - } else { - $NOT = false; - } - - if ($this->match($condition, $value) == $NOT) { - return false; - } - } - return true; - } -} - -function purge_flood_table() { - global $config; - - // Determine how long we need to keep a cache of posts for flood prevention. Unfortunately, it is not - // aware of flood filters in other board configurations. You can solve this problem by settings the - // config variable $config['flood_cache'] (seconds). - - if ($config['flood_cache'] != -1) { - $max_time = &$config['flood_cache']; - } else { - $max_time = 0; - foreach ($config['filters'] as $filter) { - if (isset($filter['condition']['flood-time'])) { - $max_time = max($max_time, $filter['condition']['flood-time']); - } - } - } - - $time = time() - $max_time; - - query("DELETE FROM ``flood`` WHERE `time` < $time") or error(db_error()); -} - -function do_filters(Context $ctx, array $post) { - global $config; - - if (!isset($config['filters']) || empty($config['filters'])) { + if (empty($config['filters'])) { return; } - foreach ($config['filters'] as $filter) { - if (isset($filter['condition']['flood-match'])) { - $has_flood = true; - break; + $floodManager = $ctx->get(FloodManager::class); + $filterResult = $floodManager->processPost($post); + + if ($filterResult) { + $action = $filterResult['action'] ?? 'reject'; + + if ($action === 'reject') { + error($filterResult['message'] ?? _('Posting throttled by filter.')); + } elseif ($action === 'ban') { + Bans::new_ban( + $post['ip'], + $filterResult['reason'] ?? _('Banned by filter'), + $filterResult['expires'] ?? false, + $filterResult['all_boards'] ? false : $post['board'], + -1 + ); + + if ($filterResult['reject'] ?? true) { + error( + $filterResult['message'] + ?? + _('You have been banned. Click here to view.') + ); + } } } - - if (isset($has_flood)) { - if ($post['has_file']) { - $query = prepare("SELECT * FROM ``flood`` WHERE `ip` = :ip OR `posthash` = :posthash OR `filehash` = :filehash"); - $query->bindValue(':ip', $_SERVER['REMOTE_ADDR']); - $query->bindValue(':posthash', make_comment_hex($post['body_nomarkup'])); - $query->bindValue(':filehash', $post['filehash']); - } else { - $query = prepare("SELECT * FROM ``flood`` WHERE `ip` = :ip OR `posthash` = :posthash"); - $query->bindValue(':ip', $_SERVER['REMOTE_ADDR']); - $query->bindValue(':posthash', make_comment_hex($post['body_nomarkup'])); - } - $query->execute() or error(db_error($query)); - $flood_check = $query->fetchAll(PDO::FETCH_ASSOC); - } else { - $flood_check = false; - } - - foreach ($config['filters'] as $filter_array) { - $filter = new Filter($filter_array); - $filter->flood_check = $flood_check; - if ($filter->check($post)) { - $filter->action($ctx); - } - } - - purge_flood_table(); } diff --git a/inc/functions.php b/inc/functions.php index 1787b440..52a7ec20 100755 --- a/inc/functions.php +++ b/inc/functions.php @@ -867,22 +867,6 @@ function threadExists($id) { return false; } -function insertFloodPost(array $post) { - global $board; - - $query = prepare("INSERT INTO ``flood`` VALUES (NULL, :ip, :board, :time, :posthash, :filehash, :isreply)"); - $query->bindValue(':ip', $_SERVER['REMOTE_ADDR']); - $query->bindValue(':board', $board['uri']); - $query->bindValue(':time', time()); - $query->bindValue(':posthash', make_comment_hex($post['body_nomarkup'])); - if ($post['has_file']) - $query->bindValue(':filehash', $post['filehash']); - else - $query->bindValue(':filehash', null, PDO::PARAM_NULL); - $query->bindValue(':isreply', !$post['op'], PDO::PARAM_INT); - $query->execute() or error(db_error($query)); -} - function post(array $post) { global $pdo, $board; $query = prepare(sprintf("INSERT INTO ``posts_%s`` VALUES ( NULL, :thread, :subject, :email, :name, :trip, :capcode, :body, :body_nomarkup, :time, :time, :files, :num_files, :filehash, :password, :ip, :sticky, :locked, :cycle, 0, :embed, :slug)", $board['uri'])); diff --git a/post.php b/post.php index beea3cc2..fb927987 100644 --- a/post.php +++ b/post.php @@ -597,6 +597,7 @@ if (isset($_POST['delete'])) { // Check if banned checkBan($board['uri']); + $post['ip'] = $_SERVER['REMOTE_ADDR']; // Check for CAPTCHA right after opening the board so the "return" link is in there. try { $provider = $config['captcha']['provider']; @@ -1272,8 +1273,6 @@ if (isset($_POST['delete'])) { nntp_publish($message, $msgid); } - insertFloodPost($post); - // Handle cyclical threads if (!$post['op'] && isset($thread['cycle']) && $thread['cycle']) { delete_cyclical_posts($board['uri'], $post['thread'], $config['cycle_limit']);