diff --git a/app/Controllers/entryController.php b/app/Controllers/entryController.php index 6bcf7f49e..c8f4bf8d1 100644 --- a/app/Controllers/entryController.php +++ b/app/Controllers/entryController.php @@ -45,14 +45,14 @@ class FreshRSS_entry_Controller extends FreshRSS_ActionController { * - is_read (default: true) */ public function readAction(): void { - $get = Minz_Request::paramString('get'); - $next_get = Minz_Request::paramString('nextGet') ?: $get; - $id_max = Minz_Request::paramString('idMax'); + $get = Minz_Request::paramString('get', plaintext: true); + $next_get = Minz_Request::paramString('nextGet', plaintext: true) ?: $get; + $id_max = Minz_Request::paramString('idMax', plaintext: true); if (!ctype_digit($id_max)) { $id_max = '0'; } $is_read = Minz_Request::paramTernary('is_read') ?? true; - FreshRSS_Context::$search = new FreshRSS_BooleanSearch(Minz_Request::paramString('search')); + FreshRSS_Context::$search = new FreshRSS_BooleanSearch(Minz_Request::paramString('search', plaintext: true)); $maxPubDate = Minz_Request::paramInt('maxPubDate'); if ($maxPubDate > 0) { $search = new FreshRSS_Search(''); @@ -170,8 +170,8 @@ class FreshRSS_entry_Controller extends FreshRSS_ActionController { } } else { /** @var list $idArray */ - $idArray = Minz_Request::paramArrayString('id'); - $idString = Minz_Request::paramString('id'); + $idArray = Minz_Request::paramArrayString('id', plaintext: true); + $idString = Minz_Request::paramString('id', plaintext: true); if (count($idArray) > 0) { $ids = $idArray; } elseif (ctype_digit($idString)) { @@ -218,7 +218,7 @@ class FreshRSS_entry_Controller extends FreshRSS_ActionController { * If id is false, nothing happened. */ public function bookmarkAction(): void { - $id = Minz_Request::paramString('id'); + $id = Minz_Request::paramString('id', plaintext: true); $is_favourite = Minz_Request::paramTernary('is_favorite') ?? true; if ($id != '' && ctype_digit($id)) { $entryDAO = FreshRSS_Factory::createEntryDao(); diff --git a/app/Models/BooleanSearch.php b/app/Models/BooleanSearch.php index 68885cde8..e959f7af9 100644 --- a/app/Models/BooleanSearch.php +++ b/app/Models/BooleanSearch.php @@ -26,16 +26,6 @@ class FreshRSS_BooleanSearch implements \Stringable { if ($input === '') { return; } - if ($level === 0) { - $input = preg_replace('/:"(.*?)"/', ':"\1"', $input); - if (!is_string($input)) { - return; - } - $input = preg_replace('/(?<=[\s(!-]|^)"(.*?)"/', '"\1"', $input); - if (!is_string($input)) { - return; - } - } $this->raw_input = $input; if ($level === 0) { @@ -517,7 +507,7 @@ class FreshRSS_BooleanSearch implements \Stringable { if ($part === '') { continue; } - $operator = $search instanceof FreshRSS_BooleanSearch ? $search->operator() : 'OR'; + $operator = $search instanceof FreshRSS_BooleanSearch ? $search->operator : 'OR'; if ((str_contains($part, ' ') || str_starts_with($part, '-')) && (count($this->searches) > 1 || in_array($operator, ['OR NOT', 'AND NOT'], true))) { $part = '(' . $part . ')'; diff --git a/app/Models/Context.php b/app/Models/Context.php index be4a06cc8..cc1b77026 100644 --- a/app/Models/Context.php +++ b/app/Models/Context.php @@ -248,7 +248,7 @@ final class FreshRSS_Context { } } - self::$search = new FreshRSS_BooleanSearch(Minz_Request::paramString('search')); + self::$search = new FreshRSS_BooleanSearch(Minz_Request::paramString('search', plaintext: true)); $order = Minz_Request::paramString('order', plaintext: true) ?: FreshRSS_Context::userConf()->sort_order; self::$order = in_array($order, ['ASC', 'DESC'], true) ? $order : 'DESC'; $sort = Minz_Request::paramString('sort', plaintext: true) ?: FreshRSS_Context::userConf()->sort; diff --git a/app/Models/Search.php b/app/Models/Search.php index 752e28408..201fed9f9 100644 --- a/app/Models/Search.php +++ b/app/Models/Search.php @@ -155,13 +155,19 @@ class FreshRSS_Search implements \Stringable { } private static function quote(string $s): string { - if (str_contains($s, ' ') || $s === '') { + if (strpbrk($s, ' "\'\\') !== false || $s === '') { return '"' . addcslashes($s, '\\"') . '"'; } return $s; } - private static function dateIntervalToString(?int $min, ?int $max): string { + private static function dateIntervalToString(int|false|null $min, int|false|null $max): string { + if ($min === false) { + $min = null; + } + if ($max === false) { + $max = null; + } if ($min === null && $max === null) { return ''; } @@ -261,184 +267,184 @@ class FreshRSS_Search implements \Stringable { public function __toString(): string { $result = ''; - if ($this->getEntryIds() !== null) { - $result .= ' e:' . implode(',', $this->getEntryIds()); + if ($this->entry_ids !== null) { + $result .= ' e:' . implode(',', $this->entry_ids); } - if ($this->getFeedIds() !== null) { - $result .= ' f:' . implode(',', $this->getFeedIds()); + if ($this->feed_ids !== null) { + $result .= ' f:' . implode(',', $this->feed_ids); } - if ($this->getCategoryIds() !== null) { - $result .= ' c:' . implode(',', $this->getCategoryIds()); + if ($this->category_ids !== null) { + $result .= ' c:' . implode(',', $this->category_ids); } - if ($this->getLabelIds() !== null) { - foreach ($this->getLabelIds() as $ids) { + if ($this->label_ids !== null) { + foreach ($this->label_ids as $ids) { $result .= ' L:' . (is_array($ids) ? implode(',', $ids) : $ids); } } - if ($this->getLabelNames() !== null) { - foreach ($this->getLabelNames() as $names) { + if ($this->label_names !== null) { + foreach ($this->label_names as $names) { $result .= ' labels:' . self::quote(implode(',', $names)); } } - if ($this->getMinUserdate() !== null || $this->getMaxUserdate() !== null) { - $result .= ' userdate:' . self::dateIntervalToString($this->getMinUserdate(), $this->getMaxUserdate()); + if ($this->min_userdate !== null || $this->max_userdate !== null) { + $result .= ' userdate:' . self::dateIntervalToString($this->min_userdate, $this->max_userdate); } - if ($this->getMinPubdate() !== null || $this->getMaxPubdate() !== null) { - $result .= ' pubdate:' . self::dateIntervalToString($this->getMinPubdate(), $this->getMaxPubdate()); + if ($this->min_pubdate !== null || $this->max_pubdate !== null) { + $result .= ' pubdate:' . self::dateIntervalToString($this->min_pubdate, $this->max_pubdate); } - if ($this->getMinDate() !== null || $this->getMaxDate() !== null) { - $result .= ' date:' . self::dateIntervalToString($this->getMinDate(), $this->getMaxDate()); + if ($this->min_date !== null || $this->max_date !== null) { + $result .= ' date:' . self::dateIntervalToString($this->min_date, $this->max_date); } - if ($this->getIntitleRegex() !== null) { - foreach ($this->getIntitleRegex() as $s) { + if ($this->intitle_regex !== null) { + foreach ($this->intitle_regex as $s) { $result .= ' intitle:' . $s; } } - if ($this->getIntitle() !== null) { - foreach ($this->getIntitle() as $s) { + if ($this->intitle !== null) { + foreach ($this->intitle as $s) { $result .= ' intitle:' . self::quote($s); } } - if ($this->getIntextRegex() !== null) { - foreach ($this->getIntextRegex() as $s) { + if ($this->intext_regex !== null) { + foreach ($this->intext_regex as $s) { $result .= ' intext:' . $s; } } - if ($this->getIntext() !== null) { - foreach ($this->getIntext() as $s) { + if ($this->intext !== null) { + foreach ($this->intext as $s) { $result .= ' intext:' . self::quote($s); } } - if ($this->getAuthorRegex() !== null) { - foreach ($this->getAuthorRegex() as $s) { + if ($this->author_regex !== null) { + foreach ($this->author_regex as $s) { $result .= ' author:' . $s; } } - if ($this->getAuthor() !== null) { - foreach ($this->getAuthor() as $s) { + if ($this->author !== null) { + foreach ($this->author as $s) { $result .= ' author:' . self::quote($s); } } - if ($this->getInurlRegex() !== null) { - foreach ($this->getInurlRegex() as $s) { + if ($this->inurl_regex !== null) { + foreach ($this->inurl_regex as $s) { $result .= ' inurl:' . $s; } } - if ($this->getInurl() !== null) { - foreach ($this->getInurl() as $s) { + if ($this->inurl !== null) { + foreach ($this->inurl as $s) { $result .= ' inurl:' . self::quote($s); } } - if ($this->getTagsRegex() !== null) { - foreach ($this->getTagsRegex() as $s) { + if ($this->tags_regex !== null) { + foreach ($this->tags_regex as $s) { $result .= ' #' . $s; } } - if ($this->getTags() !== null) { - foreach ($this->getTags() as $s) { + if ($this->tags !== null) { + foreach ($this->tags as $s) { $result .= ' #' . self::quote($s); } } - if ($this->getSearchRegex() !== null) { - foreach ($this->getSearchRegex() as $s) { + if ($this->search_regex !== null) { + foreach ($this->search_regex as $s) { $result .= ' ' . $s; } } - if ($this->getSearch() !== null) { - foreach ($this->getSearch() as $s) { + if ($this->search !== null) { + foreach ($this->search as $s) { $result .= ' ' . self::quote($s); } } - if ($this->getNotEntryIds() !== null) { - $result .= ' -e:' . implode(',', $this->getNotEntryIds()); + if ($this->not_entry_ids !== null) { + $result .= ' -e:' . implode(',', $this->not_entry_ids); } - if ($this->getNotFeedIds() !== null) { - $result .= ' -f:' . implode(',', $this->getNotFeedIds()); + if ($this->not_feed_ids !== null) { + $result .= ' -f:' . implode(',', $this->not_feed_ids); } - if ($this->getNotCategoryIds() !== null) { - $result .= ' -c:' . implode(',', $this->getNotCategoryIds()); + if ($this->not_category_ids !== null) { + $result .= ' -c:' . implode(',', $this->not_category_ids); } - if ($this->getNotLabelIds() !== null) { - foreach ($this->getNotLabelIds() as $ids) { + if ($this->not_label_ids !== null) { + foreach ($this->not_label_ids as $ids) { $result .= ' -L:' . (is_array($ids) ? implode(',', $ids) : $ids); } } - if ($this->getNotLabelNames() !== null) { - foreach ($this->getNotLabelNames() as $names) { + if ($this->not_label_names !== null) { + foreach ($this->not_label_names as $names) { $result .= ' -labels:' . self::quote(implode(',', $names)); } } - if ($this->getNotMinUserdate() !== null || $this->getNotMaxUserdate() !== null) { - $result .= ' -userdate:' . self::dateIntervalToString($this->getNotMinUserdate(), $this->getNotMaxUserdate()); + if ($this->not_min_userdate !== null || $this->not_max_userdate !== null) { + $result .= ' -userdate:' . self::dateIntervalToString($this->not_min_userdate, $this->not_max_userdate); } - if ($this->getNotMinPubdate() !== null || $this->getNotMaxPubdate() !== null) { - $result .= ' -pubdate:' . self::dateIntervalToString($this->getNotMinPubdate(), $this->getNotMaxPubdate()); + if ($this->not_min_pubdate !== null || $this->not_max_pubdate !== null) { + $result .= ' -pubdate:' . self::dateIntervalToString($this->not_min_pubdate, $this->not_max_pubdate); } - if ($this->getNotMinDate() !== null || $this->getNotMaxDate() !== null) { - $result .= ' -date:' . self::dateIntervalToString($this->getNotMinDate(), $this->getNotMaxDate()); + if ($this->not_min_date !== null || $this->not_max_date !== null) { + $result .= ' -date:' . self::dateIntervalToString($this->not_min_date, $this->not_max_date); } - if ($this->getNotIntitleRegex() !== null) { - foreach ($this->getNotIntitleRegex() as $s) { + if ($this->not_intitle_regex !== null) { + foreach ($this->not_intitle_regex as $s) { $result .= ' -intitle:' . $s; } } - if ($this->getNotIntitle() !== null) { - foreach ($this->getNotIntitle() as $s) { + if ($this->not_intitle !== null) { + foreach ($this->not_intitle as $s) { $result .= ' -intitle:' . self::quote($s); } } - if ($this->getNotIntextRegex() !== null) { - foreach ($this->getNotIntextRegex() as $s) { + if ($this->not_intext_regex !== null) { + foreach ($this->not_intext_regex as $s) { $result .= ' -intext:' . $s; } } - if ($this->getNotIntext() !== null) { - foreach ($this->getNotIntext() as $s) { + if ($this->not_intext !== null) { + foreach ($this->not_intext as $s) { $result .= ' -intext:' . self::quote($s); } } - if ($this->getNotAuthorRegex() !== null) { - foreach ($this->getNotAuthorRegex() as $s) { + if ($this->not_author_regex !== null) { + foreach ($this->not_author_regex as $s) { $result .= ' -author:' . $s; } } - if ($this->getNotAuthor() !== null) { - foreach ($this->getNotAuthor() as $s) { + if ($this->not_author !== null) { + foreach ($this->not_author as $s) { $result .= ' -author:' . self::quote($s); } } - if ($this->getNotInurlRegex() !== null) { - foreach ($this->getNotInurlRegex() as $s) { + if ($this->not_inurl_regex !== null) { + foreach ($this->not_inurl_regex as $s) { $result .= ' -inurl:' . $s; } } - if ($this->getNotInurl() !== null) { - foreach ($this->getNotInurl() as $s) { + if ($this->not_inurl !== null) { + foreach ($this->not_inurl as $s) { $result .= ' -inurl:' . self::quote($s); } } - if ($this->getNotTagsRegex() !== null) { - foreach ($this->getNotTagsRegex() as $s) { + if ($this->not_tags_regex !== null) { + foreach ($this->not_tags_regex as $s) { $result .= ' -#' . $s; } } - if ($this->getNotTags() !== null) { - foreach ($this->getNotTags() as $s) { + if ($this->not_tags !== null) { + foreach ($this->not_tags as $s) { $result .= ' -#' . self::quote($s); } } - if ($this->getNotSearchRegex() !== null) { - foreach ($this->getNotSearchRegex() as $s) { + if ($this->not_search_regex !== null) { + foreach ($this->not_search_regex as $s) { $result .= ' -' . $s; } } - if ($this->getNotSearch() !== null) { - foreach ($this->getNotSearch() as $s) { + if ($this->not_search !== null) { + foreach ($this->not_search as $s) { $result .= ' -' . self::quote($s); } } @@ -486,25 +492,25 @@ class FreshRSS_Search implements \Stringable { return $this->not_label_ids; } /** @return list>|null */ - public function getLabelNames(): ?array { - return $this->label_names; + public function getLabelNames(bool $plaintext = false): ?array { + return $plaintext ? $this->label_names : Minz_Helper::htmlspecialchars_utf8($this->label_names, ENT_NOQUOTES); } /** @return list>|null */ - public function getNotLabelNames(): ?array { - return $this->not_label_names; + public function getNotLabelNames(bool $plaintext = false): ?array { + return $plaintext ? $this->not_label_names : Minz_Helper::htmlspecialchars_utf8($this->not_label_names, ENT_NOQUOTES); } /** @return list|null */ - public function getIntitle(): ?array { - return $this->intitle; + public function getIntitle(bool $plaintext = false): ?array { + return $plaintext ? $this->intitle : Minz_Helper::htmlspecialchars_utf8($this->intitle, ENT_NOQUOTES); } /** @return list|null */ public function getIntitleRegex(): ?array { return $this->intitle_regex; } /** @return list|null */ - public function getNotIntitle(): ?array { - return $this->not_intitle; + public function getNotIntitle(bool $plaintext = false): ?array { + return $plaintext ? $this->not_intitle : Minz_Helper::htmlspecialchars_utf8($this->not_intitle, ENT_NOQUOTES); } /** @return list|null */ public function getNotIntitleRegex(): ?array { @@ -512,16 +518,16 @@ class FreshRSS_Search implements \Stringable { } /** @return list|null */ - public function getIntext(): ?array { - return $this->intext; + public function getIntext(bool $plaintext = false): ?array { + return $plaintext ? $this->intext : Minz_Helper::htmlspecialchars_utf8($this->intext, ENT_NOQUOTES); } /** @return list|null */ public function getIntextRegex(): ?array { return $this->intext_regex; } /** @return list|null */ - public function getNotIntext(): ?array { - return $this->not_intext; + public function getNotIntext(bool $plaintext = false): ?array { + return $plaintext ? $this->not_intext : Minz_Helper::htmlspecialchars_utf8($this->not_intext, ENT_NOQUOTES); } /** @return list|null */ public function getNotIntextRegex(): ?array { @@ -580,16 +586,16 @@ class FreshRSS_Search implements \Stringable { } /** @return list|null */ - public function getInurl(): ?array { - return $this->inurl; + public function getInurl(bool $plaintext = false): ?array { + return $plaintext ? $this->inurl : Minz_Helper::htmlspecialchars_utf8($this->inurl, ENT_NOQUOTES); } /** @return list|null */ public function getInurlRegex(): ?array { return $this->inurl_regex; } /** @return list|null */ - public function getNotInurl(): ?array { - return $this->not_inurl; + public function getNotInurl(bool $plaintext = false): ?array { + return $plaintext ? $this->not_inurl : Minz_Helper::htmlspecialchars_utf8($this->not_inurl, ENT_NOQUOTES); } /** @return list|null */ public function getNotInurlRegex(): ?array { @@ -597,16 +603,16 @@ class FreshRSS_Search implements \Stringable { } /** @return list|null */ - public function getAuthor(): ?array { - return $this->author; + public function getAuthor(bool $plaintext = false): ?array { + return $plaintext ? $this->author : Minz_Helper::htmlspecialchars_utf8($this->author, ENT_NOQUOTES); } /** @return list|null */ public function getAuthorRegex(): ?array { return $this->author_regex; } /** @return list|null */ - public function getNotAuthor(): ?array { - return $this->not_author; + public function getNotAuthor(bool $plaintext = false): ?array { + return $plaintext ? $this->not_author : Minz_Helper::htmlspecialchars_utf8($this->not_author, ENT_NOQUOTES); } /** @return list|null */ public function getNotAuthorRegex(): ?array { @@ -614,16 +620,16 @@ class FreshRSS_Search implements \Stringable { } /** @return list|null */ - public function getTags(): ?array { - return $this->tags; + public function getTags(bool $plaintext = false): ?array { + return $plaintext ? $this->tags : Minz_Helper::htmlspecialchars_utf8($this->tags, ENT_NOQUOTES); } /** @return list|null */ public function getTagsRegex(): ?array { return $this->tags_regex; } /** @return list|null */ - public function getNotTags(): ?array { - return $this->not_tags; + public function getNotTags(bool $plaintext = false): ?array { + return $plaintext ? $this->not_tags : Minz_Helper::htmlspecialchars_utf8($this->not_tags, ENT_NOQUOTES); } /** @return list|null */ public function getNotTagsRegex(): ?array { @@ -631,16 +637,16 @@ class FreshRSS_Search implements \Stringable { } /** @return list|null */ - public function getSearch(): ?array { - return $this->search; + public function getSearch(bool $plaintext = false): ?array { + return $plaintext ? $this->search : Minz_Helper::htmlspecialchars_utf8($this->search, ENT_NOQUOTES); } /** @return list|null */ public function getSearchRegex(): ?array { return $this->search_regex; } /** @return list|null */ - public function getNotSearch(): ?array { - return $this->not_search; + public function getNotSearch(bool $plaintext = false): ?array { + return $plaintext ? $this->not_search : Minz_Helper::htmlspecialchars_utf8($this->not_search, ENT_NOQUOTES); } /** @return list|null */ public function getNotSearchRegex(): ?array { @@ -671,14 +677,6 @@ class FreshRSS_Search implements \Stringable { return $value; } - /** - * @param list $strings - * @return list - */ - private static function htmlspecialchars_decodes(array $strings): array { - return array_map(static fn(string $s) => htmlspecialchars_decode($s, ENT_QUOTES), $strings); - } - /** * Parse the search string to find entry (article) IDs. */ @@ -890,7 +888,7 @@ class FreshRSS_Search implements \Stringable { */ private function parseIntitleSearch(string $input): string { if (preg_match_all('#\\bintitle:(?P/.*?(?intitle_regex = self::htmlspecialchars_decodes($matches['search']); + $this->intitle_regex = $matches['search']; $input = str_replace($matches[0], '', $input); } if (preg_match_all('/\\bintitle:(?P[\'"])(?P.*)(?P=delim)/U', $input, $matches)) { @@ -910,7 +908,7 @@ class FreshRSS_Search implements \Stringable { private function parseNotIntitleSearch(string $input): string { if (preg_match_all('#(?<=[\\s(]|^)[!-]intitle:(?P/.*?(?not_intitle_regex = self::htmlspecialchars_decodes($matches['search']); + $this->not_intitle_regex = $matches['search']; $input = str_replace($matches[0], '', $input); } if (preg_match_all('/(?<=[\\s(]|^)[!-]intitle:(?P[\'"])(?P.*)(?P=delim)/U', $input, $matches)) { @@ -933,7 +931,7 @@ class FreshRSS_Search implements \Stringable { */ private function parseIntextSearch(string $input): string { if (preg_match_all('#\\bintext:(?P/.*?(?intext_regex = self::htmlspecialchars_decodes($matches['search']); + $this->intext_regex = $matches['search']; $input = str_replace($matches[0], '', $input); } if (preg_match_all('/\\bintext:(?P[\'"])(?P.*)(?P=delim)/U', $input, $matches)) { @@ -953,7 +951,7 @@ class FreshRSS_Search implements \Stringable { private function parseNotIntextSearch(string $input): string { if (preg_match_all('#(?<=[\\s(]|^)[!-]intext:(?P/.*?(?not_intext_regex = self::htmlspecialchars_decodes($matches['search']); + $this->not_intext_regex = $matches['search']; $input = str_replace($matches[0], '', $input); } if (preg_match_all('/(?<=[\\s(]|^)[!-]intext:(?P[\'"])(?P.*)(?P=delim)/U', $input, $matches)) { @@ -978,7 +976,7 @@ class FreshRSS_Search implements \Stringable { */ private function parseAuthorSearch(string $input): string { if (preg_match_all('#\\bauthor:(?P/.*?(?author_regex = self::htmlspecialchars_decodes($matches['search']); + $this->author_regex = $matches['search']; $input = str_replace($matches[0], '', $input); } if (preg_match_all('/\\bauthor:(?P[\'"])(?P.*)(?P=delim)/U', $input, $matches)) { @@ -998,7 +996,7 @@ class FreshRSS_Search implements \Stringable { private function parseNotAuthorSearch(string $input): string { if (preg_match_all('#(?<=[\\s(]|^)[!-]author:(?P/.*?(?not_author_regex = self::htmlspecialchars_decodes($matches['search']); + $this->not_author_regex = $matches['search']; $input = str_replace($matches[0], '', $input); } if (preg_match_all('/(?<=[\\s(]|^)[!-]author:(?P[\'"])(?P.*)(?P=delim)/U', $input, $matches)) { @@ -1022,7 +1020,7 @@ class FreshRSS_Search implements \Stringable { */ private function parseInurlSearch(string $input): string { if (preg_match_all('#\\binurl:(?P/.*?(?inurl_regex = self::htmlspecialchars_decodes($matches['search']); + $this->inurl_regex = $matches['search']; $input = str_replace($matches[0], '', $input); } if (preg_match_all('/\\binurl:(?P[\'"])(?P.*)(?P=delim)/U', $input, $matches)) { @@ -1042,7 +1040,7 @@ class FreshRSS_Search implements \Stringable { private function parseNotInurlSearch(string $input): string { if (preg_match_all('#(?<=[\\s(]|^)[!-]inurl:(?P/.*?(?not_inurl_regex = self::htmlspecialchars_decodes($matches['search']); + $this->not_inurl_regex = $matches['search']; $input = str_replace($matches[0], '', $input); } if (preg_match_all('/(?<=[\\s(]|^)[!-]inurl:(?P[\'"])(?P.*)(?P=delim)/U', $input, $matches)) { @@ -1146,7 +1144,7 @@ class FreshRSS_Search implements \Stringable { */ private function parseTagsSearch(string $input): string { if (preg_match_all('%#(?P/.*?(?tags_regex = self::htmlspecialchars_decodes($matches['search']); + $this->tags_regex = $matches['search']; $input = str_replace($matches[0], '', $input); } if (preg_match_all('/#(?P[\'"])(?P.*)(?P=delim)/U', $input, $matches)) { @@ -1168,7 +1166,7 @@ class FreshRSS_Search implements \Stringable { private function parseNotTagsSearch(string $input): string { if (preg_match_all('%(?<=[\\s(]|^)[!-]#(?P/.*?(?not_tags_regex = self::htmlspecialchars_decodes($matches['search']); + $this->not_tags_regex = $matches['search']; $input = str_replace($matches[0], '', $input); } if (preg_match_all('/(?<=[\\s(]|^)[!-]#(?P[\'"])(?P.*)(?P=delim)/U', $input, $matches)) { @@ -1199,7 +1197,7 @@ class FreshRSS_Search implements \Stringable { return ''; } if (preg_match_all('#(?<=[\\s(]|^)(?/.*?(?search_regex = self::htmlspecialchars_decodes($matches['search']); + $this->search_regex = $matches['search']; //TODO: Replace all those str_replace with PREG_OFFSET_CAPTURE $input = str_replace($matches[0], '', $input); } @@ -1234,7 +1232,7 @@ class FreshRSS_Search implements \Stringable { return ''; } if (preg_match_all('#(?<=[\\s(]|^)[!-](?P(?not_search_regex = self::htmlspecialchars_decodes($matches['search']); + $this->not_search_regex = $matches['search']; $input = str_replace($matches[0], '', $input); } if (preg_match_all('/(?<=[\\s(]|^)[!-](?P[\'"])(?P.*)(?P=delim)/U', $input, $matches)) { diff --git a/lib/Minz/Helper.php b/lib/Minz/Helper.php index 61641f09f..bc143d0bd 100644 --- a/lib/Minz/Helper.php +++ b/lib/Minz/Helper.php @@ -19,13 +19,13 @@ final class Minz_Helper { * @phpstan-param T $var * @phpstan-return T */ - public static function htmlspecialchars_utf8(mixed $var): mixed { + public static function htmlspecialchars_utf8(mixed $var, int $flags = ENT_COMPAT): mixed { if (is_array($var)) { // @phpstan-ignore return.type - return array_map([self::class, 'htmlspecialchars_utf8'], $var); + return array_map(fn($v) => self::htmlspecialchars_utf8($v, $flags), $var); } elseif (is_string($var)) { // @phpstan-ignore return.type - return htmlspecialchars($var, ENT_COMPAT, 'UTF-8'); + return htmlspecialchars($var, $flags, 'UTF-8'); } else { return $var; } diff --git a/p/api/query.php b/p/api/query.php index 3fb4cadd7..991a1a7bb 100644 --- a/p/api/query.php +++ b/p/api/query.php @@ -8,21 +8,21 @@ require LIB_PATH . '/lib_rss.php'; //Includes class autoloader Minz_Request::init(); -$token = Minz_Request::paramString('t'); +$token = Minz_Request::paramString('t', plaintext: true); if (!ctype_alnum($token)) { header('HTTP/1.1 422 Unprocessable Entity'); header('Content-Type: text/plain; charset=UTF-8'); die('Invalid token `t`!' . $token); } -$format = Minz_Request::paramString('f'); +$format = Minz_Request::paramString('f', plaintext: true); if (!in_array($format, ['atom', 'greader', 'html', 'json', 'opml', 'rss'], true)) { header('HTTP/1.1 422 Unprocessable Entity'); header('Content-Type: text/plain; charset=UTF-8'); die('Invalid format `f`!'); } -$user = Minz_Request::paramString('user'); +$user = Minz_Request::paramString('user', plaintext: true); if (!FreshRSS_user_Controller::checkUsername($user)) { header('HTTP/1.1 422 Unprocessable Entity'); header('Content-Type: text/plain; charset=UTF-8'); @@ -87,19 +87,19 @@ foreach (FreshRSS_Context::userConf()->queries as $raw_query) { } $query = new FreshRSS_UserQuery($raw_query, FreshRSS_Context::categories(), FreshRSS_Context::labels()); Minz_Request::_param('get', $query->getGet()); - if (Minz_Request::paramString('order') === '') { + if (Minz_Request::paramString('order', plaintext: true) === '') { Minz_Request::_param('order', $query->getOrder()); } Minz_Request::_param('state', (string)$query->getState()); - $search = $query->getSearch()->getRawInput(); + $search = $query->getSearch()->__toString(); // Note: we disallow references to user queries in public user search to avoid sniffing internal user queries - $userSearch = new FreshRSS_BooleanSearch(Minz_Request::paramString('search'), 0, 'AND', allowUserQueries: false); - if ($userSearch->getRawInput() !== '') { + $userSearch = new FreshRSS_BooleanSearch(Minz_Request::paramString('search', plaintext: true), 0, 'AND', allowUserQueries: false); + if ($userSearch->__toString() !== '') { if ($search === '') { - $search = $userSearch->getRawInput(); + $search = $userSearch->__toString(); } else { - $search .= ' (' . $userSearch->getRawInput() . ')'; + $search .= ' (' . $userSearch->__toString() . ')'; } } Minz_Request::_param('search', $search); diff --git a/tests/app/Models/SearchTest.php b/tests/app/Models/SearchTest.php index 92024c7b6..a543777ef 100644 --- a/tests/app/Models/SearchTest.php +++ b/tests/app/Models/SearchTest.php @@ -65,6 +65,7 @@ final class SearchTest extends \PHPUnit\Framework\TestCase { ["intitle:'word1 word2' word3'", ['word1 word2'], ["word3'"]], ['intitle:"word1 word2\' word3"', ["word1 word2' word3"], null], ["intitle:'word1 word2\" word3'", ['word1 word2" word3'], null], + ['intitle:"< & >"', ['< & >'], null], ["intitle:word1 'word2 word3' word4", ['word1'], ['word2 word3', 'word4']], ['intitle:word1+word2', ['word1+word2'], null], ]; @@ -561,10 +562,9 @@ final class SearchTest extends \PHPUnit\Framework\TestCase { ['%ab%', '%ab%', '%cd%', '%cd%', '%ef%', '%ef%', '%gh%', '%ij%', '%ij%', '%kl%', '%kl%'] ], [ - '"ab" "cd" ("ef") intitle:"gh" !"ij" -"kl"', - '(((e.title LIKE ? OR e.content LIKE ?) AND (e.title LIKE ? OR e.content LIKE ?) )) AND (((e.title LIKE ? OR e.content LIKE ?) )) ' . - 'AND ((e.title LIKE ? AND e.title NOT LIKE ? AND e.content NOT LIKE ? AND e.title NOT LIKE ? AND e.content NOT LIKE ? ))', - ['%ab%', '%ab%', '%cd%', '%cd%', '%ef%', '%ef%', '%gh%', '%ij%', '%ij%', '%kl%', '%kl%'] + 'intitle:"é & \' è" intext:/<&>/ \'< & " >\'', + '(e.title LIKE ? AND e.content ~ ? AND (e.title LIKE ? OR e.content LIKE ?) )', + ['%é & \' è%', '<&>', '%< & " >%', '%< & " >%'] ], [ '/^(ab|cd) [(] \\) (ef|gh)/', @@ -934,8 +934,8 @@ final class SearchTest extends \PHPUnit\Framework\TestCase { userdate:2025-01-01T00:00:00/2026-01-01T00:00:00 pubdate:2025-02-01T00:00:00/2026-01-01T00:00:00 date:2025-03-01T00:00:00/2026-01-01T00:00:00 - intitle:/Interesting/i intitle:good - intext:/Interesting/i intext:good + intitle://i intitle:"g ' & d" + intext://i intext:g&d author:/Bob/ author:Alice inurl:/https/ inurl:example.net #/tag2/ #tag1 @@ -944,8 +944,8 @@ final class SearchTest extends \PHPUnit\Framework\TestCase { -userdate:2025-06-01T00:00:00/2025-09-01T00:00:00 -pubdate:2025-06-01T00:00:00/2025-09-01T00:00:00 -date:2025-06-01T00:00:00/2025-09-01T00:00:00 - -intitle:/Spam/i -intitle:bad - -intext:/Spam/i -intext:bad + -intitle:/Spam/i -intitle:"'bad" + -intext:/Spam/i -intext:"'bad" -author:/Dave/i -author:Charlie -inurl:/ftp/ -inurl:example.com -#/tag4/ -#tag3