aboutsummaryrefslogtreecommitdiff
path: root/app/Models/UserQuery.php
diff options
context:
space:
mode:
authorGravatar Alexandre Alapetite <alexandre@alapetite.fr> 2024-02-26 09:01:03 +0100
committerGravatar GitHub <noreply@github.com> 2024-02-26 09:01:03 +0100
commit39cc1c11ec596176e842cc98e6a54337e3c04d7e (patch)
treedab89beb80268acb5e4bd58dfc55297bd30a8486 /app/Models/UserQuery.php
parent25166c218be4e1ce1cb098de274a231b623d527e (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.php210
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 ? '&amp;' : '&') . 'f=rss';
+ }
+ return '';
+ }
+
+ public function sharedUrlHtml(bool $xmlEscaped = true): string {
+ if ($this->shareRss && $this->token !== '') {
+ return $this->sharedUrl($xmlEscaped) . ($xmlEscaped ? '&amp;' : '&') . '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 ? '&amp;' : '&') . 'f=opml';
+ }
+ return '';
+ }
}