Merge pull request #923 from perdedora/new-filtersystem

New filter system
This commit is contained in:
Lorenzo Yario 2025-04-18 10:36:59 -07:00 committed by GitHub
commit 94082cf517
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 874 additions and 278 deletions

View File

@ -0,0 +1,92 @@
<?php
namespace Vichan\Controller;
use Vichan\Data\Driver\LogDriver;
use Vichan\Data\IpNoteQueries;
use Vichan\Service\FilterService;
use Vichan\Service\FloodService;
/**
* Manages the overall process of checking posts against filters and flood controls.
* Orchestrates FilterService and FloodService for incoming posts.
*/
class FloodManager {
/**
* @var FilterService Service to apply configured filters to posts.
*/
private FilterService $filterService;
/**
* @var FloodService Service to record post history for flood detection.
*/
private FloodService $floodService;
/**
* @var IpNoteQueries Handles database operations for IP notes.
*/
private IpNoteQueries $notes;
/**
* @var LogDriver Logger instance for recording errors.
*/
private LogDriver $logger;
/**
* Constructor for FloodManager.
*
* @param FilterService $filterService The filter checking service.
* @param FloodService $floodService The flood recording and checking service.
* @param IpNoteQueries $notes The IP note database query handler.
* @param LogDriver $logger The logging service.
*/
public function __construct(
FilterService $filterService,
FloodService $floodService,
IpNoteQueries $notes,
LogDriver $logger
) {
$this->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<string, mixed> $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;
}
}
}

96
inc/Data/FloodQueries.php Normal file
View File

@ -0,0 +1,96 @@
<?php
namespace Vichan\Data;
/**
* Handles direct database interactions for the `flood` table.
*/
class FloodQueries {
/**
* @var \PDO The PDO database connection instance.
*/
private \PDO $pdo;
/**
* Constructor for FloodQueries.
*
* @param \PDO $pdo An active PDO database connection.
*/
public function __construct(\PDO $pdo) {
$this->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<int, array<string, mixed>> 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();
}
}

View File

@ -0,0 +1,500 @@
<?php
namespace Vichan\Service;
use Throwable;
use Vichan\Data\Driver\LogDriver;
/**
* Applies filters to posts based on configured conditions.
*/
class FilterService {
/**
* @var array<int, array<string, mixed>> 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<int, array<string, mixed>> $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<string, mixed> $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<string, mixed> $conditions The conditions for this specific filter.
* @param array $post The post data array.
* @param array<int, array<string, mixed>> $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<string, mixed> $post The post data array.
* @param array<int, array<string, mixed>> $floodEntries The recent post history.
* @param array<string, mixed> $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<int, mixed> $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<string, mixed> $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<int, string> $patterns An array of regex patterns.
* @param array<string, mixed> $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<int, mixed> $match An array of conditions to check against each flood entry.
* @param array<string, mixed> $post The current post data.
* @param array<int, array<string, mixed>> $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<int, mixed> $match Conditions to verify.
* @param array<string, mixed> $floodPost Previous post to compare against.
* @param array<string, mixed> $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<int, array<string, mixed>> $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<int, array<string, mixed>> $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<string, mixed> $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;
}
}

View File

@ -0,0 +1,133 @@
<?php
namespace Vichan\Service;
use Vichan\Data\FloodQueries;
/**
* Manages post flood detection logic based on recent post history.
* Uses filter configurations to determine relevant time windows.
*/
class FloodService {
/**
* @var FloodQueries Handles database interactions for flood data.
*/
private FloodQueries $floodQueries;
/**
* @var array<int, array<string, mixed>> 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<int, array<string, mixed>> $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<string, mixed> $post The post data array. Expects 'ip', 'body_nomarkup', optionally 'filehash', 'has_file'.
* @return array<int, array<string, mixed>> 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<string, mixed> $floodPost A single historical flood entry.
* @param array<string, mixed> $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<string, mixed> $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'])
);
}
}

View File

@ -1,8 +1,11 @@
<?php
namespace Vichan;
use Vichan\Controller\FloodManager;
use Vichan\Data\Driver\{CacheDriver, HttpDriver, ErrorLogLogDriver, FileLogDriver, LogDriver, StderrLogDriver, SyslogLogDriver};
use Vichan\Data\{IpNoteQueries, UserPostQueries, ReportQueries};
use Vichan\Data\{FloodQueries, IpNoteQueries, UserPostQueries, ReportQueries};
use Vichan\Service\FilterService;
use Vichan\Service\FloodService;
use Vichan\Service\HCaptchaQuery;
use Vichan\Service\SecureImageCaptchaQuery;
use Vichan\Service\ReCaptchaQuery;
@ -84,7 +87,26 @@ function build_context(array $config): Context {
),
UserPostQueries::class => 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)
),
]);
}

View File

@ -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. <a href="/banned.php">Click here to view.</a>')
);
}
}
}
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();
}

View File

@ -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']));

View File

@ -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']);