From 73df37918d5298dd9e753c27f940dff71cb480b9 Mon Sep 17 00:00:00 2001 From: Zankaria Date: Tue, 15 Apr 2025 22:56:49 +0200 Subject: [PATCH 01/25] SearchQueries.php: add --- inc/Data/SearchQueries.php | 98 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 inc/Data/SearchQueries.php diff --git a/inc/Data/SearchQueries.php b/inc/Data/SearchQueries.php new file mode 100644 index 00000000..7aa7cbad --- /dev/null +++ b/inc/Data/SearchQueries.php @@ -0,0 +1,98 @@ +pdo->prepare("SELECT COUNT(2) FROM `search_queries` WHERE `ip` = :ip AND `time` > :time"); + $query->bindValue(':ip', $ip); + $query->bindValue(':time', $now - $this->range_for_single, \PDO::PARAM_INT); + $query->execute(); + if ($query->fetchColumn() > $this->queries_for_single) { + return true; + } + + $query = $this->pdo->prepare("SELECT COUNT(2) FROM `search_queries` WHERE `time` > :time"); + $query->bindValue(':time', $now - $this->range_for_all, \PDO::PARAM_INT); + $query->execute(); + if ($query->fetchColumn() > $this->queries_for_all) { + return true; + } + + $query = $this->pdo->prepare("INSERT INTO `search_queries` VALUES (:ip, :time, :query)"); + $query->bindValue(':ip', $ip); + $query->bindValue(':time', $now, \PDO::PARAM_INT); + $query->bindValue(':query', $phrase); + $query->execute(); + + if ($this->auto_gc) { + $this->purgeExpired(); + } + + return false; + } + + /** + * @param \PDO $pdo PDO to access the DB. + * @param int $queries_for_single Maximum number of queries for a single IP, in seconds. + * @param int $range_for_single Maximum age of the oldest query to consider from a single IP. + * @param int $queries_for_all Maximum number of queries for all IPs. + * @param int $range_for_all Maximum age of the oldest query to consider from all IPs, in seconds. + * @param bool $auto_gc If to run the cleanup at every check. Must be invoked from the outside otherwise. + */ + public function __construct( + \PDO $pdo, + int $queries_for_single, + int $range_for_single, + int $queries_for_all, + int $range_for_all, + bool $auto_gc + ) { + $this->pdo = $pdo; + $this->queries_for_single = $queries_for_single; + $this->range_for_single = $range_for_single; + $this->queries_for_all = $queries_for_all; + $this->range_for_all = $range_for_all; + $this->auto_gc = $auto_gc; + } + + /** + * Check if the IP-query pair overflows the limit. + * + * @param string $ip Source IP. + * @param string $phrase The search query. + * @return bool True if the request goes over the limit. + */ + public function checkFlood(string $ip, string $phrase): bool { + $this->pdo->beginTransaction(); + try { + $ret = $this->checkFloodImpl($ip, $phrase); + $this->pdo->commit(); + return $ret; + } catch (\Exception $e) { + $this->pdo->rollBack(); + throw $e; + } + } + + public function purgeExpired(): int { + // Cleanup search queries table. + $query = $this->pdo->prepare("DELETE FROM `search_queries` WHERE `time` <= :expiry_limit"); + $query->bindValue(':expiry_limit', \time() - $this->range_for_all, \PDO::PARAM_INT); + $query->execute(); + return $query->rowCount(); + } +} From 57cf3abed4f6c5599bb9714c38f994031cec7505 Mon Sep 17 00:00:00 2001 From: Zankaria Date: Sun, 18 May 2025 22:29:16 +0200 Subject: [PATCH 02/25] maintenance.php: add SearchQueries cleanup --- tools/maintenance.php | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/tools/maintenance.php b/tools/maintenance.php index 7f3c248b..53fd30b0 100644 --- a/tools/maintenance.php +++ b/tools/maintenance.php @@ -3,7 +3,7 @@ * Performs maintenance tasks. Invoke this periodically if the auto_maintenance configuration option is turned off. */ -use Vichan\Data\ReportQueries; +use Vichan\Data\{ReportQueries, SearchQueries}; require dirname(__FILE__) . '/inc/cli.php'; @@ -45,9 +45,17 @@ if ($config['cache']['enabled'] === 'fs') { $fs_cache->collect(); $delta = microtime(true) - $start; echo "Deleted $deleted_count expired filesystem cache items in $delta seconds!\n"; - $time_tot = $delta; + $time_tot += $delta; $deleted_tot = $deleted_count; } +echo "Clearing old search log...\n"; +$search_queries = $ctx->get(SearchQueries::class); +$start = microtime(true); +$deleted_count = $search_queries->purgeExpired(); +$delta = microtime(true) - $start; +$time_tot += $delta; +$deleted_tot = $deleted_count; + $time_tot = number_format((float)$time_tot, 4, '.', ''); modLog("Deleted $deleted_tot expired entries in {$time_tot}s with maintenance tool"); From c6cec16971d4604968e55d6c46a033e1ed03182c Mon Sep 17 00:00:00 2001 From: Zankaria Date: Wed, 16 Apr 2025 22:49:30 +0200 Subject: [PATCH 03/25] UserPostQueries.php: add searchPost method --- inc/Data/UserPostQueries.php | 115 +++++++++++++++++++++++++++++++++++ 1 file changed, 115 insertions(+) diff --git a/inc/Data/UserPostQueries.php b/inc/Data/UserPostQueries.php index 1c203431..ef9e6343 100644 --- a/inc/Data/UserPostQueries.php +++ b/inc/Data/UserPostQueries.php @@ -13,6 +13,36 @@ class UserPostQueries { private \PDO $pdo; + + /** + * Escapes wildcards from LIKE operators using the default escape character. + */ + private static function escapeLike(string $str): string { + // Escape any existing escape characters. + $str = \str_replace('\\', '\\\\', $str); + // Escape wildcard characters. + $str = \str_replace('%', '\\%', $str); + $str = \str_replace('_', '\\_', $str); + return $str; + } + + /** + * Joins the fragments of filter into a list of bindable parameters for the CONCAT sql function. + * Given prefix = cat and fragments_count = 3, we get [ "'%'", ":cat0%", "'%', ":cat1", "'%'" ":cat2%", "'%'" ]; + * + * @param string $prefix The prefix for the parameter binding + * @param int $fragments_count MUST BE >= 1. + * @return array + */ + private static function arrayOfFragments(string $prefix, int $fragments_count): array { + $args = [ "'%'" ]; + for ($i = 0; $i < $fragments_count; $i++) { + $args[] = ":$prefix$i"; + $args[] = "'%'"; + } + return $args; + } + public function __construct(\PDO $pdo) { $this->pdo = $pdo; } @@ -156,4 +186,89 @@ class UserPostQueries { } }); } + + /** + * Search among the user posts with the given filters. + * The subject, name and elements of the bodies filters are fragments which are joined together with wildcards, to + * allow for more flexible filtering. + * + * @param string $board The board where to search in. + * @param array $subject Fragments of the subject filter. + * @param array $name Fragments of the name filter. + * @param array $flags An array of the flag names to search among the HTML. + * @param ?int $id Post id filter. + * @param ?int $thread Thread id filter. + * @param array> $bodies An array whose element are arrays containing the fragments of multiple body filters, each + * searched independently from the others + * @param integer $limit The maximum number of results. + * @throws PDOException On error. + * @return array + */ + public function searchPosts(string $board, array $subject, array $name, array $flags, ?int $id, ?int $thread, array $bodies, int $limit): array { + $where_acc = []; + + if (!empty($subject)) { + $like_arg = self::arrayOfFragments('subj', \count($subject)); + $where_acc[] = 'subject LIKE CONCAT(' . \implode(', ', $like_arg) . ')'; + } + if (!empty($name)) { + $like_arg = self::arrayOfFragments('name', \count($name)); + $where_acc[] = 'name LIKE CONCAT(' . \implode(', ', $like_arg) . ')'; + } + if (!empty($flags)) { + $flag_acc = []; + for ($i = 0; $i < \count($flags); $i++) { + // Yes, vichan stores the flag inside the generated HTML. Now you know why it's slow as shit. + // English lacks the words to express my feelings about it in a satisfying manner. + $flag_acc[] = "CONCAT('%', :flag$i, '%')"; + } + $where_acc[] = 'body_nomarkup LIKE (' . \implode(' OR ', $flag_acc) . ')'; + } + if ($id !== null) { + $where_acc[] = 'id = :id'; + } + if ($thread !== null) { + $where_acc[] = 'thread = :thread'; + } + for ($i = 0; $i < \count($bodies); $i++) { + $body = $bodies[$i]; + $like_arg = self::arrayOfFragments("body_{$i}_", \count($body)); + $where_acc[] = 'body_nomarkup LIKE CONCAT(' . \implode(', ', $like_arg) . ')'; + } + + if (empty($where_acc)) { + return []; + } + + $sql = "SELECT * FROM `posts_$board` WHERE " . \implode(' AND ', $where_acc) . ' LIMIT :limit'; + $query = $this->pdo->prepare($sql); + + for ($i = 0; $i < \count($subject); $i++) { + $query->bindValue(":subj$i", self::escapeLike($subject[$i])); + } + for ($i = 0; $i < \count($name); $i++) { + $query->bindValue(":name$i", self::escapeLike($name[$i])); + } + for ($i = 0; $i < \count($flags); $i++) { + $query->bindValue(":flag$i", self::escapeLike($flags[$i])); + } + if ($id !== null) { + $query->bindValue(':id', $id, \PDO::PARAM_INT); + } + if ($thread !== null) { + $query->bindValue(':thread', $thread, \PDO::PARAM_INT); + } + for ($body_i = 0; $body_i < \count($bodies); $body_i++) { + $body = $bodies[$body_i]; + + for ($i = 0; $i < \count($body); $i++) { + $query->bindValue(":body_{$body_i}_{$i}", self::escapeLike($body[$i])); + } + } + + $query->bindValue(':limit', $limit, \PDO::PARAM_INT); + + $query->execute(); + return $query->fetchAll(\PDO::FETCH_ASSOC); + } } From 46d5dc0fd4017efaa72665cc67b1b736dbccff33 Mon Sep 17 00:00:00 2001 From: Zankaria Date: Fri, 18 Apr 2025 19:13:47 +0200 Subject: [PATCH 04/25] SearchFilters.php: add --- inc/Data/SearchFilters.php | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 inc/Data/SearchFilters.php diff --git a/inc/Data/SearchFilters.php b/inc/Data/SearchFilters.php new file mode 100644 index 00000000..36beb921 --- /dev/null +++ b/inc/Data/SearchFilters.php @@ -0,0 +1,32 @@ +> + */ + public array $body = []; + /** + * @var array + */ + public array $subject = []; + /** + * @var array + */ + public array $name = []; + /** + * @var ?string + */ + public ?string $board = null; + /** + * @var array + */ + public array $flag = []; + public ?int $id = null; + public ?int $thread = null; + public float $weight = 0; +} From 6e153daa1d031df04106315609144c290c45a95d Mon Sep 17 00:00:00 2001 From: Zankaria Date: Sun, 18 May 2025 00:49:37 +0200 Subject: [PATCH 05/25] FiltersParseResult.php: add --- inc/Data/FiltersParseResult.php | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 inc/Data/FiltersParseResult.php diff --git a/inc/Data/FiltersParseResult.php b/inc/Data/FiltersParseResult.php new file mode 100644 index 00000000..1eb1acd8 --- /dev/null +++ b/inc/Data/FiltersParseResult.php @@ -0,0 +1,13 @@ + Date: Fri, 23 May 2025 23:18:07 +0200 Subject: [PATCH 06/25] Flags.php: add the flags that come embedded with php --- inc/Data/Flags.php | 285 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 285 insertions(+) create mode 100644 inc/Data/Flags.php diff --git a/inc/Data/Flags.php b/inc/Data/Flags.php new file mode 100644 index 00000000..00d37f12 --- /dev/null +++ b/inc/Data/Flags.php @@ -0,0 +1,285 @@ + Date: Wed, 16 Apr 2025 01:03:18 +0200 Subject: [PATCH 07/25] SearchService.php: add search service with fully linear regex parser --- inc/Service/SearchService.php | 363 ++++++++++++++++++++++++++++++++++ 1 file changed, 363 insertions(+) create mode 100644 inc/Service/SearchService.php diff --git a/inc/Service/SearchService.php b/inc/Service/SearchService.php new file mode 100644 index 00000000..e9aab2bd --- /dev/null +++ b/inc/Service/SearchService.php @@ -0,0 +1,363 @@ + '\\', + '\\*' => '*', + '\\"' => '"' + ]); + } + + /** + * Split the filter into fragments along the wildcards, handling escaping. + * + * @param string $str The full filter. + * @return array + */ + private static function split(string $str): array { + // Split the fragments + return \preg_split('/(?:\\\\\\\\)*\\\\\*|(?:\\\\\\\\)*\*+/', $str); + } + + private static function weightByContent(array $fragments): float { + $w = 0; + + foreach ($fragments as $fragment) { + $short = \strlen($fragment) < 4; + if (\in_array($fragment, self::COMMON_WORDS)) { + $w += $short ? 16 : 6; + } elseif ($short) { + $w += 6; + } + } + + return $w; + } + + private static function filterAndWeight(string $filter): array { + $fragments = self::split($filter); + $acc = []; + $total_len = 0; + + foreach ($fragments as $fragment) { + $fragment = self::trim(self::unescape($fragment)); + + if (!empty($fragment)) { + $total_len += \strlen($fragment); + $acc[] = $fragment; + } + } + + // Interword wildcards + $interword = \min(\count($fragments) - 1, 0); + // Wildcards over the total length of the word. Ergo the number of fragments minus 1. + $perc = $interword / $total_len * 100; + $wildcard_weight = $perc + \count($fragments) * 2; + + return [ $acc, $total_len, $wildcard_weight ]; + } + + /** + * Gets a subset of the given strings which match every filter. + * + * @param array $fragments User provided fragments to search in the flags. + * @param array $strings An array of strings. + * @return array An array of strings, subset of $strings. + */ + private static function matchStrings(array $strings, array $fragments): array { + return \array_filter($strings, function ($str) use ($fragments) { + // Saves the last position. We use this to ensure the fragments are one after the other. + $last_ret = 0; + foreach ($fragments as $fragment) { + if ($last_ret + 1 > \strlen($fragment)) { + // Cannot possibly match. + return false; + } + + $last_ret = \stripos($str, $fragment, $last_ret + 1); + if ($last_ret === false) { + // Exclude flags that don't much even a single fragment. + return false; + } + } + return true; + }); + } + + /** + * Parses a raw search query. + * + * @param string $raw_query Raw user query. Phrases are searched in the post bodies. The user can specify also + * additional filters in the : format. + * Available filters: + * - board: the board, value can be quoted + * - subject: post subject, value can be quoted, supports wildcards + * - name: post name, value can be quoted, supports wildcards + * - flag: post flag, value can be quoted, supports wildcards + * - id: post id, must be numeric + * - thread: thread id, must be numeric + * The remaining text is split into chunks and searched in the post body. + * @return FiltersParseResult + */ + public function parse(string $raw_query): FiltersParseResult{ + $tres = self::truncateQuery($raw_query, $this->max_query_length); + if ($tres === null) { + throw new \RuntimeException('Could not truncate query'); + } + + $pres = \preg_match_all( + '/(?: + \b(board): + (?: + "([^"]+)" # [2] board: "quoted" + | + ([^\s"]+) # [3] board: unquoted + ) + | + \b(subject|name|flag): + (?: + "((?:\\\\\\\\|\\\\\"|\\\\\*|[^"\\\\])*)" # [5] quoted with wildcards + | + ((?:\\\\\\\\|\\\\\*|[^\s\\\\])++) # [6] unquoted with wildcards + ) + | + \b(id|thread): + (\d+) # [8] numeric only + | + "((?:\\\\\\\\|\\\\\"|\\\\\*|[^"\\\\])*)" # [9] quoted free text + | + ([^"\s]++) # [10] unquoted free text block + )/iux', + $tres, + $matches, + \PREG_SET_ORDER + ); + if ($pres === false) { + throw new \RuntimeException('Could not decode the query'); + } + + $filters = new FiltersParseResult(); + + foreach ($matches as $m) { + if (!empty($m[1])) { + // board (no wildcards). + $value = \trim(!empty($m[2]) ? $m[2] : $m[3], '/'); + + $filters->board = $value; + } elseif (!empty($m[4])) { + // subject, name, flag (with wildcards). + $key = \strtolower($m[4]); + $value = !empty($m[5]) ? $m[5] : $m[6]; + + if ($key === 'name') { + $filters->name = $value; + } elseif ($key === 'subject') { + $filters->subject = $value; + } else { + $filters->flag = $value; + } + } elseif (!empty($m[7])) { + $key = \strtolower($m[7]); + $value = (int)$m[8]; + + if ($key === 'id') { + $filters->id = $value; + } else { + $filters->thread = $value; + } + } elseif (!empty($m[9]) || !empty($m[10])) { + $value = !empty($m[9]) ? $m[9] : $m[10]; + + $filters->body[] = $value; + } + } + + return $filters; + } + + /** + * @param UserPostQueries $user_queries User posts queries. + * @param ?flag_map $max_flag_length The key-value map of user flags, or null to disable flag search. + */ + public function __construct(LogDriver $log, UserPostQueries $user_queries, ?array $flag_map, float $max_weight, int $max_query_length, int $post_limit) { + $this->log = $log; + $this->user_queries = $user_queries; + $this->flag_map = $flag_map; + $this->max_weight = $max_weight; + $this->max_query_length = $max_query_length; + $this->post_limit = $post_limit; + } + + /** + * Reduces the user provided filters and assigns them a total weight. + * + * @param FiltersParseResult $filters The filters to sanitize, reduce and weight. + * @return SearchFilters + */ + public function reduceAndWeight(FiltersParseResult $filters): SearchFilters { + $weighted = new SearchFilters(); + + if ($filters->subject !== null) { + list($fragments, $total_len, $wildcard_weight) = self::filterAndWeight($filters->subject); + + if ($total_len <= self::MAX_LENGTH_SUBJECT) { + $weighted->subject = $fragments; + $weighted->weight = $wildcard_weight; + } + } + if ($filters->name !== null) { + list($fragments, $total_len, $wildcard_weight) = self::filterAndWeight($filters->name); + + if ($total_len <= self::MAX_LENGTH_NAME) { + $weighted->name = $fragments; + $weighted->weight += $wildcard_weight; + } + } + // No wildcard support, and obligatory anyway so it weights 0. + $weighted->board = $filters->board; + if ($filters->flag !== null) { + $weighted->flag = []; + + if ($this->flag_map !== null && !empty($this->flag_map)) { + $max_flag_length = \array_reduce($this->flag_map, fn($max, $str) => \max($max, \strlen($str)), 0); + + list($fragments, $total_len, $wildcard_weight) = self::filterAndWeight($filters->flag); + + // Add 2 to account for possible wildcards on the ends. + if ($total_len <= $max_flag_length + 2) { + $weighted->flag = $fragments; + $weighted->weight += $wildcard_weight; + } + } + } + $weighted->id = $filters->id; + $weighted->thread = $filters->thread; + if (!empty($filters->body)) { + foreach ($filters->body as $keyword) { + list($fragments, $total_len, $wildcard_weight) = self::filterAndWeight($keyword); + $content_weight = self::weightByContent($fragments); + $str_weight = $content_weight + $wildcard_weight; + + if ($str_weight + $weighted->weight <= $this->max_weight) { + $weighted->weight += $str_weight; + $weighted->body[] = $fragments; + } + } + } + + return $weighted; + } + + /** + * Run a search on user posts with the given filters. + * + * @param SearchFilters $filters An array of filters made by {@see self::parse()}. + * @param ?string $fallback_board Fallback board if there isn't a board filter. + * @return array Data array straight from the PDO, with all the fields in posts.sql + */ + public function search(string $ip, string $raw_query, SearchFilters $filters, ?string $fallback_board): array { + $board = $filters->board ?? $fallback_board; + if ($board === null) { + return []; + } + + $valid_uris = listBoards(true); + if (!\in_array($board, $valid_uris)) { + return []; + } + + $weight_perc = ($filters->weight / $this->max_weight) * 100; + if ($weight_perc > 85) { + /// Over 85 of the weight. + $this->log->log(LogDriver::NOTICE, "$ip search: weight $weight_perc ({$filters->weight}) query '$raw_query'"); + } else { + $this->log->log(LogDriver::INFO, "$ip search: weight $weight_perc ({$filters->weight}) query '$raw_query'"); + } + + $flags = []; + if ($filters->flag !== null && $this->flag_map !== null) { + $flags = $this->matchStrings($this->flag_map, $filters->flag); + if (empty($flags)) { + // The query doesn't match any flags so it will always fail anyway. + return []; + } + } + + return $this->user_queries->searchPosts( + $board, + $filters->subject, + $filters->name, + $flags, + $filters->id, + $filters->thread, + $filters->body, + $this->post_limit + ); + } +} From 63be2bca4e38588eb2bdd5dfc9b6dc22ae1246dd Mon Sep 17 00:00:00 2001 From: Zankaria Date: Fri, 23 May 2025 22:31:35 +0200 Subject: [PATCH 08/25] SearchService.php: limit and expose the searchable boards --- inc/Service/SearchService.php | 35 ++++++++++++++++++++++++++++------- 1 file changed, 28 insertions(+), 7 deletions(-) diff --git a/inc/Service/SearchService.php b/inc/Service/SearchService.php index e9aab2bd..93ea745d 100644 --- a/inc/Service/SearchService.php +++ b/inc/Service/SearchService.php @@ -37,6 +37,7 @@ class SearchService { private float $max_weight; private int $max_query_length; private int $post_limit; + private array $searchable_board_uris; private static function truncateQuery(string $text, int $byteLimit): ?string { @@ -242,16 +243,30 @@ class SearchService { } /** + * @param LogDriver $log Log river. * @param UserPostQueries $user_queries User posts queries. - * @param ?flag_map $max_flag_length The key-value map of user flags, or null to disable flag search. + * @param ?array $flag_map The key-value map of user flags, or null to disable flag search. + * @param float $max_weight The maximum weight of the parsed user query. Body filters that go beyond this limit are discarded. + * @param int $max_query_length Maximum length of the raw input query before it's truncated. + * @param int $post_limit Maximum number of results. + * @param ?array $searchable_board_uris The uris of the board that can be searched. Null to search all the boards. */ - public function __construct(LogDriver $log, UserPostQueries $user_queries, ?array $flag_map, float $max_weight, int $max_query_length, int $post_limit) { + public function __construct( + LogDriver $log, + UserPostQueries $user_queries, + ?array $flag_map, + float $max_weight, + int $max_query_length, + int $post_limit, + ?array $searchable_board_uris + ) { $this->log = $log; $this->user_queries = $user_queries; $this->flag_map = $flag_map; $this->max_weight = $max_weight; $this->max_query_length = $max_query_length; $this->post_limit = $post_limit; + $this->searchable_board_uris = $searchable_board_uris ?? listBoards(true); } /** @@ -284,7 +299,7 @@ class SearchService { if ($filters->flag !== null) { $weighted->flag = []; - if ($this->flag_map !== null && !empty($this->flag_map)) { + if (!empty($this->flag_map)) { $max_flag_length = \array_reduce($this->flag_map, fn($max, $str) => \max($max, \strlen($str)), 0); list($fragments, $total_len, $wildcard_weight) = self::filterAndWeight($filters->flag); @@ -322,13 +337,12 @@ class SearchService { * @return array Data array straight from the PDO, with all the fields in posts.sql */ public function search(string $ip, string $raw_query, SearchFilters $filters, ?string $fallback_board): array { - $board = $filters->board ?? $fallback_board; + $board = !empty($filters->board) ? $filters->board : $fallback_board; if ($board === null) { return []; } - $valid_uris = listBoards(true); - if (!\in_array($board, $valid_uris)) { + if (!\in_array($board, $this->searchable_board_uris)) { return []; } @@ -341,7 +355,7 @@ class SearchService { } $flags = []; - if ($filters->flag !== null && $this->flag_map !== null) { + if ($filters->flag !== null && !empty($this->flag_map)) { $flags = $this->matchStrings($this->flag_map, $filters->flag); if (empty($flags)) { // The query doesn't match any flags so it will always fail anyway. @@ -360,4 +374,11 @@ class SearchService { $this->post_limit ); } + + /** + * Returns the uris of the boards that may be searched. + */ + public function getSearchableBoards(): array { + return $this->searchable_board_uris; + } } From b61cb8acf3b79a0759e54f1b85b8114800bcc775 Mon Sep 17 00:00:00 2001 From: Zankaria Date: Sat, 24 May 2025 01:05:43 +0200 Subject: [PATCH 09/25] SearchService.php: add SearchQueries and checkFlood --- inc/Service/SearchService.php | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/inc/Service/SearchService.php b/inc/Service/SearchService.php index 93ea745d..443edd44 100644 --- a/inc/Service/SearchService.php +++ b/inc/Service/SearchService.php @@ -2,7 +2,7 @@ namespace Vichan\Service; use Vichan\Data\Driver\LogDriver; -use Vichan\Data\{FiltersParseResult, UserPostQueries, SearchFilters}; +use Vichan\Data\{FiltersParseResult, UserPostQueries, SearchFilters, SearchQueries}; class SearchService { @@ -33,6 +33,7 @@ class SearchService { private LogDriver $log; private UserPostQueries $user_queries; + private SearchQueries $search_queries; private ?array $flag_map; private float $max_weight; private int $max_query_length; @@ -245,6 +246,7 @@ class SearchService { /** * @param LogDriver $log Log river. * @param UserPostQueries $user_queries User posts queries. + * @param SearchQueries $search_queries Search queries for flood detection. * @param ?array $flag_map The key-value map of user flags, or null to disable flag search. * @param float $max_weight The maximum weight of the parsed user query. Body filters that go beyond this limit are discarded. * @param int $max_query_length Maximum length of the raw input query before it's truncated. @@ -254,6 +256,7 @@ class SearchService { public function __construct( LogDriver $log, UserPostQueries $user_queries, + SearchQueries $search_queries, ?array $flag_map, float $max_weight, int $max_query_length, @@ -262,6 +265,7 @@ class SearchService { ) { $this->log = $log; $this->user_queries = $user_queries; + $this->search_queries = $search_queries; $this->flag_map = $flag_map; $this->max_weight = $max_weight; $this->max_query_length = $max_query_length; @@ -375,6 +379,17 @@ class SearchService { ); } + /** + * Check if the IP-query pair passes the limit. + * + * @param string $ip Source IP. + * @param string $phrase The search query. + * @return bool True if the request goes over the limit. + */ + public function checkFlood(string $ip, string $raw_query) { + return $this->search_queries->checkFlood($ip, $raw_query); + } + /** * Returns the uris of the boards that may be searched. */ From a749cc829c04ff6c8c3d688b84a01310c1f1d256 Mon Sep 17 00:00:00 2001 From: Zankaria Date: Sat, 7 Jun 2025 01:44:14 +0200 Subject: [PATCH 10/25] SearchService.php: check for query too broad --- inc/Service/SearchService.php | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/inc/Service/SearchService.php b/inc/Service/SearchService.php index 443edd44..c3428073 100644 --- a/inc/Service/SearchService.php +++ b/inc/Service/SearchService.php @@ -338,14 +338,25 @@ class SearchService { * * @param SearchFilters $filters An array of filters made by {@see self::parse()}. * @param ?string $fallback_board Fallback board if there isn't a board filter. - * @return array Data array straight from the PDO, with all the fields in posts.sql + * @return ?array Data array straight from the PDO, with all the fields in posts.sql, or null if the query was too broad. */ - public function search(string $ip, string $raw_query, SearchFilters $filters, ?string $fallback_board): array { + public function search(string $ip, string $raw_query, SearchFilters $filters, ?string $fallback_board): ?array { $board = !empty($filters->board) ? $filters->board : $fallback_board; if ($board === null) { return []; } + // Only board is specified. + if (empty($filters->subject) && + empty($filters->name) && + empty($filters->flag) && + $filters->id === null && + $filters->thread === null && + empty($filters->body) + ) { + return null; + } + if (!\in_array($board, $this->searchable_board_uris)) { return []; } From 78e31f653c01f09bc74ab6f14b9a6c01b2bc4384 Mon Sep 17 00:00:00 2001 From: Zankaria Date: Mon, 7 Jul 2025 23:36:47 +0200 Subject: [PATCH 11/25] SearchService.php: expose if the flag filter is enabled --- inc/Service/SearchService.php | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/inc/Service/SearchService.php b/inc/Service/SearchService.php index c3428073..61019e4b 100644 --- a/inc/Service/SearchService.php +++ b/inc/Service/SearchService.php @@ -407,4 +407,11 @@ class SearchService { public function getSearchableBoards(): array { return $this->searchable_board_uris; } + + /** + * @return bool True if the flag filter is enabled. + */ + public function isFlagFilterEnabled(): bool { + return !empty($this->flag_map); + } } From 330d6b7c011dafa313ea0a562ad28e0b01452bdc Mon Sep 17 00:00:00 2001 From: Zankaria Date: Fri, 23 May 2025 23:02:02 +0200 Subject: [PATCH 12/25] config.php: add search.max_weight to the configuration options --- inc/config.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/inc/config.php b/inc/config.php index fb4b4493..49097e77 100644 --- a/inc/config.php +++ b/inc/config.php @@ -1856,6 +1856,10 @@ // Limit of search results $config['search']['search_limit'] = 100; + // Maximum weigth of the search query. + // Body search filters are discarded if they make the query heavier than this. + $config['search']['max_weight'] = 80; + // Boards for searching //$config['search']['boards'] = array('a', 'b', 'c', 'd', 'e'); From 701007ea953870549da118fe7239d9a9e8592f1c Mon Sep 17 00:00:00 2001 From: Zankaria Date: Fri, 23 May 2025 23:02:38 +0200 Subject: [PATCH 13/25] config.php: better documentation for search.boards --- inc/config.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/inc/config.php b/inc/config.php index 49097e77..fd99dfe3 100644 --- a/inc/config.php +++ b/inc/config.php @@ -1860,7 +1860,7 @@ // Body search filters are discarded if they make the query heavier than this. $config['search']['max_weight'] = 80; - // Boards for searching + // Uncomment to limit the search feature to the given boards by uri. //$config['search']['boards'] = array('a', 'b', 'c', 'd', 'e'); // Enable public logs? 0: NO, 1: YES, 2: YES, but drop names From 48f29774c3b93d18aeff3f42a78615af70932b27 Mon Sep 17 00:00:00 2001 From: Zankaria Date: Fri, 23 May 2025 23:05:13 +0200 Subject: [PATCH 14/25] config.php: add search.max_length --- inc/config.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/inc/config.php b/inc/config.php index fd99dfe3..2f43f614 100644 --- a/inc/config.php +++ b/inc/config.php @@ -1860,6 +1860,10 @@ // Body search filters are discarded if they make the query heavier than this. $config['search']['max_weight'] = 80; + // Maximum length of the user sent search query. + // Characters beyond the limit are truncated and ignored. + $config['search']['max_length'] = 768; + // Uncomment to limit the search feature to the given boards by uri. //$config['search']['boards'] = array('a', 'b', 'c', 'd', 'e'); From b542ee949aa042ec20c2c0a0edcb0ee30bc5a117 Mon Sep 17 00:00:00 2001 From: Zankaria Date: Fri, 23 May 2025 22:33:52 +0200 Subject: [PATCH 15/25] context.php: add UserPostQueries --- inc/context.php | 1 + 1 file changed, 1 insertion(+) diff --git a/inc/context.php b/inc/context.php index 11a153ec..77603fc6 100644 --- a/inc/context.php +++ b/inc/context.php @@ -78,5 +78,6 @@ function build_context(array $config): Context { return new UserPostQueries($c->get(\PDO::class)); }, IpNoteQueries::class => fn($c) => new IpNoteQueries($c->get(\PDO::class), $c->get(CacheDriver::class)), + UserPostQueries::class => fn($c) => new UserPostQueries($c->get(\PDO::class)) ]); } From 519036e6250f44c8a5cae215690ea9978fd4ec5b Mon Sep 17 00:00:00 2001 From: Zankaria Date: Fri, 23 May 2025 23:18:42 +0200 Subject: [PATCH 16/25] context.php: add SearchService --- inc/context.php | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/inc/context.php b/inc/context.php index 77603fc6..a3eae13f 100644 --- a/inc/context.php +++ b/inc/context.php @@ -1,8 +1,9 @@ function($c) { + $config = $c->get('config'); + if ($config['user_flag']) { + $flags = $config['user_flags']; + } elseif ($config['country_flags']) { + $flags = Flags::EMBEDDED_FLAGS; + } else { + $flags = null; + } + + $board_uris = $config['search']['boards'] ?? null; + + return new SearchService( + $c->get(LogDriver::class), + $c->get(UserPostQueries::class), + $c->get(SearchQueries::class), + $flags, + $config['search']['max_weight'], + $config['search']['max_length'], + $config['search']['search_limit'], + $board_uris + ); + }, ReportQueries::class => function($c) { $auto_maintenance = (bool)$c->get('config')['auto_maintenance']; $pdo = $c->get(\PDO::class); From b2308b1ffee8ff2833a556bf930fe2a4c9be3d40 Mon Sep 17 00:00:00 2001 From: Zankaria Date: Sat, 24 May 2025 00:50:02 +0200 Subject: [PATCH 17/25] context.php: add SearchQueries --- inc/context.php | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/inc/context.php b/inc/context.php index a3eae13f..f5123b64 100644 --- a/inc/context.php +++ b/inc/context.php @@ -1,7 +1,7 @@ get(\PDO::class)); }, IpNoteQueries::class => fn($c) => new IpNoteQueries($c->get(\PDO::class), $c->get(CacheDriver::class)), - UserPostQueries::class => fn($c) => new UserPostQueries($c->get(\PDO::class)) + SearchQueries::class => function($c) { + $config = $c->get('config'); + list($queries_for_single, $range_for_single_min) = $config['search']['queries_per_minutes']; + list($queries_for_all, $range_for_all_min) = $config['search']['queries_per_minutes_all']; + + return new SearchQueries( + $c->get(\PDO::class), + $queries_for_single, + $range_for_single_min * 60, + $queries_for_all, + $range_for_all_min * 60, + (bool)$config['auto_maintenance'] + ); + } ]); } From 29f476e3a9d11640832340738250c6aeb21ada50 Mon Sep 17 00:00:00 2001 From: Zankaria Date: Fri, 9 May 2025 21:53:13 +0200 Subject: [PATCH 18/25] search.php: refactor, use SearchService --- search.php | 202 +++++++++++++---------------------------------------- 1 file changed, 49 insertions(+), 153 deletions(-) diff --git a/search.php b/search.php index bfd4b022..ebce15fc 100644 --- a/search.php +++ b/search.php @@ -1,178 +1,74 @@ get(SearchService::class); -if (isset($config['search']['boards'])) { - $boards = $config['search']['boards']; -} else { - $boards = listBoards(TRUE); -} +if (isset($_GET['search']) && !empty($_GET['search'])) { + $raw_search = $_GET['search']; + $ip = $_SERVER['REMOTE_ADDR']; + $fallback_board = (isset($_GET['board']) && !empty($_GET['board'])) ? $_GET['board'] : null; -$body = Element('search_form.html', Array('boards' => $boards, 'board' => isset($_GET['board']) ? $_GET['board'] : false, 'search' => isset($_GET['search']) ? str_replace('"', '"', utf8tohtml($_GET['search'])) : false)); -if (isset($_GET['search']) && !empty($_GET['search']) && isset($_GET['board']) && in_array($_GET['board'], $boards)) { - $phrase = $_GET['search']; - $_body = ''; - - $query = prepare("SELECT COUNT(*) FROM ``search_queries`` WHERE `ip` = :ip AND `time` > :time"); - $query->bindValue(':ip', $_SERVER['REMOTE_ADDR']); - $query->bindValue(':time', time() - ($queries_per_minutes[1] * 60)); - $query->execute() or error(db_error($query)); - if ($query->fetchColumn() > $queries_per_minutes[0]) + if ($search_service->checkFlood($ip, $raw_search)) { error(_('Wait a while before searching again, please.')); - - $query = prepare("SELECT COUNT(*) FROM ``search_queries`` WHERE `time` > :time"); - $query->bindValue(':time', time() - ($queries_per_minutes_all[1] * 60)); - $query->execute() or error(db_error($query)); - if ($query->fetchColumn() > $queries_per_minutes_all[0]) - error(_('Wait a while before searching again, please.')); - - - $query = prepare("INSERT INTO ``search_queries`` VALUES (:ip, :time, :query)"); - $query->bindValue(':ip', $_SERVER['REMOTE_ADDR']); - $query->bindValue(':time', time()); - $query->bindValue(':query', $phrase); - $query->execute() or error(db_error($query)); - - _syslog(LOG_NOTICE, 'Searched /' . $_GET['board'] . '/ for "' . $phrase . '"'); - - // Cleanup search queries table - $query = prepare("DELETE FROM ``search_queries`` WHERE `time` <= :time"); - $query->bindValue(':time', time() - ($queries_per_minutes_all[1] * 60)); - $query->execute() or error(db_error($query)); - - openBoard($_GET['board']); - - $filters = Array(); - - function search_filters($m) { - global $filters; - $name = $m[2]; - $value = isset($m[4]) ? $m[4] : $m[3]; - - if (!in_array($name, array('id', 'thread', 'subject', 'name'))) { - // unknown filter - return $m[0]; - } - - $filters[$name] = $value; - - return $m[1]; } - $phrase = trim(preg_replace_callback('/(^|\s)(\w+):("(.*)?"|[^\s]*)/', 'search_filters', $phrase)); + // Actually do the search. + $parse_res = $search_service->parse($raw_search); + $filters = $search_service->reduceAndWeight($parse_res); + $search_res = $search_service->search($ip, $raw_search, $filters, $fallback_board); - if (!preg_match('/[^*^\s]/', $phrase) && empty($filters)) { - _syslog(LOG_WARNING, 'Query too broad.'); - $body .= '

(Query too broad.)

'; - echo Element('page.html', Array( - 'config'=>$config, - 'title'=>'Search', - 'body'=>$body, - )); - exit; - } - // Escape escape character - $phrase = str_replace('!', '!!', $phrase); + // Needed to set a global variable further down the stack, plus the template. + $actual_board = $filters->board ?? $fallback_board; - // Remove SQL wildcard - $phrase = str_replace('%', '!%', $phrase); + $body = Element('search_form.html', [ + 'boards' => $search_service->getSearchableBoards(), + 'board' => $actual_board, + 'search' => \str_replace('"', '"', utf8tohtml($_GET['search'])) + ]); - // Use asterisk as wildcard to suit convention - $phrase = str_replace('*', '%', $phrase); - - // Remove `, it's used by table prefix magic - $phrase = str_replace('`', '!`', $phrase); - - $like = ''; - $match = Array(); - - // Find exact phrases - if (preg_match_all('/"(.+?)"/', $phrase, $m)) { - foreach($m[1] as &$quote) { - $phrase = str_replace("\"{$quote}\"", '', $phrase); - $match[] = $pdo->quote($quote); - } - } - - $words = explode(' ', $phrase); - foreach($words as &$word) { - if (empty($word)) { - continue; - } - $match[] = $pdo->quote($word); - } - - $like = ''; - foreach($match as &$phrase) { - if (!empty($like)) { - $like .= ' AND '; - } - $phrase = preg_replace('/^\'(.+)\'$/', '\'%$1%\'', $phrase); - $like .= '`body` LIKE ' . $phrase . ' ESCAPE \'!\''; - } - - foreach($filters as $name => $value) { - if (!empty($like)) { - $like .= ' AND '; - } - $like .= '`' . $name . '` = '. $pdo->quote($value); - } - - $like = str_replace('%', '%%', $like); - - $query = prepare(sprintf("SELECT * FROM ``posts_%s`` WHERE " . $like . " ORDER BY `time` DESC LIMIT :limit", $board['uri'])); - $query->bindValue(':limit', $search_limit, PDO::PARAM_INT); - $query->execute() or error(db_error($query)); - - if ($query->rowCount() == $search_limit) { - _syslog(LOG_WARNING, 'Query too broad.'); - $body .= '

('._('Query too broad.').')

'; - echo Element('page.html', Array( - 'config'=>$config, - 'title'=>'Search', - 'body'=>$body, - )); - exit; - } - - $temp = ''; - while ($post = $query->fetch()) { - if (!$post['thread']) { - $po = new Thread($post); - } else { - $po = new Post($post); - } - $temp .= $po->build(true) . '
'; - } - - if (!empty($temp)) - $_body .= '
' . - sprintf(ngettext('%d result in', '%d results in', $query->rowCount()), - $query->rowCount()) . ' ' . - sprintf($config['board_abbreviation'], $board['uri']) . ' - ' . $board['title'] . - '' . $temp . '
'; - - $body .= '
'; - if (!empty($_body)) { - $body .= $_body; + if (empty($search_res)) { + $body .= '

(' . _('No results.') . ')

'; } else { - $body .= '

('._('No results.').')

'; + $body .= '
'; + + openBoard($actual_board); + + $posts_html = ''; + foreach ($search_res as $post) { + if (!$post['thread']) { + $po = new Thread($post); + } else { + $po = new Post($post); + } + $posts_html .= $po->build(true) . '
'; + } + + $body .= '
' . + sprintf(ngettext('%d result in', '%d results in', \count($search_res)), \count($search_res)) . ' ' . + sprintf($config['board_abbreviation'], $board['uri']) . ' - ' . $board['title'] . + '' . $posts_html . '
'; } +} else { + $body = Element('search_form.html', [ + 'boards' => $search_service->getSearchableBoards(), + 'board' => false, + 'search' => false + ]); } echo Element('page.html', Array( 'config'=>$config, - 'title'=>_('Search'), + 'title'=> _('Search'), 'body'=>'' . $body )); From bbcfbf78aea7bc8324db0d3abd0a0136a4381e39 Mon Sep 17 00:00:00 2001 From: Zankaria Date: Sat, 7 Jun 2025 01:13:46 +0200 Subject: [PATCH 19/25] docker: print PHP errors on docker log --- docker/php/www.conf | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docker/php/www.conf b/docker/php/www.conf index d9d84760..f96329c8 100644 --- a/docker/php/www.conf +++ b/docker/php/www.conf @@ -1,5 +1,7 @@ [www] access.log = /proc/self/fd/2 +php_admin_value[error_log] = /proc/self/fd/2 +php_admin_flag[log_errors] = on ; Ensure worker stdout and stderr are sent to the main error log. catch_workers_output = yes From 1723db32a69f9cd28d9c2534fb1b93af388ac9b0 Mon Sep 17 00:00:00 2001 From: Zankaria Date: Sat, 7 Jun 2025 01:20:56 +0200 Subject: [PATCH 20/25] style.css: remove fieldset label css (interfered with threads in the search page) --- stylesheets/style.css | 4 ---- 1 file changed, 4 deletions(-) diff --git a/stylesheets/style.css b/stylesheets/style.css index bef69cca..d2130794 100644 --- a/stylesheets/style.css +++ b/stylesheets/style.css @@ -753,10 +753,6 @@ table.test td img { margin: 0; } -fieldset label { - display: block; -} - div.pages { /*! color: #89A; */ /*! background: #D6DAF0; */ From c9610bb2375ed63e88b3dab77f0a591e690a8d31 Mon Sep 17 00:00:00 2001 From: Zankaria Date: Sat, 7 Jun 2025 01:44:42 +0200 Subject: [PATCH 21/25] search.php: handle query too broad error --- search.php | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/search.php b/search.php index ebce15fc..55c9d5cf 100644 --- a/search.php +++ b/search.php @@ -26,7 +26,6 @@ if (isset($_GET['search']) && !empty($_GET['search'])) { $filters = $search_service->reduceAndWeight($parse_res); $search_res = $search_service->search($ip, $raw_search, $filters, $fallback_board); - // Needed to set a global variable further down the stack, plus the template. $actual_board = $filters->board ?? $fallback_board; @@ -36,7 +35,9 @@ if (isset($_GET['search']) && !empty($_GET['search'])) { 'search' => \str_replace('"', '"', utf8tohtml($_GET['search'])) ]); - if (empty($search_res)) { + if ($search_res === null) { + $body .= '

(' . _('Query too broad.') . ')

'; + } elseif (empty($search_res)) { $body .= '

(' . _('No results.') . ')

'; } else { $body .= '
'; @@ -70,5 +71,5 @@ if (isset($_GET['search']) && !empty($_GET['search'])) { echo Element('page.html', Array( 'config'=>$config, 'title'=> _('Search'), - 'body'=>'' . $body + 'body'=> $body )); From 3c5cca9265779161def20b126e19458617b7e165 Mon Sep 17 00:00:00 2001 From: Zankaria Date: Mon, 7 Jul 2025 23:37:44 +0200 Subject: [PATCH 22/25] search.php: supply flag filter enablement --- search.php | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/search.php b/search.php index 55c9d5cf..cd01c4ad 100644 --- a/search.php +++ b/search.php @@ -32,7 +32,8 @@ if (isset($_GET['search']) && !empty($_GET['search'])) { $body = Element('search_form.html', [ 'boards' => $search_service->getSearchableBoards(), 'board' => $actual_board, - 'search' => \str_replace('"', '"', utf8tohtml($_GET['search'])) + 'search' => \str_replace('"', '"', utf8tohtml($_GET['search'])), + 'flags_enabled' => $search_service->isFlagFilterEnabled() ]); if ($search_res === null) { @@ -64,7 +65,8 @@ if (isset($_GET['search']) && !empty($_GET['search'])) { $body = Element('search_form.html', [ 'boards' => $search_service->getSearchableBoards(), 'board' => false, - 'search' => false + 'search' => false, + 'flags_enabled' => $search_service->isFlagFilterEnabled() ]); } From 626a3fd683b6a6482548f52a4cabec7ae52c59d4 Mon Sep 17 00:00:00 2001 From: Zankaria Date: Mon, 7 Jul 2025 22:20:08 +0200 Subject: [PATCH 23/25] search_form.html: format --- templates/search_form.html | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/templates/search_form.html b/templates/search_form.html index 095968de..e88f9ffb 100644 --- a/templates/search_form.html +++ b/templates/search_form.html @@ -2,17 +2,17 @@

{% trans %}Search{% endtrans %}

- + From 746f36e9f25123725ede15be69ba33f49a9e4bb4 Mon Sep 17 00:00:00 2001 From: Zankaria Date: Mon, 7 Jul 2025 22:22:49 +0200 Subject: [PATCH 24/25] search_form.html: update filters help --- templates/search_form.html | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/templates/search_form.html b/templates/search_form.html index e88f9ffb..7d2b42c2 100644 --- a/templates/search_form.html +++ b/templates/search_form.html @@ -19,6 +19,10 @@

- {% trans %}Search is case-insensitive and based on keywords. To match exact phrases, use "quotes". Use an asterisk (*) for wildcard.

You may apply the following filters to your searches: id, thread, subject, and name. To apply a filter, simply add to your query, for example, name:Anonymous or subject:"Some Thread". Wildcards cannot be used in filters.{% endtrans %} + {% if flags_enabled %} + {% trans %}Search is case-insensitive and based on keywords. To match exact phrases, use "quotes". Use an asterisk (*) for wildcard.

You may apply the following filters to your searches: id, thread, subject, name, flag and board (as an alternative syntax). To apply a filter, simply add to your query, for example, name:Anonymous or subject:"Some Thread". The id, thread and board filters do not support wildcards.{% endtrans %} + {% else %} + {% trans %}Search is case-insensitive and based on keywords. To match exact phrases, use "quotes". Use an asterisk (*) for wildcard.

You may apply the following filters to your searches: id, thread, subject, name and board (as an alternative syntax). To apply a filter, simply add to your query, for example, name:Anonymous or subject:"Some Thread". The id, thread and board filters do not support wildcards.{% endtrans %} + {% endif %}

From 26ad13bbea640d0dcf892092fe6c8b38adce960f Mon Sep 17 00:00:00 2001 From: Zankaria Date: Mon, 7 Jul 2025 23:24:43 +0200 Subject: [PATCH 25/25] search_form.html: make the search for UI less terrible --- templates/search_form.html | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/templates/search_form.html b/templates/search_form.html index 7d2b42c2..e433ca44 100644 --- a/templates/search_form.html +++ b/templates/search_form.html @@ -1,9 +1,14 @@
+

{% trans %}Search{% endtrans %}

-
+ +

+

- -