diff options
| author | 2024-02-26 09:01:03 +0100 | |
|---|---|---|
| committer | 2024-02-26 09:01:03 +0100 | |
| commit | 39cc1c11ec596176e842cc98e6a54337e3c04d7e (patch) | |
| tree | dab89beb80268acb5e4bd58dfc55297bd30a8486 /app/Models/UserQuery.php | |
| parent | 25166c218be4e1ce1cb098de274a231b623d527e (diff) | |
New feature: shareable user query (#6052)
* New feature: shareable user query
Share the output of a user query by RSS / HTML / OPML with other people through unique URLs.
Replaces the global admin token, which was the only option (but unsafe) to share RSS outputs with other people.
Also add a new HTML output for people without an RSS reader.
fix https://github.com/FreshRSS/FreshRSS/issues/3066#issuecomment-648977890
fix https://github.com/FreshRSS/FreshRSS/issues/3178#issuecomment-769435504
* Remove unused method
* Fix token saving
* Implement HTML view
* Update i18n for master token
* Revert i18n get_favorite
* Fix missing i18n for user queries from before this PR
* Remove irrelevant tests
* Add link to RSS version
* Fix getGet
* Fix getState
* Fix getSearch
* Alternative getSearch
* Default getOrder
* Explicit default state
* Fix test
* Add OPML sharing
* Remove many redundant SQL queries from original implementation of user queries
* Fix article tags
* Use default user settings
* Prepare public search
* Fixes
* Allow user search on article tags
* Implement user search
* Revert filter bug
* Revert wrong SQL left outer join change
* Implement checkboxes
* Safe check of OPML
* Fix label
* Remove RSS button to favour new sharing method
That sharing button was using a global admin token
* First version of HTTP 304
* Disallow some recusrivity
fix https://github.com/FreshRSS/FreshRSS/issues/6086
* Draft of nav
* Minor httpConditional
* Add support for offset for pagination
* Fix offset pagination
* Fix explicit order ASC
* Add documentation
* Help links i18n
* Note about deprecated master token
* Typo
* Doc about format
Diffstat (limited to 'app/Models/UserQuery.php')
| -rw-r--r-- | app/Models/UserQuery.php | 210 |
1 files changed, 127 insertions, 83 deletions
diff --git a/app/Models/UserQuery.php b/app/Models/UserQuery.php index 000cfbbdd..156b2df4a 100644 --- a/app/Models/UserQuery.php +++ b/app/Models/UserQuery.php @@ -18,17 +18,34 @@ class FreshRSS_UserQuery { private FreshRSS_BooleanSearch $search; private int $state = 0; private string $url = ''; - private ?FreshRSS_FeedDAO $feed_dao; - private ?FreshRSS_CategoryDAO $category_dao; - private ?FreshRSS_TagDAO $tag_dao; + private string $token = ''; + private bool $shareRss = false; + private bool $shareOpml = false; + /** @var array<int,FreshRSS_Category> $categories */ + private array $categories; + /** @var array<int,FreshRSS_Tag> $labels */ + private array $labels; + + public static function generateToken(string $salt): string { + if (!FreshRSS_Context::hasSystemConf()) { + return ''; + } + $hash = md5(FreshRSS_Context::systemConf()->salt . $salt . random_bytes(16)); + if (function_exists('gmp_init')) { + // Shorten the hash if possible by converting from base 16 to base 62 + $hash = gmp_strval(gmp_init($hash, 16), 62); + } + return $hash; + } /** - * @param array{'get'?:string,'name'?:string,'order'?:string,'search'?:string,'state'?:int,'url'?:string} $query + * @param array{get?:string,name?:string,order?:string,search?:string,state?:int,url?:string,token?:string,shareRss?:bool,shareOpml?:bool} $query + * @param array<int,FreshRSS_Category> $categories + * @param array<int,FreshRSS_Tag> $labels */ - public function __construct(array $query, FreshRSS_FeedDAO $feed_dao = null, FreshRSS_CategoryDAO $category_dao = null, FreshRSS_TagDAO $tag_dao = null) { - $this->category_dao = $category_dao; - $this->feed_dao = $feed_dao; - $this->tag_dao = $tag_dao; + public function __construct(array $query, array $categories, array $labels) { + $this->categories = $categories; + $this->labels = $labels; if (isset($query['get'])) { $this->parseGet($query['get']); } @@ -49,8 +66,18 @@ class FreshRSS_UserQuery { if (!isset($query['search'])) { $query['search'] = ''; } + if (!empty($query['token'])) { + $this->token = $query['token']; + } + if (isset($query['shareRss'])) { + $this->shareRss = $query['shareRss']; + } + if (isset($query['shareOpml'])) { + $this->shareOpml = $query['shareOpml']; + } + // linked too deeply with the search object, need to use dependency injection - $this->search = new FreshRSS_BooleanSearch($query['search']); + $this->search = new FreshRSS_BooleanSearch($query['search'], 0, 'AND', false); if (!empty($query['state'])) { $this->state = intval($query['state']); } @@ -59,16 +86,19 @@ class FreshRSS_UserQuery { /** * Convert the current object to an array. * - * @return array{'get'?:string,'name'?:string,'order'?:string,'search'?:string,'state'?:int,'url'?:string} + * @return array{'get'?:string,'name'?:string,'order'?:string,'search'?:string,'state'?:int,'url'?:string,'token'?:string} */ public function toArray(): array { return array_filter([ 'get' => $this->get, 'name' => $this->name, 'order' => $this->order, - 'search' => $this->search->__toString(), + 'search' => $this->search->getRawInput(), 'state' => $this->state, 'url' => $this->url, + 'token' => $this->token, + 'shareRss' => $this->shareRss, + 'shareOpml' => $this->shareOpml, ]); } @@ -77,93 +107,44 @@ class FreshRSS_UserQuery { */ private function parseGet(string $get): void { $this->get = $get; - if (preg_match('/(?P<type>[acfst])(_(?P<id>\d+))?/', $get, $matches)) { + if (preg_match('/(?P<type>[acfistT])(_(?P<id>\d+))?/', $get, $matches)) { $id = intval($matches['id'] ?? '0'); switch ($matches['type']) { case 'a': - $this->parseAll(); + $this->get_type = 'all'; break; case 'c': - $this->parseCategory($id); + $this->get_type = 'category'; + $c = $this->categories[$id] ?? null; + $this->get_name = $c === null ? '' : $c->name(); break; case 'f': - $this->parseFeed($id); + $this->get_type = 'feed'; + $f = FreshRSS_Category::findFeed($this->categories, $id); + $this->get_name = $f === null ? '' : $f->name(); + break; + case 'i': + $this->get_type = 'important'; break; case 's': - $this->parseFavorite(); + $this->get_type = 'favorite'; break; case 't': - $this->parseTag($id); + $this->get_type = 'label'; + $l = $this->labels[$id] ?? null; + $this->get_name = $l === null ? '' : $l->name(); + break; + case 'T': + $this->get_type = 'all_labels'; break; } + if ($this->get_name === '' && in_array($matches['type'], ['c', 'f', 't'], true)) { + $this->deprecated = true; + } } } /** - * Parse the query string when it is an "all" query - */ - private function parseAll(): void { - $this->get_name = 'all'; - $this->get_type = 'all'; - } - - /** - * Parse the query string when it is a "category" query - */ - private function parseCategory(int $id): void { - if ($this->category_dao === null) { - $this->category_dao = FreshRSS_Factory::createCategoryDao(); - } - $category = $this->category_dao->searchById($id); - if ($category !== null) { - $this->get_name = $category->name(); - } else { - $this->deprecated = true; - } - $this->get_type = 'category'; - } - - /** - * Parse the query string when it is a "feed" query - */ - private function parseFeed(int $id): void { - if ($this->feed_dao === null) { - $this->feed_dao = FreshRSS_Factory::createFeedDao(); - } - $feed = $this->feed_dao->searchById($id); - if ($feed !== null) { - $this->get_name = $feed->name(); - } else { - $this->deprecated = true; - } - $this->get_type = 'feed'; - } - - /** - * Parse the query string when it is a "tag" query - */ - private function parseTag(int $id): void { - if ($this->tag_dao === null) { - $this->tag_dao = FreshRSS_Factory::createTagDao(); - } - $tag = $this->tag_dao->searchById($id); - if ($tag !== null) { - $this->get_name = $tag->name(); - } else { - $this->deprecated = true; - } - $this->get_type = 'tag'; - } - - /** - * Parse the query string when it is a "favorite" query - */ - private function parseFavorite(): void { - $this->get_name = 'favorite'; - $this->get_type = 'favorite'; - } - - /** * Check if the current user query is deprecated. * It is deprecated if the category or the feed used in the query are * not existing. @@ -219,7 +200,7 @@ class FreshRSS_UserQuery { } public function getOrder(): string { - return $this->order; + return $this->order ?: FreshRSS_Context::userConf()->sort_order; } public function getSearch(): FreshRSS_BooleanSearch { @@ -227,11 +208,74 @@ class FreshRSS_UserQuery { } public function getState(): int { - return $this->state; + $state = $this->state; + if (!($state & FreshRSS_Entry::STATE_READ) && !($state & FreshRSS_Entry::STATE_NOT_READ)) { + $state |= FreshRSS_Entry::STATE_READ | FreshRSS_Entry::STATE_NOT_READ; + } + if (!($state & FreshRSS_Entry::STATE_FAVORITE) && !($state & FreshRSS_Entry::STATE_NOT_FAVORITE)) { + $state |= FreshRSS_Entry::STATE_FAVORITE | FreshRSS_Entry::STATE_NOT_FAVORITE; + } + return $state; } public function getUrl(): string { return $this->url; } + public function getToken(): string { + return $this->token; + } + + public function setToken(string $token): void { + $this->token = $token; + } + + public function setShareRss(bool $shareRss): void { + $this->shareRss = $shareRss; + } + + public function shareRss(): bool { + return $this->shareRss; + } + + public function setShareOpml(bool $shareOpml): void { + $this->shareOpml = $shareOpml; + } + + public function shareOpml(): bool { + return $this->shareOpml; + } + + protected function sharedUrl(bool $xmlEscaped = true): string { + $currentUser = Minz_User::name() ?? ''; + return Minz_Url::display("/api/query.php?user={$currentUser}&t={$this->token}", $xmlEscaped ? 'html' : '', true); + } + + public function sharedUrlRss(bool $xmlEscaped = true): string { + if ($this->shareRss && $this->token !== '') { + return $this->sharedUrl($xmlEscaped) . ($xmlEscaped ? '&' : '&') . 'f=rss'; + } + return ''; + } + + public function sharedUrlHtml(bool $xmlEscaped = true): string { + if ($this->shareRss && $this->token !== '') { + return $this->sharedUrl($xmlEscaped) . ($xmlEscaped ? '&' : '&') . 'f=html'; + } + return ''; + } + + /** + * OPML is only safe for some query types, otherwise it risks leaking unwanted feed information. + */ + public function safeForOpml(): bool { + return in_array($this->get_type, ['all', 'category', 'feed'], true); + } + + public function sharedUrlOpml(bool $xmlEscaped = true): string { + if ($this->shareOpml && $this->token !== '' && $this->safeForOpml()) { + return $this->sharedUrl($xmlEscaped) . ($xmlEscaped ? '&' : '&') . 'f=opml'; + } + return ''; + } } |
