diff options
-rw-r--r-- | CONTRIBUTING.md | 2 | ||||
-rw-r--r-- | README.fr.md | 2 | ||||
-rw-r--r-- | README.md | 4 | ||||
-rw-r--r-- | app/Models/DatabaseDAO.php | 27 | ||||
-rw-r--r-- | app/Models/Entry.php | 66 | ||||
-rw-r--r-- | app/Models/EntryDAO.php | 116 | ||||
-rw-r--r-- | app/Models/EntryDAOPGSQL.php | 26 | ||||
-rw-r--r-- | app/Models/EntryDAOSQLite.php | 21 | ||||
-rw-r--r-- | app/Models/Search.php | 207 | ||||
-rw-r--r-- | docs/en/admins/02_Prerequisites.md | 2 | ||||
-rw-r--r-- | docs/en/admins/DatabaseConfig.md | 2 | ||||
-rw-r--r-- | docs/en/developers/06_Reporting_Bugs.md | 2 | ||||
-rw-r--r-- | docs/en/users/10_filter.md | 29 | ||||
-rw-r--r-- | docs/fr/contributing.md | 2 | ||||
-rw-r--r-- | docs/fr/developers/02_Github.md | 2 | ||||
-rw-r--r-- | docs/fr/users/01_Installation.md | 2 | ||||
-rw-r--r-- | docs/fr/users/03_Main_view.md | 29 | ||||
-rw-r--r-- | lib/Minz/ModelPdo.php | 8 | ||||
-rw-r--r-- | tests/app/Models/SearchTest.php | 174 |
19 files changed, 670 insertions, 53 deletions
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 66b28bb7e..724ae35f7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -21,7 +21,7 @@ If you have to create a new ticket, try to apply the following advice: - We also need some information: - Your FreshRSS version (on about page or `constants.php` file) - Your server configuration: type of hosting, PHP version - - Your storage system (SQLite, MySQL, MariaDB, PostgreSQL) + - Your storage system (SQLite, PostgreSQL, MariaDB, MySQL) - If possible, the related logs (PHP logs and FreshRSS logs under `data/users/your_user/log.txt`) ## Fix a bug diff --git a/README.fr.md b/README.fr.md index ad592227e..89a7f8714 100644 --- a/README.fr.md +++ b/README.fr.md @@ -66,7 +66,7 @@ FreshRSS n’est fourni avec aucune garantie. * Extensions requises : [cURL](https://www.php.net/curl), [DOM](https://www.php.net/dom), [JSON](https://www.php.net/json), [XML](https://www.php.net/xml), [session](https://www.php.net/session), [ctype](https://www.php.net/ctype) * Extensions recommandées : [PDO_SQLite](https://www.php.net/pdo-sqlite) (pour l’export/import), [GMP](https://www.php.net/gmp) (pour accès API sur plateformes < 64 bits), [IDN](https://www.php.net/intl.idn) (pour les noms de domaines internationalisés), [mbstring](https://www.php.net/mbstring) (pour le texte Unicode), [iconv](https://www.php.net/iconv) (pour conversion d’encodages), [ZIP](https://www.php.net/zip) (pour import/export), [zlib](https://www.php.net/zlib) (pour les flux compressés) * Extension pour base de données : [PDO_PGSQL](https://www.php.net/pdo-pgsql) ou [PDO_SQLite](https://www.php.net/pdo-sqlite) ou [PDO_MySQL](https://www.php.net/pdo-mysql) -* PostgreSQL 10+ ou SQLite ou MySQL 5.5.3+ ou MariaDB 5.5+ +* PostgreSQL 10+ ou SQLite ou MariaDB 10.0.5+ ou MySQL 8.0+ # [Installation](https://freshrss.github.io/FreshRSS/fr/users/01_Installation.html) @@ -61,12 +61,12 @@ FreshRSS comes with absolutely no warranty. * Works on mobile (except a few features) * Light server running Linux or Windows * It even works on Raspberry Pi 1 with response time under a second (tested with 150 feeds, 22k articles) -* A web server: Apache2.4+ (recommended), nginx, lighttpd (not tested on others) +* A Web server: Apache2.4+ (recommended), nginx, lighttpd (not tested on others) * PHP 8.1+ * Required extensions: [cURL](https://www.php.net/curl), [DOM](https://www.php.net/dom), [JSON](https://www.php.net/json), [XML](https://www.php.net/xml), [session](https://www.php.net/session), [ctype](https://www.php.net/ctype) * Recommended extensions: [PDO_SQLite](https://www.php.net/pdo-sqlite) (for export/import), [GMP](https://www.php.net/gmp) (for API access on 32-bit platforms), [IDN](https://www.php.net/intl.idn) (for Internationalized Domain Names), [mbstring](https://www.php.net/mbstring) (for Unicode strings), [iconv](https://www.php.net/iconv) (for charset conversion), [ZIP](https://www.php.net/zip) (for import/export), [zlib](https://www.php.net/zlib) (for compressed feeds) * Extension for database: [PDO_PGSQL](https://www.php.net/pdo-pgsql) or [PDO_SQLite](https://www.php.net/pdo-sqlite) or [PDO_MySQL](https://www.php.net/pdo-mysql) -* PostgreSQL 10+ or SQLite or MySQL 5.5.3+ or MariaDB 5.5+ +* PostgreSQL 10+ or SQLite or MariaDB 10.0.5+ or MySQL 8.0+ # [Installation](https://freshrss.github.io/FreshRSS/en/admins/03_Installation.html) diff --git a/app/Models/DatabaseDAO.php b/app/Models/DatabaseDAO.php index 363225cdb..ba0ee3e79 100644 --- a/app/Models/DatabaseDAO.php +++ b/app/Models/DatabaseDAO.php @@ -185,6 +185,30 @@ class FreshRSS_DatabaseDAO extends Minz_ModelPdo { return $list; } + private static ?string $staticVersion = null; + /** + * To override the database version. Useful for testing. + */ + public static function setStaticVersion(?string $version): void { + self::$staticVersion = $version; + } + + public function version(): string { + if (self::$staticVersion !== null) { + return self::$staticVersion; + } + static $version = null; + if ($version === null) { + $version = $this->fetchValue('SELECT version()') ?? ''; + } + return $version; + } + + final public function isMariaDB(): bool { + // MariaDB includes its name in version, but not MySQL + return str_contains($this->version(), 'MariaDB'); + } + public function size(bool $all = false): int { $db = FreshRSS_Context::systemConf()->db; @@ -237,8 +261,7 @@ SQL; $isMariaDB = false; if ($this->pdo->dbType() === 'mysql') { - $dbVersion = $this->fetchValue('SELECT version()') ?? ''; - $isMariaDB = stripos($dbVersion, 'MariaDB') !== false; // MariaDB includes its name in version, but not MySQL + $isMariaDB = $this->isMariaDB(); if (!$isMariaDB) { // MySQL does not support `DROP INDEX IF EXISTS` yet https://dev.mysql.com/doc/refman/8.3/en/drop-index.html // but MariaDB does https://mariadb.com/kb/en/drop-index/ diff --git a/app/Models/Entry.php b/app/Models/Entry.php index 4b331419b..415bc0235 100644 --- a/app/Models/Entry.php +++ b/app/Models/Entry.php @@ -631,27 +631,60 @@ HTML; $ok &= stripos(implode(';', $this->authors), $author) !== false; } } + if ($ok && $filter->getAuthorRegex()) { + foreach ($filter->getAuthorRegex() as $author) { + $ok &= preg_match($author, implode("\n", $this->authors)) === 1; + } + } if ($ok && $filter->getNotAuthor()) { foreach ($filter->getNotAuthor() as $author) { $ok &= stripos(implode(';', $this->authors), $author) === false; } } + if ($ok && $filter->getNotAuthorRegex()) { + foreach ($filter->getNotAuthorRegex() as $author) { + $ok &= preg_match($author, implode("\n", $this->authors)) === 0; + } + } if ($ok && $filter->getIntitle()) { foreach ($filter->getIntitle() as $title) { $ok &= stripos($this->title, $title) !== false; } } + if ($ok && $filter->getIntitleRegex()) { + foreach ($filter->getIntitleRegex() as $title) { + $ok &= preg_match($title, $this->title) === 1; + } + } if ($ok && $filter->getNotIntitle()) { foreach ($filter->getNotIntitle() as $title) { $ok &= stripos($this->title, $title) === false; } } + if ($ok && $filter->getNotIntitleRegex()) { + foreach ($filter->getNotIntitleRegex() as $title) { + $ok &= preg_match($title, $this->title) === 0; + } + } if ($ok && $filter->getTags()) { foreach ($filter->getTags() as $tag2) { $found = false; foreach ($this->tags as $tag1) { if (strcasecmp($tag1, $tag2) === 0) { $found = true; + break; + } + } + $ok &= $found; + } + } + if ($ok && $filter->getTagsRegex()) { + foreach ($filter->getTagsRegex() as $tag2) { + $found = false; + foreach ($this->tags as $tag1) { + if (preg_match($tag2, $tag1) === 1) { + $found = true; + break; } } $ok &= $found; @@ -663,6 +696,19 @@ HTML; foreach ($this->tags as $tag1) { if (strcasecmp($tag1, $tag2) === 0) { $found = true; + break; + } + } + $ok &= !$found; + } + } + if ($ok && $filter->getNotTagsRegex()) { + foreach ($filter->getNotTagsRegex() as $tag2) { + $found = false; + foreach ($this->tags as $tag1) { + if (preg_match($tag2, $tag1) === 1) { + $found = true; + break; } } $ok &= !$found; @@ -673,11 +719,21 @@ HTML; $ok &= stripos($this->link, $url) !== false; } } + if ($ok && $filter->getInurlRegex()) { + foreach ($filter->getInurlRegex() as $url) { + $ok &= preg_match($url, $this->link) === 1; + } + } if ($ok && $filter->getNotInurl()) { foreach ($filter->getNotInurl() as $url) { $ok &= stripos($this->link, $url) === false; } } + if ($ok && $filter->getNotInurlRegex()) { + foreach ($filter->getNotInurlRegex() as $url) { + $ok &= preg_match($url, $this->link) === 0; + } + } if ($ok && $filter->getSearch()) { foreach ($filter->getSearch() as $needle) { $ok &= (stripos($this->title, $needle) !== false || stripos($this->content, $needle) !== false); @@ -688,6 +744,16 @@ HTML; $ok &= (stripos($this->title, $needle) === false && stripos($this->content, $needle) === false); } } + if ($ok && $filter->getSearchRegex()) { + foreach ($filter->getSearchRegex() as $needle) { + $ok &= (preg_match($needle, $this->title) === 1 || preg_match($needle, $this->content) === 1); + } + } + if ($ok && $filter->getNotSearchRegex()) { + foreach ($filter->getNotSearchRegex() as $needle) { + $ok &= (preg_match($needle, $this->title) === 0 && preg_match($needle, $this->content) === 0); + } + } if ($ok) { return true; } diff --git a/app/Models/EntryDAO.php b/app/Models/EntryDAO.php index 175df15c3..6a00e2108 100644 --- a/app/Models/EntryDAO.php +++ b/app/Models/EntryDAO.php @@ -27,6 +27,55 @@ class FreshRSS_EntryDAO extends Minz_ModelPdo { return str_replace('INSERT INTO ', 'INSERT IGNORE INTO ', $sql); } + /** @return array{pattern?:string,matchType?:string} */ + protected static function regexToSql(string $regex): array { + if (preg_match('#^/(?P<pattern>.*)/(?P<matchType>[im]*)$#', $regex, $matches)) { + return $matches; + } + return []; + } + + /** @param array<int|string> $values */ + protected static function sqlRegex(string $expression, string $regex, array &$values): string { + // The implementation of this function is solely for MySQL and MariaDB + static $databaseDAOMySQL = null; + if ($databaseDAOMySQL === null) { + $databaseDAOMySQL = new FreshRSS_DatabaseDAO(); + } + + $matches = static::regexToSql($regex); + if (isset($matches['pattern'])) { + $matchType = $matches['matchType'] ?? ''; + if ($databaseDAOMySQL->isMariaDB()) { + if (str_contains($matchType, 'm')) { + // multiline mode + $matches['pattern'] = '(?m)' . $matches['pattern']; + } + if (str_contains($matchType, 'i')) { + // case-insensitive match + $matches['pattern'] = '(?i)' . $matches['pattern']; + } else { + $matches['pattern'] = '(?-i)' . $matches['pattern']; + } + $values[] = $matches['pattern']; + return "{$expression} REGEXP ?"; + } else { // MySQL + if (!str_contains($matchType, 'i')) { + // Case-sensitive matching + $matchType .= 'c'; + } + $values[] = $matches['pattern']; + return "REGEXP_LIKE({$expression},?,'{$matchType}')"; + } + } + return ''; + } + + /** Register any needed SQL function for the query, e.g. application-defined functions for SQLite */ + protected function registerSqlFunctions(string $sql): void { + // Nothing to do for MySQL + } + private function updateToMediumBlob(): bool { if ($this->pdo->dbType() !== 'mysql') { return false; @@ -910,24 +959,44 @@ SQL; $values[] = "%{$author}%"; } } + if ($filter->getAuthorRegex() !== null) { + foreach ($filter->getAuthorRegex() as $author) { + $sub_search .= 'AND ' . static::sqlRegex("REPLACE({$alias}author, ';', '\n')", $author, $values) . ' '; + } + } if ($filter->getIntitle() !== null) { foreach ($filter->getIntitle() as $title) { $sub_search .= 'AND ' . $alias . 'title LIKE ? '; $values[] = "%{$title}%"; } } + if ($filter->getIntitleRegex() !== null) { + foreach ($filter->getIntitleRegex() as $title) { + $sub_search .= 'AND ' . static::sqlRegex($alias . 'title', $title, $values) . ' '; + } + } if ($filter->getTags() !== null) { foreach ($filter->getTags() as $tag) { $sub_search .= 'AND ' . static::sqlConcat('TRIM(' . $alias . 'tags) ', " ' #'") . ' LIKE ? '; $values[] = "%{$tag} #%"; } } + if ($filter->getTagsRegex() !== null) { + foreach ($filter->getTagsRegex() as $tag) { + $sub_search .= 'AND ' . static::sqlRegex("REPLACE(REPLACE({$alias}tags, ' #', '#'), '#', '\n')", $tag, $values) . ' '; + } + } if ($filter->getInurl() !== null) { foreach ($filter->getInurl() as $url) { $sub_search .= 'AND ' . $alias . 'link LIKE ? '; $values[] = "%{$url}%"; } } + if ($filter->getInurlRegex() !== null) { + foreach ($filter->getInurlRegex() as $url) { + $sub_search .= 'AND ' . static::sqlRegex($alias . 'link', $url, $values) . ' '; + } + } if ($filter->getNotAuthor() !== null) { foreach ($filter->getNotAuthor() as $author) { @@ -935,29 +1004,49 @@ SQL; $values[] = "%{$author}%"; } } + if ($filter->getNotAuthorRegex() !== null) { + foreach ($filter->getNotAuthorRegex() as $author) { + $sub_search .= 'AND NOT ' . static::sqlRegex("REPLACE({$alias}author, ';', '\n')", $author, $values) . ' '; + } + } if ($filter->getNotIntitle() !== null) { foreach ($filter->getNotIntitle() as $title) { $sub_search .= 'AND ' . $alias . 'title NOT LIKE ? '; $values[] = "%{$title}%"; } } + if ($filter->getNotIntitleRegex() !== null) { + foreach ($filter->getNotIntitleRegex() as $title) { + $sub_search .= 'AND NOT ' . static::sqlRegex($alias . 'title', $title, $values) . ' '; + } + } if ($filter->getNotTags() !== null) { foreach ($filter->getNotTags() as $tag) { $sub_search .= 'AND ' . static::sqlConcat('TRIM(' . $alias . 'tags) ', " ' #'") . ' NOT LIKE ? '; $values[] = "%{$tag} #%"; } } + if ($filter->getNotTagsRegex() !== null) { + foreach ($filter->getNotTagsRegex() as $tag) { + $sub_search .= 'AND NOT ' . static::sqlRegex("REPLACE(REPLACE({$alias}tags, ' #', '#'), '#', '\n')", $tag, $values) . ' '; + } + } if ($filter->getNotInurl() !== null) { foreach ($filter->getNotInurl() as $url) { $sub_search .= 'AND ' . $alias . 'link NOT LIKE ? '; $values[] = "%{$url}%"; } } + if ($filter->getNotInurlRegex() !== null) { + foreach ($filter->getNotInurlRegex() as $url) { + $sub_search .= 'AND NOT ' . static::sqlRegex($alias . 'link', $url, $values) . ' '; + } + } if ($filter->getSearch() !== null) { foreach ($filter->getSearch() as $search_value) { if (static::isCompressed()) { // MySQL-only - $sub_search .= 'AND CONCAT(' . $alias . 'title, UNCOMPRESS(' . $alias . 'content_bin)) LIKE ? '; + $sub_search .= "AND CONCAT({$alias}title, '\\n', UNCOMPRESS({$alias}content_bin)) LIKE ? "; $values[] = "%{$search_value}%"; } else { $sub_search .= 'AND (' . $alias . 'title LIKE ? OR ' . $alias . 'content LIKE ?) '; @@ -966,10 +1055,21 @@ SQL; } } } + if ($filter->getSearchRegex() !== null) { + foreach ($filter->getSearchRegex() as $search_value) { + if (static::isCompressed()) { // MySQL-only + $sub_search .= 'AND (' . static::sqlRegex($alias . 'title', $search_value, $values) . + ' OR ' . static::sqlRegex("UNCOMPRESS({$alias}content_bin)", $search_value, $values) . ') '; + } else { + $sub_search .= 'AND (' . static::sqlRegex($alias . 'title', $search_value, $values) . + ' OR ' . static::sqlRegex($alias . 'content', $search_value, $values) . ') '; + } + } + } if ($filter->getNotSearch() !== null) { foreach ($filter->getNotSearch() as $search_value) { if (static::isCompressed()) { // MySQL-only - $sub_search .= 'AND CONCAT(' . $alias . 'title, UNCOMPRESS(' . $alias . 'content_bin)) NOT LIKE ? '; + $sub_search .= "AND CONCAT({$alias}title, '\\n', UNCOMPRESS({$alias}content_bin)) NOT LIKE ? "; $values[] = "%{$search_value}%"; } else { $sub_search .= 'AND ' . $alias . 'title NOT LIKE ? AND ' . $alias . 'content NOT LIKE ? '; @@ -978,6 +1078,17 @@ SQL; } } } + if ($filter->getNotSearchRegex() !== null) { + foreach ($filter->getNotSearchRegex() as $search_value) { + if (static::isCompressed()) { // MySQL-only + $sub_search .= 'AND NOT ' . static::sqlRegex($alias . 'title', $search_value, $values) . + ' ANT NOT ' . static::sqlRegex("UNCOMPRESS({$alias}content_bin)", $search_value, $values) . ' '; + } else { + $sub_search .= 'AND NOT ' . static::sqlRegex($alias . 'title', $search_value, $values) . + ' AND NOT ' . static::sqlRegex($alias . 'content', $search_value, $values) . ' '; + } + } + } if ($sub_search != '') { if ($isOpen) { @@ -1039,6 +1150,7 @@ SQL; if ($filterSearch !== '') { $search .= 'AND (' . $filterSearch . ') '; $values = array_merge($values, $filterValues); + $this->registerSqlFunctions($search); } } return [$values, $search]; diff --git a/app/Models/EntryDAOPGSQL.php b/app/Models/EntryDAOPGSQL.php index 8adeffe9e..fe157308c 100644 --- a/app/Models/EntryDAOPGSQL.php +++ b/app/Models/EntryDAOPGSQL.php @@ -23,6 +23,32 @@ class FreshRSS_EntryDAOPGSQL extends FreshRSS_EntryDAOSQLite { return rtrim($sql, ' ;') . ' ON CONFLICT DO NOTHING'; } + #[\Override] + protected static function sqlRegex(string $expression, string $regex, array &$values): string { + $matches = static::regexToSql($regex); + if (isset($matches['pattern'])) { + $matchType = $matches['matchType'] ?? ''; + if (str_contains($matchType, 'm')) { + // newline-sensitive matching + $matches['pattern'] = '(?m)' . $matches['pattern']; + } + $values[] = $matches['pattern']; + if (str_contains($matchType, 'i')) { + // case-insensitive matching + return "{$expression} ~* ?"; + } else { + // case-sensitive matching + return "{$expression} ~ ?"; + } + } + return ''; + } + + #[\Override] + protected function registerSqlFunctions(string $sql): void { + // Nothing to do for PostgreSQL + } + /** @param array<string|int> $errorInfo */ #[\Override] protected function autoUpdateDb(array $errorInfo): bool { diff --git a/app/Models/EntryDAOSQLite.php b/app/Models/EntryDAOSQLite.php index 9c2b37623..6d604f25a 100644 --- a/app/Models/EntryDAOSQLite.php +++ b/app/Models/EntryDAOSQLite.php @@ -28,6 +28,27 @@ class FreshRSS_EntryDAOSQLite extends FreshRSS_EntryDAO { return str_replace('INSERT INTO ', 'INSERT OR IGNORE INTO ', $sql); } + #[\Override] + protected static function sqlRegex(string $expression, string $regex, array &$values): string { + $values[] = $regex; + return "{$expression} REGEXP ?"; + } + + #[\Override] + protected function registerSqlFunctions(string $sql): void { + if (!str_contains($sql, ' REGEXP ')) { + return; + } + // https://php.net/pdo.sqlitecreatefunction + // https://www.sqlite.org/lang_expr.html#the_like_glob_regexp_match_and_extract_operators + $this->pdo->sqliteCreateFunction('regexp', + function (string $pattern, string $text): bool { + return preg_match($pattern, $text) === 1; + }, + 2 + ); + } + /** @param array<string|int> $errorInfo */ #[\Override] protected function autoUpdateDb(array $errorInfo): bool { diff --git a/app/Models/Search.php b/app/Models/Search.php index 7eaf741c3..755cf6b59 100644 --- a/app/Models/Search.php +++ b/app/Models/Search.php @@ -27,6 +27,8 @@ class FreshRSS_Search { private ?array $label_names = null; /** @var array<string>|null */ private ?array $intitle = null; + /** @var array<string>|null */ + private ?array $intitle_regex = null; /** @var int|false|null */ private $min_date = null; /** @var int|false|null */ @@ -38,11 +40,19 @@ class FreshRSS_Search { /** @var array<string>|null */ private ?array $inurl = null; /** @var array<string>|null */ + private ?array $inurl_regex = null; + /** @var array<string>|null */ private ?array $author = null; /** @var array<string>|null */ + private ?array $author_regex = null; + /** @var array<string>|null */ private ?array $tags = null; /** @var array<string>|null */ + private ?array $tags_regex = null; + /** @var array<string>|null */ private ?array $search = null; + /** @var array<string>|null */ + private ?array $search_regex = null; /** @var array<string>|null */ private ?array $not_entry_ids = null; @@ -54,6 +64,8 @@ class FreshRSS_Search { private ?array $not_label_names = null; /** @var array<string>|null */ private ?array $not_intitle = null; + /** @var array<string>|null */ + private ?array $not_intitle_regex = null; /** @var int|false|null */ private $not_min_date = null; /** @var int|false|null */ @@ -65,11 +77,19 @@ class FreshRSS_Search { /** @var array<string>|null */ private ?array $not_inurl = null; /** @var array<string>|null */ + private ?array $not_inurl_regex = null; + /** @var array<string>|null */ private ?array $not_author = null; /** @var array<string>|null */ + private ?array $not_author_regex = null; + /** @var array<string>|null */ private ?array $not_tags = null; /** @var array<string>|null */ + private ?array $not_tags_regex = null; + /** @var array<string>|null */ private ?array $not_search = null; + /** @var array<string>|null */ + private ?array $not_search_regex = null; public function __construct(string $input) { $input = self::cleanSearch($input); @@ -156,9 +176,17 @@ class FreshRSS_Search { return $this->intitle; } /** @return array<string>|null */ + public function getIntitleRegex(): ?array { + return $this->intitle_regex; + } + /** @return array<string>|null */ public function getNotIntitle(): ?array { return $this->not_intitle; } + /** @return array<string>|null */ + public function getNotIntitleRegex(): ?array { + return $this->not_intitle_regex; + } public function getMinDate(): ?int { return $this->min_date ?: null; @@ -199,36 +227,68 @@ class FreshRSS_Search { return $this->inurl; } /** @return array<string>|null */ + public function getInurlRegex(): ?array { + return $this->inurl_regex; + } + /** @return array<string>|null */ public function getNotInurl(): ?array { return $this->not_inurl; } + /** @return array<string>|null */ + public function getNotInurlRegex(): ?array { + return $this->not_inurl_regex; + } /** @return array<string>|null */ public function getAuthor(): ?array { return $this->author; } /** @return array<string>|null */ + public function getAuthorRegex(): ?array { + return $this->author_regex; + } + /** @return array<string>|null */ public function getNotAuthor(): ?array { return $this->not_author; } + /** @return array<string>|null */ + public function getNotAuthorRegex(): ?array { + return $this->not_author_regex; + } /** @return array<string>|null */ public function getTags(): ?array { return $this->tags; } /** @return array<string>|null */ + public function getTagsRegex(): ?array { + return $this->tags_regex; + } + /** @return array<string>|null */ public function getNotTags(): ?array { return $this->not_tags; } + /** @return array<string>|null */ + public function getNotTagsRegex(): ?array { + return $this->not_tags_regex; + } /** @return array<string>|null */ public function getSearch(): ?array { return $this->search; } /** @return array<string>|null */ + public function getSearchRegex(): ?array { + return $this->search_regex; + } + /** @return array<string>|null */ public function getNotSearch(): ?array { return $this->not_search; } + /** @return array<string>|null */ + public function getNotSearchRegex(): ?array { + return $this->not_search_regex; + } /** * @param array<string>|null $anArray @@ -254,10 +314,18 @@ class FreshRSS_Search { } /** + * @param array<string> $strings + * @return array<string> + */ + 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. */ private function parseEntryIds(string $input): string { - if (preg_match_all('/\be:(?P<search>[0-9,]*)/', $input, $matches)) { + if (preg_match_all('/\\be:(?P<search>[0-9,]*)/', $input, $matches)) { $input = str_replace($matches[0], '', $input); $ids_lists = $matches['search']; $this->entry_ids = []; @@ -273,7 +341,7 @@ class FreshRSS_Search { } private function parseNotEntryIds(string $input): string { - if (preg_match_all('/(?<=\s|^)[!-]e:(?P<search>[0-9,]*)/', $input, $matches)) { + if (preg_match_all('/(?<=\\s|^)[!-]e:(?P<search>[0-9,]*)/', $input, $matches)) { $input = str_replace($matches[0], '', $input); $ids_lists = $matches['search']; $this->not_entry_ids = []; @@ -289,7 +357,7 @@ class FreshRSS_Search { } private function parseFeedIds(string $input): string { - if (preg_match_all('/\bf:(?P<search>[0-9,]*)/', $input, $matches)) { + if (preg_match_all('/\\bf:(?P<search>[0-9,]*)/', $input, $matches)) { $input = str_replace($matches[0], '', $input); $ids_lists = $matches['search']; $this->feed_ids = []; @@ -307,7 +375,7 @@ class FreshRSS_Search { } private function parseNotFeedIds(string $input): string { - if (preg_match_all('/(?<=\s|^)[!-]f:(?P<search>[0-9,]*)/', $input, $matches)) { + if (preg_match_all('/(?<=\\s|^)[!-]f:(?P<search>[0-9,]*)/', $input, $matches)) { $input = str_replace($matches[0], '', $input); $ids_lists = $matches['search']; $this->not_feed_ids = []; @@ -328,7 +396,7 @@ class FreshRSS_Search { * Parse the search string to find tags (labels) IDs. */ private function parseLabelIds(string $input): string { - if (preg_match_all('/\b[lL]:(?P<search>[0-9,]+|[*])/', $input, $matches)) { + if (preg_match_all('/\\b[lL]:(?P<search>[0-9,]+|[*])/', $input, $matches)) { $input = str_replace($matches[0], '', $input); $ids_lists = $matches['search']; $this->label_ids = []; @@ -350,7 +418,7 @@ class FreshRSS_Search { } private function parseNotLabelIds(string $input): string { - if (preg_match_all('/(?<=\s|^)[!-][lL]:(?P<search>[0-9,]+|[*])/', $input, $matches)) { + if (preg_match_all('/(?<=\\s|^)[!-][lL]:(?P<search>[0-9,]+|[*])/', $input, $matches)) { $input = str_replace($matches[0], '', $input); $ids_lists = $matches['search']; $this->not_label_ids = []; @@ -376,11 +444,11 @@ class FreshRSS_Search { */ private function parseLabelNames(string $input): string { $names_lists = []; - if (preg_match_all('/\blabels?:(?P<delim>[\'"])(?P<search>.*)(?P=delim)/U', $input, $matches)) { + if (preg_match_all('/\\blabels?:(?P<delim>[\'"])(?P<search>.*)(?P=delim)/U', $input, $matches)) { $names_lists = $matches['search']; $input = str_replace($matches[0], '', $input); } - if (preg_match_all('/\blabels?:(?P<search>[^\s"]*)/', $input, $matches)) { + if (preg_match_all('/\\blabels?:(?P<search>[^\s"]*)/', $input, $matches)) { $names_lists = array_merge($names_lists, $matches['search']); $input = str_replace($matches[0], '', $input); } @@ -402,11 +470,11 @@ class FreshRSS_Search { */ private function parseNotLabelNames(string $input): string { $names_lists = []; - if (preg_match_all('/(?<=\s|^)[!-]labels?:(?P<delim>[\'"])(?P<search>.*)(?P=delim)/U', $input, $matches)) { + if (preg_match_all('/(?<=\\s|^)[!-]labels?:(?P<delim>[\'"])(?P<search>.*)(?P=delim)/U', $input, $matches)) { $names_lists = $matches['search']; $input = str_replace($matches[0], '', $input); } - if (preg_match_all('/(?<=\s|^)[!-]labels?:(?P<search>[^\s"]*)/', $input, $matches)) { + if (preg_match_all('/(?<=\\s|^)[!-]labels?:(?P<search>[^\\s"]*)/', $input, $matches)) { $names_lists = array_merge($names_lists, $matches['search']); $input = str_replace($matches[0], '', $input); } @@ -428,11 +496,15 @@ class FreshRSS_Search { * The search is the first word following the keyword. */ private function parseIntitleSearch(string $input): string { - if (preg_match_all('/\bintitle:(?P<delim>[\'"])(?P<search>.*)(?P=delim)/U', $input, $matches)) { + if (preg_match_all('#\\bintitle:(?P<search>/.*?(?<!\\\\)/[im]*)#', $input, $matches)) { + $this->intitle_regex = self::htmlspecialchars_decodes($matches['search']); + $input = str_replace($matches[0], '', $input); + } + if (preg_match_all('/\\bintitle:(?P<delim>[\'"])(?P<search>.*)(?P=delim)/U', $input, $matches)) { $this->intitle = $matches['search']; $input = str_replace($matches[0], '', $input); } - if (preg_match_all('/\bintitle:(?P<search>[^\s"]*)/', $input, $matches)) { + if (preg_match_all('/\\bintitle:(?P<search>[^\s"]*)/', $input, $matches)) { $this->intitle = array_merge($this->intitle ?: [], $matches['search']); $input = str_replace($matches[0], '', $input); } @@ -444,11 +516,15 @@ class FreshRSS_Search { } private function parseNotIntitleSearch(string $input): string { - if (preg_match_all('/(?<=\s|^)[!-]intitle:(?P<delim>[\'"])(?P<search>.*)(?P=delim)/U', $input, $matches)) { + if (preg_match_all('#(?<=\\s|^)[!-]intitle:(?P<search>/.*?(?<!\\\\)/[im]*)#', $input, $matches)) { + $this->not_intitle_regex = self::htmlspecialchars_decodes($matches['search']); + $input = str_replace($matches[0], '', $input); + } + if (preg_match_all('/(?<=\\s|^)[!-]intitle:(?P<delim>[\'"])(?P<search>.*)(?P=delim)/U', $input, $matches)) { $this->not_intitle = $matches['search']; $input = str_replace($matches[0], '', $input); } - if (preg_match_all('/(?<=\s|^)[!-]intitle:(?P<search>[^\s"]*)/', $input, $matches)) { + if (preg_match_all('/(?<=\\s|^)[!-]intitle:(?P<search>[^\s"]*)/', $input, $matches)) { $this->not_intitle = array_merge($this->not_intitle ?: [], $matches['search']); $input = str_replace($matches[0], '', $input); } @@ -465,11 +541,15 @@ class FreshRSS_Search { * a delimiter. Supported delimiters are single quote (') and double quotes ("). */ private function parseAuthorSearch(string $input): string { - if (preg_match_all('/\bauthor:(?P<delim>[\'"])(?P<search>.*)(?P=delim)/U', $input, $matches)) { + if (preg_match_all('#\\bauthor:(?P<search>/.*?(?<!\\\\)/[im]*)#', $input, $matches)) { + $this->author_regex = self::htmlspecialchars_decodes($matches['search']); + $input = str_replace($matches[0], '', $input); + } + if (preg_match_all('/\\bauthor:(?P<delim>[\'"])(?P<search>.*)(?P=delim)/U', $input, $matches)) { $this->author = $matches['search']; $input = str_replace($matches[0], '', $input); } - if (preg_match_all('/\bauthor:(?P<search>[^\s"]*)/', $input, $matches)) { + if (preg_match_all('/\\bauthor:(?P<search>[^\s"]*)/', $input, $matches)) { $this->author = array_merge($this->author ?: [], $matches['search']); $input = str_replace($matches[0], '', $input); } @@ -481,11 +561,15 @@ class FreshRSS_Search { } private function parseNotAuthorSearch(string $input): string { - if (preg_match_all('/(?<=\s|^)[!-]author:(?P<delim>[\'"])(?P<search>.*)(?P=delim)/U', $input, $matches)) { + if (preg_match_all('#(?<=\\s|^)[!-]author:(?P<search>/.*?(?<!\\\\)/[im]*)#', $input, $matches)) { + $this->not_author_regex = self::htmlspecialchars_decodes($matches['search']); + $input = str_replace($matches[0], '', $input); + } + if (preg_match_all('/(?<=\\s|^)[!-]author:(?P<delim>[\'"])(?P<search>.*)(?P=delim)/U', $input, $matches)) { $this->not_author = $matches['search']; $input = str_replace($matches[0], '', $input); } - if (preg_match_all('/(?<=\s|^)[!-]author:(?P<search>[^\s"]*)/', $input, $matches)) { + if (preg_match_all('/(?<=\\s|^)[!-]author:(?P<search>[^\s"]*)/', $input, $matches)) { $this->not_author = array_merge($this->not_author ?: [], $matches['search']); $input = str_replace($matches[0], '', $input); } @@ -501,19 +585,41 @@ class FreshRSS_Search { * The search is the first word following the keyword. */ private function parseInurlSearch(string $input): string { - if (preg_match_all('/\binurl:(?P<search>[^\s]*)/', $input, $matches)) { + if (preg_match_all('#\\binurl:(?P<search>/.*?(?<!\\\\)/[im]*)#', $input, $matches)) { + $this->inurl_regex = self::htmlspecialchars_decodes($matches['search']); + $input = str_replace($matches[0], '', $input); + } + if (preg_match_all('/\\binurl:(?P<delim>[\'"])(?P<search>.*)(?P=delim)/U', $input, $matches)) { $this->inurl = $matches['search']; $input = str_replace($matches[0], '', $input); - $this->inurl = self::removeEmptyValues($this->inurl); + } + if (preg_match_all('/\\binurl:(?P<search>[^\\s]*)/', $input, $matches)) { + $this->inurl = $matches['search']; + $input = str_replace($matches[0], '', $input); + } + $this->inurl = self::removeEmptyValues($this->inurl); + if (empty($this->inurl)) { + $this->inurl = null; } return $input; } private function parseNotInurlSearch(string $input): string { - if (preg_match_all('/(?<=\s|^)[!-]inurl:(?P<search>[^\s]*)/', $input, $matches)) { + if (preg_match_all('#(?<=\\s|^)[!-]inurl:(?P<search>/.*?(?<!\\\\)/[im]*)#', $input, $matches)) { + $this->not_inurl_regex = self::htmlspecialchars_decodes($matches['search']); + $input = str_replace($matches[0], '', $input); + } + if (preg_match_all('/(?<=\\s|^)[!-]inurl:(?P<delim>[\'"])(?P<search>.*)(?P=delim)/U', $input, $matches)) { $this->not_inurl = $matches['search']; $input = str_replace($matches[0], '', $input); - $this->not_inurl = self::removeEmptyValues($this->not_inurl); + } + if (preg_match_all('/(?<=\\s|^)[!-]inurl:(?P<search>[^\\s]*)/', $input, $matches)) { + $this->not_inurl = $matches['search']; + $input = str_replace($matches[0], '', $input); + } + $this->not_inurl = self::removeEmptyValues($this->not_inurl); + if (empty($this->not_inurl)) { + $this->not_inurl = null; } return $input; } @@ -523,7 +629,7 @@ class FreshRSS_Search { * The search is the first word following the keyword. */ private function parseDateSearch(string $input): string { - if (preg_match_all('/\bdate:(?P<search>[^\s]*)/', $input, $matches)) { + if (preg_match_all('/\\bdate:(?P<search>[^\\s]*)/', $input, $matches)) { $input = str_replace($matches[0], '', $input); $dates = self::removeEmptyValues($matches['search']); if (!empty($dates[0])) { @@ -534,7 +640,7 @@ class FreshRSS_Search { } private function parseNotDateSearch(string $input): string { - if (preg_match_all('/(?<=\s|^)[!-]date:(?P<search>[^\s]*)/', $input, $matches)) { + if (preg_match_all('/(?<=\\s|^)[!-]date:(?P<search>[^\\s]*)/', $input, $matches)) { $input = str_replace($matches[0], '', $input); $dates = self::removeEmptyValues($matches['search']); if (!empty($dates[0])) { @@ -550,7 +656,7 @@ class FreshRSS_Search { * The search is the first word following the keyword. */ private function parsePubdateSearch(string $input): string { - if (preg_match_all('/\bpubdate:(?P<search>[^\s]*)/', $input, $matches)) { + if (preg_match_all('/\\bpubdate:(?P<search>[^\\s]*)/', $input, $matches)) { $input = str_replace($matches[0], '', $input); $dates = self::removeEmptyValues($matches['search']); if (!empty($dates[0])) { @@ -561,7 +667,7 @@ class FreshRSS_Search { } private function parseNotPubdateSearch(string $input): string { - if (preg_match_all('/(?<=\s|^)[!-]pubdate:(?P<search>[^\s]*)/', $input, $matches)) { + if (preg_match_all('/(?<=\\s|^)[!-]pubdate:(?P<search>[^\\s]*)/', $input, $matches)) { $input = str_replace($matches[0], '', $input); $dates = self::removeEmptyValues($matches['search']); if (!empty($dates[0])) { @@ -577,20 +683,44 @@ class FreshRSS_Search { * The search is the first word following the #. */ private function parseTagsSearch(string $input): string { - if (preg_match_all('/#(?P<search>[^\s]+)/', $input, $matches)) { + if (preg_match_all('%#(?P<search>/.*?(?<!\\\\)/[im]*)%', $input, $matches)) { + $this->tags_regex = self::htmlspecialchars_decodes($matches['search']); + $input = str_replace($matches[0], '', $input); + } + if (preg_match_all('/#(?P<delim>[\'"])(?P<search>.*)(?P=delim)/U', $input, $matches)) { $this->tags = $matches['search']; $input = str_replace($matches[0], '', $input); - $this->tags = self::removeEmptyValues($this->tags); + } + if (preg_match_all('/#(?P<search>[^\\s]+)/', $input, $matches)) { + $this->tags = $matches['search']; + $input = str_replace($matches[0], '', $input); + } + $this->tags = self::removeEmptyValues($this->tags); + if (empty($this->tags)) { + $this->tags = null; + } else { $this->tags = self::decodeSpaces($this->tags); } return $input; } private function parseNotTagsSearch(string $input): string { - if (preg_match_all('/(?<=\s|^)[!-]#(?P<search>[^\s]+)/', $input, $matches)) { + if (preg_match_all('%(?<=\\s|^)[!-]#(?P<search>/.*?(?<!\\\\)/[im]*)%', $input, $matches)) { + $this->not_tags_regex = self::htmlspecialchars_decodes($matches['search']); + $input = str_replace($matches[0], '', $input); + } + if (preg_match_all('/(?<=\\s|^)[!-]#(?P<delim>[\'"])(?P<search>.*)(?P=delim)/U', $input, $matches)) { + $this->not_tags = $matches['search']; + $input = str_replace($matches[0], '', $input); + } + if (preg_match_all('/(?<=\\s|^)[!-]#(?P<search>[^\\s]+)/', $input, $matches)) { $this->not_tags = $matches['search']; $input = str_replace($matches[0], '', $input); - $this->not_tags = self::removeEmptyValues($this->not_tags); + } + $this->not_tags = self::removeEmptyValues($this->not_tags); + if (empty($this->not_tags)) { + $this->not_tags = null; + } else { $this->not_tags = self::decodeSpaces($this->not_tags); } return $input; @@ -599,13 +729,18 @@ class FreshRSS_Search { /** * Parse the search string to find search values. * Every word is a distinct search value using a delimiter. - * Supported delimiters are single quote (') and double quotes ("). + * Supported delimiters are single quote (') and double quotes (") and regex (/). */ private function parseQuotedSearch(string $input): string { $input = self::cleanSearch($input); if ($input === '') { return ''; } + if (preg_match_all('#(?<=\\s|^)(?<![!-\\\\])(?P<search>/.*?(?<!\\\\)/[im]*)#', $input, $matches)) { + $this->search_regex = self::htmlspecialchars_decodes($matches['search']); + //TODO: Replace all those str_replace with PREG_OFFSET_CAPTURE + $input = str_replace($matches[0], '', $input); + } if (preg_match_all('/(?<![!-])(?P<delim>[\'"])(?P<search>.*)(?P=delim)/U', $input, $matches)) { $this->search = $matches['search']; //TODO: Replace all those str_replace with PREG_OFFSET_CAPTURE @@ -636,7 +771,11 @@ class FreshRSS_Search { if ($input === '') { return ''; } - if (preg_match_all('/(?<=\s|^)[!-](?P<delim>[\'"])(?P<search>.*)(?P=delim)/U', $input, $matches)) { + if (preg_match_all('#(?<=\\s|^)[!-](?P<search>(?<!\\\\)/.*?(?<!\\\\)/[im]*)#', $input, $matches)) { + $this->not_search_regex = self::htmlspecialchars_decodes($matches['search']); + $input = str_replace($matches[0], '', $input); + } + if (preg_match_all('/(?<=\\s|^)[!-](?P<delim>[\'"])(?P<search>.*)(?P=delim)/U', $input, $matches)) { $this->not_search = $matches['search']; $input = str_replace($matches[0], '', $input); } @@ -644,7 +783,7 @@ class FreshRSS_Search { if ($input === '') { return ''; } - if (preg_match_all('/(?<=\s|^)[!-](?P<search>[^\s]+)/', $input, $matches)) { + if (preg_match_all('/(?<=\\s|^)[!-](?P<search>[^\\s]+)/', $input, $matches)) { $this->not_search = array_merge(is_array($this->not_search) ? $this->not_search : [], $matches['search']); $input = str_replace($matches[0], '', $input); } @@ -656,7 +795,7 @@ class FreshRSS_Search { * Remove all unnecessary spaces in the search */ private static function cleanSearch(string $input): string { - $input = preg_replace('/\s+/', ' ', $input); + $input = preg_replace('/\\s+/', ' ', $input); if (!is_string($input)) { return ''; } diff --git a/docs/en/admins/02_Prerequisites.md b/docs/en/admins/02_Prerequisites.md index c54a7fd56..d38093b7a 100644 --- a/docs/en/admins/02_Prerequisites.md +++ b/docs/en/admins/02_Prerequisites.md @@ -9,7 +9,7 @@ You need to verify that your server can run FreshRSS before installing it. If yo | Web server | **Apache 2.4** | nginx, lighttpd<br />minimal compatibility with Apache 2.2 | | PHP | **PHP 8.1+** | FreshRSS 1.21/1.22: PHP 7.2+; FreshRSS 1.23/1.24: PHP 7.4+ | | PHP modules | Required: libxml, cURL, JSON, PDO_MySQL, PCRE and ctype.<br />Required (32-bit only): GMP <br />Recommended: Zlib, mbstring, iconv, ZipArchive<br />*For the whole modules list see [Dockerfile](https://github.com/FreshRSS/FreshRSS/blob/edge/Docker/Dockerfile-Alpine#L7-L9)* | | -| Database | **PostgreSQL 10+** | SQLite, MySQL 5.5.3+, MariaDB 5.5+ | +| Database | **PostgreSQL 10+** | SQLite, MariaDB 10.0.5+, MySQL 8.0+ | | Browser | **Firefox** | Chrome, Opera, Safari, or Edge | ## Getting the appropriate version of FreshRSS diff --git a/docs/en/admins/DatabaseConfig.md b/docs/en/admins/DatabaseConfig.md index 575749a7f..bc9b6a175 100644 --- a/docs/en/admins/DatabaseConfig.md +++ b/docs/en/admins/DatabaseConfig.md @@ -1,6 +1,6 @@ # Database configuration -FreshRSS supports the databases SQLite (built-in), PostgreSQL, MySQL / MariaDB. +FreshRSS supports the databases SQLite (built-in), PostgreSQL, MariaDB / MySQL. While the default installation should be fine for most cases, additional tuning can be made. diff --git a/docs/en/developers/06_Reporting_Bugs.md b/docs/en/developers/06_Reporting_Bugs.md index 690330118..0c5d255ff 100644 --- a/docs/en/developers/06_Reporting_Bugs.md +++ b/docs/en/developers/06_Reporting_Bugs.md @@ -64,7 +64,7 @@ Remember to give the following information if you know it: 1. Which browser? Which version? 2. Which server: Apache, Nginx? Which version? 3. Which version of PHP? -4. Which database: SQLite, MySQL, MariaDB, PostgreSQL? Which version? +4. Which database: SQLite, PostgreSQL, MariaDB, MySQL? Which version? 5. Which distribution runs on the server? And… which version? ## How to provide feed data diff --git a/docs/en/users/10_filter.md b/docs/en/users/10_filter.md index 519130c14..943537471 100644 --- a/docs/en/users/10_filter.md +++ b/docs/en/users/10_filter.md @@ -49,7 +49,7 @@ You can use the search field to further refine results: * by author: `author:name` or `author:'composed name'` * by title: `intitle:keyword` or `intitle:'composed keyword'` * by URL: `inurl:keyword` or `inurl:'composed keyword'` -* by tag: `#tag` or `#tag+with+whitespace` +* by tag: `#tag` or `#tag+with+whitespace` or or `#'tag with whitespace'` * by free-text: `keyword` or `'composed keyword'` * by date of discovery, using the [ISO 8601 time interval format](http://en.wikipedia.org/wiki/ISO_8601#Time_intervals): `date:<date-interval>` * From a specific day, or month, or year: @@ -105,6 +105,8 @@ can be used to combine several search criteria with a logical *or* instead: `aut You don’t have to do anything special to combine multiple negative operators. Writing `!intitle:'thing1' !intitle:'thing2'` implies AND, see above. For more pointers on how AND and OR interact with negation, see [this GitHub comment](https://github.com/FreshRSS/FreshRSS/issues/3236#issuecomment-891219460). Additional reading: [De Morgan’s laws](https://en.wikipedia.org/wiki/De_Morgan%27s_laws). +> ℹ️ Searches are applied to the raw HTML content + Finally, parentheses may be used to express more complex queries, with basic negation support: * `(author:Alice OR intitle:hello) (author:Bob OR intitle:world)` @@ -115,6 +117,31 @@ Finally, parentheses may be used to express more complex queries, with basic neg > ℹ️ If you need to search for a parenthesis, it needs to be escaped like `\(` or `\)` +### Regex + +Text searches (including `author:`, `intitle:`, `inurl:`, `#`) may use regular expressions, which must be enclosed in `/ /`. + +Regex searches are case-sensitive by default, but can be made case-insensitive with the `i` modifier like: `/Alice/i` + +Supports multiline mode with `m` modifier like: `/^Alice/m` + +> ℹ️ `author:` is working with one author per line, so the multiline mode may advantageously be used, like: `author:/^Alice Dupont$/im` +> +> ℹ️ `#` is likewise working with one tag per line, so the multiline mode may advantageously be used, like: `#/^Hello World$/im` + +Example to search entries, which title starts with the *Lol* word, with any number of *o*: `intitle:/^Lo+l/i` + +As opposed to normal searches, HTML special characters are not escaped in regex searches, to allow searching HTML code, like: `/Hello <span>world<\/span>/` + +⚠️ Advanced regex syntax details depend on the regex engine used: + +* FreshRSS filter actions such as auto-mark-as-read and auto-favourite use [PHP preg_match](https://php.net/function.preg-match). +* Regex searches depend on which database you are using: + * For SQLite, [PHP preg_match](https://php.net/function.preg-match) is used; + * [For PostgreSQL](https://www.postgresql.org/docs/current/functions-matching.html#FUNCTIONS-POSIX-REGEXP); + * [For MariaDB](https://mariadb.com/kb/en/pcre/); + * [For MySQL](https://dev.mysql.com/doc/refman/9.0/en/regexp.html#function_regexp-like). + ## By sorting by date You can change the sort order by clicking the toggle button available in the header. diff --git a/docs/fr/contributing.md b/docs/fr/contributing.md index c1742d62b..0ffbe806e 100644 --- a/docs/fr/contributing.md +++ b/docs/fr/contributing.md @@ -32,7 +32,7 @@ Nous avons aussi besoin de quelques informations : * Votre version de FreshRSS (sur la page A propos) ou le fichier `constants.php`) * Votre configuration de serveur : type d’hébergement, version PHP -* Quelle base de données : SQLite, MySQL, MariaDB, PostgreSQL ? Quelle version ? +* Quelle base de données : SQLite, PostgreSQL, MariaDB, MySQL ? Quelle version ? * Si possible, les logs associés (logs PHP et logs FreshRSS sous `data/users/your_user/log.txt`) ## Corriger un bogue diff --git a/docs/fr/developers/02_Github.md b/docs/fr/developers/02_Github.md index 0f1aaeb0d..190eed9f2 100644 --- a/docs/fr/developers/02_Github.md +++ b/docs/fr/developers/02_Github.md @@ -100,7 +100,7 @@ Pensez à donner les informations suivantes si vous les connaissez : 1. Quel navigateur ? Quelle version ? 2. Quel serveur : Apache, Nginx ? Quelle version ? 3. Quelle version de PHP ? -4. Quelle base de données : SQLite, MySQL, MariaDB, PostgreSQL ? Quelle version ? +4. Quelle base de données : SQLite, PostgreSQL, MariaDB, MySQL ? Quelle version ? 5. Quelle distribution sur le serveur ? Et… quelle version ? ## Système de branches diff --git a/docs/fr/users/01_Installation.md b/docs/fr/users/01_Installation.md index 390052ec5..ecee8702b 100644 --- a/docs/fr/users/01_Installation.md +++ b/docs/fr/users/01_Installation.md @@ -9,7 +9,7 @@ Il est toutefois de votre responsabilité de vérifier que votre hébergement pe | Serveur web | **Apache 2.4+** | nginx, lighttpd | | PHP | **PHP 8.1+** | | | Modules PHP | Requis : libxml, cURL, JSON, PDO_MySQL, PCRE et ctype<br />Requis (32 bits seulement) : GMP<br />Recommandé : Zlib, mbstring et iconv, ZipArchive<br />*Pour une liste complète des modules nécessaires voir le [Dockerfile](https://github.com/FreshRSS/FreshRSS/blob/edge/Docker/Dockerfile-Alpine#L7-L9)* | | -| Base de données | **PostgreSQL 10+** | SQLite, MySQL 5.5.3+, MariaDB 5.5+ | +| Base de données | **PostgreSQL 10+** | SQLite, MariaDB 10.0.5+, MySQL 8.0+ | | Navigateur | **Firefox** | Chrome, Opera, Safari, or Edge | ## Choisir la bonne version de FreshRSS diff --git a/docs/fr/users/03_Main_view.md b/docs/fr/users/03_Main_view.md index 788143171..2af7a86b5 100644 --- a/docs/fr/users/03_Main_view.md +++ b/docs/fr/users/03_Main_view.md @@ -208,7 +208,7 @@ Il est possible d’utiliser le champ de recherche pour raffiner les résultats * par auteur : `author:nom` ou `author:'nom composé'` * par titre : `intitle:mot` ou `intitle:'mot composé'` * par URL : `inurl:mot` ou `inurl:'mot composé'` -* par tag : `#tag` +* par tag : `#tag` ou `#'tag avec espace'` * par texte libre : `mot` ou `'mot composé'` * par date d’ajout, en utilisant le [format ISO 8601 d’intervalle entre deux dates](https://fr.wikipedia.org/wiki/ISO_8601#Intervalle_entre_deux_dates) : `date:<intervalle-de-dates>` * D’un jour spécifique, ou mois, ou année : @@ -264,6 +264,8 @@ encore plus précis, et il est autorisé d’avoir plusieurs instances de : Combiner plusieurs critères implique un *et* logique, mais le mot clef `OR` peut être utilisé pour combiner plusieurs critères avec un *ou* logique : `author:Dupont OR author:Dupond` +> ℹ️ Les recherches sont effectuées sur le code HTML brut + Enfin, les parenthèses peuvent être utilisées pour des expressions plus complexes, avec un support basique de la négation : * `(author:Alice OR intitle:bonjour) (author:Bob OR intitle:monde)` @@ -273,3 +275,28 @@ Enfin, les parenthèses peuvent être utilisées pour des expressions plus compl * `!(S:1 OR S:2)` > ℹ️ Si vous devez chercher une parenthèse, elle doit être *échappée* comme suit : `\(` ou `\)` + +#### Regex + +Les recherches de texte (incluant `author:`, `intitle:`, `inurl:`, `#`) peuvent utiliser les expressions régulières qui doivent être exprimées comme `/ /`. + +Les recherches regex sont sensibles à la casse, mais peuvent être rendues insensibles à la casse avec l’option de recherche `i` comme : `/Alice/i` + +Le mode multilignes peut être activé avec l’option de recherche `m` comme : `/^Alice/m` + +> ℹ️ `author:` fonctionne avec un auteur par ligne, ce qui fait que le mode multilignes peut être avantageux, comme : `author:/^Alice Doe$/im` +> +> ℹ️ `#` fonctionne également avec un tag par line, ce qui fait que le mode multilignes peut être avantageux, comme : `#/^Hello World$/im` + +Exemple pour rechercher des articles dont le titre commence par le mot *Lol* avec un nombre indéterminé de *o*: `intitle:/^Lo+l/i` + +Contrairement aux recherches normales, les caractères spéciaux HTML ne sont pas encodés dans les recherches regex, afin de permettre de chercher du code HTML, comme : `/Bonjour <span>à tous<\/span>/` + +⚠️ Les détails de syntaxe regex avancée dépendent du moteur regex utilisé : + +* Les filtres d’action de FreshRSS comme marquer-automatiquement-comme-lu et mettre-automatiquement-en-favori utilisent [PHP preg_match](https://php.net/function.preg-match). +* Les recherches regex dépendent de la base de données utilisée : + * Pour SQLite, [PHP preg_match](https://php.net/function.preg-match) est utilisé ; + * [Pour PostgreSQL](https://www.postgresql.org/docs/current/functions-matching.html#FUNCTIONS-POSIX-REGEXP) ; + * [Pour MariaDB](https://mariadb.com/kb/en/pcre/) ; + * [Pour MySQL](https://dev.mysql.com/doc/refman/9.0/en/regexp.html#function_regexp-like). diff --git a/lib/Minz/ModelPdo.php b/lib/Minz/ModelPdo.php index f27ae5dc7..7804b3302 100644 --- a/lib/Minz/ModelPdo.php +++ b/lib/Minz/ModelPdo.php @@ -16,6 +16,11 @@ class Minz_ModelPdo { */ public static bool $usesSharedPdo = true; + /** + * If true, the connection to the database will be a dummy one. Useful for unit tests. + */ + public static bool $dummyConnection = false; + private static ?Minz_Pdo $sharedPdo = null; private static string $sharedCurrentUser = ''; @@ -97,6 +102,9 @@ class Minz_ModelPdo { $this->pdo = $currentPdo; return; } + if (self::$dummyConnection) { + return; + } if ($currentUser == null) { throw new Minz_PDOConnectionException('Current user must not be empty!', '', Minz_Exception::ERROR); } diff --git a/tests/app/Models/SearchTest.php b/tests/app/Models/SearchTest.php index 27943cdb2..a25adc160 100644 --- a/tests/app/Models/SearchTest.php +++ b/tests/app/Models/SearchTest.php @@ -124,10 +124,10 @@ class SearchTest extends PHPUnit\Framework\TestCase { public static function provideInurlSearch(): array { return [ ['inurl:word1', ['word1'], null], - ['inurl: word1', [], ['word1']], + ['inurl: word1', null, ['word1']], ['inurl:123', ['123'], null], ['inurl:word1 word2', ['word1'], ['word2']], - ['inurl:"word1 word2"', ['"word1'], ['word2"']], + ['inurl:"word1 word2"', ['word1 word2'], null], ['inurl:word1 word2 inurl:word3', ['word1', 'word3'], ['word2']], ["inurl:word1 'word2 word3' word4", ['word1'], ['word2 word3', 'word4']], ['inurl:word1+word2', ['word1+word2'], null], @@ -196,7 +196,7 @@ class SearchTest extends PHPUnit\Framework\TestCase { ['# word1', null, ['#', 'word1']], ['#123', ['123'], null], ['#word1 word2', ['word1'], ['word2']], - ['#"word1 word2"', ['"word1'], ['word2"'],], + ['#"word1 word2"', ['word1 word2'], null], ['#word1 #word2', ['word1', 'word2'], null], ["#word1 'word2 word3' word4", ['word1'], ['word2 word3', 'word4']], ['#word1+word2', ['word1 word2'], null] @@ -442,4 +442,172 @@ class SearchTest extends PHPUnit\Framework\TestCase { ], ]; } + + /** + * @dataProvider provideRegexPostreSQL + * @param array<string> $values + */ + public function test__regex_postgresql(string $input, string $sql, array $values): void { + [$filterValues, $filterSearch] = FreshRSS_EntryDAOPGSQL::sqlBooleanSearch('e.', new FreshRSS_BooleanSearch($input)); + self::assertEquals(trim($sql), trim($filterSearch)); + self::assertEquals($values, $filterValues); + } + + /** @return array<array<mixed>> */ + public function provideRegexPostreSQL(): array { + return [ + [ + 'intitle:/^ab$/', + '(e.title ~ ? )', + ['^ab$'] + ], + [ + 'intitle:/^ab$/i', + '(e.title ~* ? )', + ['^ab$'] + ], + [ + 'intitle:/^ab$/m', + '(e.title ~ ? )', + ['(?m)^ab$'] + ], + [ + 'intitle:/^ab\\M/', + '(e.title ~ ? )', + ['^ab\\M'] + ], + [ + 'author:/^ab$/', + "(REPLACE(e.author, ';', '\n') ~ ? )", + ['^ab$'] + ], + [ + 'inurl:/^ab$/', + '(e.link ~ ? )', + ['^ab$'] + ], + [ + '/^ab$/', + '((e.title ~ ? OR e.content ~ ?) )', + ['^ab$', '^ab$'] + ], + [ + '!/^ab$/', + '(NOT e.title ~ ? AND NOT e.content ~ ? )', + ['^ab$', '^ab$'] + ], + [ // Not a regex + 'inurl:https://example.net/test/', + '(e.link LIKE ? )', + ['%https://example.net/test/%'] + ], + [ // Not a regex + 'https://example.net/test/', + '((e.title LIKE ? OR e.content LIKE ?) )', + ['%https://example.net/test/%', '%https://example.net/test/%'] + ], + ]; + } + + /** + * @dataProvider provideRegexMariaDB + * @param array<string> $values + */ + public function test__regex_mariadb(string $input, string $sql, array $values): void { + FreshRSS_DatabaseDAO::$dummyConnection = true; + FreshRSS_DatabaseDAO::setStaticVersion('11.4.3-MariaDB-ubu2404'); + [$filterValues, $filterSearch] = FreshRSS_EntryDAO::sqlBooleanSearch('e.', new FreshRSS_BooleanSearch($input)); + self::assertEquals(trim($sql), trim($filterSearch)); + self::assertEquals($values, $filterValues); + } + + /** @return array<array<mixed>> */ + public function provideRegexMariaDB(): array { + return [ + [ + 'intitle:/^ab$/', + "(e.title REGEXP ? )", + ['(?-i)^ab$'] + ], + [ + 'intitle:/^ab$/i', + "(e.title REGEXP ? )", + ['(?i)^ab$'] + ], + [ + 'intitle:/^ab$/m', + "(e.title REGEXP ? )", + ['(?-i)(?m)^ab$'] + ], + ]; + } + + /** + * @dataProvider provideRegexMySQL + * @param array<string> $values + */ + public function test__regex_mysql(string $input, string $sql, array $values): void { + FreshRSS_DatabaseDAO::$dummyConnection = true; + FreshRSS_DatabaseDAO::setStaticVersion('9.0.1'); + [$filterValues, $filterSearch] = FreshRSS_EntryDAO::sqlBooleanSearch('e.', new FreshRSS_BooleanSearch($input)); + self::assertEquals(trim($sql), trim($filterSearch)); + self::assertEquals($values, $filterValues); + } + + /** @return array<array<mixed>> */ + public function provideRegexMySQL(): array { + return [ + [ + 'intitle:/^ab$/', + "(REGEXP_LIKE(e.title,?,'c') )", + ['^ab$'] + ], + [ + 'intitle:/^ab$/i', + "(REGEXP_LIKE(e.title,?,'i') )", + ['^ab$'] + ], + [ + 'intitle:/^ab$/m', + "(REGEXP_LIKE(e.title,?,'mc') )", + ['^ab$'] + ], + ]; + } + + /** + * @dataProvider provideRegexSQLite + * @param array<string> $values + */ + public function test__regex_sqlite(string $input, string $sql, array $values): void { + [$filterValues, $filterSearch] = FreshRSS_EntryDAOSQLite::sqlBooleanSearch('e.', new FreshRSS_BooleanSearch($input)); + self::assertEquals(trim($sql), trim($filterSearch)); + self::assertEquals($values, $filterValues); + } + + /** @return array<array<mixed>> */ + public function provideRegexSQLite(): array { + return [ + [ + 'intitle:/^ab$/', + "(e.title REGEXP ? )", + ['/^ab$/'] + ], + [ + 'intitle:/^ab$/i', + "(e.title REGEXP ? )", + ['/^ab$/i'] + ], + [ + 'intitle:/^ab$/m', + "(e.title REGEXP ? )", + ['/^ab$/m'] + ], + [ + 'intitle:/^ab\\b/', + '(e.title REGEXP ? )', + ['/^ab\\b/'] + ], + ]; + } } |