4
0

UserQuery.php 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423
  1. <?php
  2. declare(strict_types=1);
  3. /**
  4. * Contains the description of a user query
  5. *
  6. * It allows to extract the meaningful bits of the query to be manipulated in an
  7. * easy way.
  8. */
  9. class FreshRSS_UserQuery {
  10. private bool $deprecated = false;
  11. private string $get = '';
  12. private string $get_name = '';
  13. private string $get_type = '';
  14. /** XML-encoded name */
  15. private string $name = '';
  16. private string $order = '';
  17. private string $sort = '';
  18. private readonly FreshRSS_BooleanSearch $search;
  19. private int $state = 0;
  20. private string $url = '';
  21. private string $token = '';
  22. private bool $shareRss = false;
  23. private bool $shareOpml = false;
  24. private bool $includeUserLabels = false;
  25. private bool $excludeArticleTags = false;
  26. private string $userLabelPrefix = '';
  27. /** @var array<int,FreshRSS_Category> $categories where the key is the category ID */
  28. private array $categories;
  29. /** @var array<int,FreshRSS_Tag> $labels where the key is the label ID */
  30. private array $labels;
  31. /** XML-encoded description */
  32. private string $description = '';
  33. private string $imageUrl = '';
  34. public static function generateToken(string $salt): string {
  35. if (!FreshRSS_Context::hasSystemConf()) {
  36. return '';
  37. }
  38. $hash = md5(FreshRSS_Context::systemConf()->salt . $salt . random_bytes(16));
  39. if (function_exists('gmp_init')) {
  40. // Shorten the hash if possible by converting from base 16 to base 62
  41. $hash = gmp_strval(gmp_init($hash, 16), 62);
  42. }
  43. return $hash;
  44. }
  45. /**
  46. * @param array{get?:string,name?:string,order?:string,sort?:string,search?:string,state?:int,url?:string,token?:string,
  47. * shareRss?:bool,shareOpml?:bool,includeUserLabels?:bool,excludeArticleTags?:bool,userLabelPrefix?:string,
  48. * publishLabelsInsteadOfTags?:bool,description?:string,imageUrl?:string} $query
  49. * @param array<FreshRSS_Category> $categories
  50. * @param array<FreshRSS_Tag> $labels
  51. * @throws Minz_BadRequestException if the search is too long or if the parentheses are nested too deeply
  52. */
  53. public function __construct(array $query, array $categories, array $labels) {
  54. $this->categories = [];
  55. foreach ($categories as $category) {
  56. $this->categories[$category->id()] = $category;
  57. }
  58. $this->labels = [];
  59. foreach ($labels as $label) {
  60. $this->labels[$label->id()] = $label;
  61. }
  62. if (isset($query['get'])) {
  63. $this->parseGet($query['get']);
  64. } else {
  65. $this->get_type = 'all';
  66. }
  67. if (isset($query['name'])) {
  68. $this->name = trim($query['name']);
  69. }
  70. if (isset($query['order'])) {
  71. $this->order = $query['order'];
  72. }
  73. if (isset($query['sort'])) {
  74. $this->sort = $query['sort'];
  75. }
  76. if (empty($query['url'])) {
  77. if (!empty($query)) {
  78. $link = $query;
  79. unset($link['description']);
  80. unset($link['imageUrl']);
  81. unset($link['name']);
  82. unset($link['shareOpml']);
  83. unset($link['shareRss']);
  84. unset($link['includeUserLabels']);
  85. unset($link['excludeArticleTags']);
  86. unset($link['userLabelPrefix']);
  87. unset($link['publishLabelsInsteadOfTags']);
  88. $this->url = Minz_Url::display(['params' => $link]);
  89. }
  90. } else {
  91. $this->url = $query['url'];
  92. }
  93. if (!isset($query['search'])) {
  94. $query['search'] = '';
  95. }
  96. if (!empty($query['token'])) {
  97. $this->token = $query['token'];
  98. }
  99. if (isset($query['shareRss'])) {
  100. $this->shareRss = $query['shareRss'];
  101. }
  102. if (isset($query['shareOpml'])) {
  103. $this->shareOpml = $query['shareOpml'];
  104. }
  105. if (isset($query['publishLabelsInsteadOfTags'])) { // Legacy
  106. $this->includeUserLabels = (bool)$query['publishLabelsInsteadOfTags'];
  107. $this->excludeArticleTags = (bool)$query['publishLabelsInsteadOfTags'];
  108. if ($this->includeUserLabels) {
  109. $this->userLabelPrefix = '';
  110. }
  111. }
  112. if (isset($query['includeUserLabels'])) {
  113. $this->includeUserLabels = (bool)$query['includeUserLabels'];
  114. }
  115. if (isset($query['excludeArticleTags'])) {
  116. $this->excludeArticleTags = (bool)$query['excludeArticleTags'];
  117. }
  118. if (isset($query['userLabelPrefix'])) {
  119. $this->userLabelPrefix = (string)$query['userLabelPrefix'];
  120. }
  121. if (isset($query['description'])) {
  122. $this->description = $query['description'];
  123. }
  124. if (isset($query['imageUrl'])) {
  125. $this->imageUrl = $query['imageUrl'];
  126. }
  127. // linked too deeply with the search object, need to use dependency injection
  128. $this->search = new FreshRSS_BooleanSearch($query['search'], 0, 'AND', allowUserQueries: true);
  129. if (!empty($query['state'])) {
  130. $this->state = intval($query['state']);
  131. }
  132. }
  133. /**
  134. * Convert the current object to an array.
  135. *
  136. * @return array{get?:string,name?:string,order?:string,sort?:string,search?:string,
  137. * state?:int,url?:string,token?:string,shareRss?:bool,shareOpml?:bool,
  138. * includeUserLabels?:bool,excludeArticleTags?:bool,userLabelPrefix?:string,description?:string,imageUrl?:string}
  139. */
  140. public function toArray(): array {
  141. return array_filter([
  142. 'get' => $this->get,
  143. 'name' => $this->name,
  144. 'order' => $this->order,
  145. 'sort' => $this->sort,
  146. 'search' => $this->search->toString(expandUserQueries: false),
  147. 'state' => $this->state,
  148. 'url' => $this->url,
  149. 'token' => $this->token,
  150. 'shareRss' => $this->shareRss,
  151. 'shareOpml' => $this->shareOpml,
  152. 'includeUserLabels' => $this->includeUserLabels,
  153. 'excludeArticleTags' => $this->excludeArticleTags,
  154. 'userLabelPrefix' => $this->userLabelPrefix,
  155. 'description' => $this->description,
  156. 'imageUrl' => $this->imageUrl,
  157. ], fn($v): bool => $v !== '' && $v !== 0 && $v !== false);
  158. }
  159. /**
  160. * Parse the get parameter in the query string to extract its name and type
  161. */
  162. private function parseGet(string $get): void {
  163. $this->get = $get;
  164. if ($this->get === '') {
  165. $this->get_type = 'all';
  166. } elseif (preg_match('/(?P<type>[aAcfistTZ])(_(?P<id>\d+))?/', $get, $matches)) {
  167. $id = intval($matches['id'] ?? '0');
  168. switch ($matches['type']) {
  169. case 'a': // All PRIORITY_MAIN_STREAM
  170. $this->get_type = 'all';
  171. break;
  172. case 'A': // All except PRIORITY_HIDDEN
  173. $this->get_type = 'A';
  174. break;
  175. case 'Z': // All including PRIORITY_HIDDEN
  176. $this->get_type = 'Z';
  177. break;
  178. case 'c': // Category
  179. $this->get_type = 'category';
  180. $c = $this->categories[$id] ?? null;
  181. $this->get_name = $c === null ? '' : $c->name();
  182. break;
  183. case 'f': // Feed
  184. $this->get_type = 'feed';
  185. $f = FreshRSS_Category::findFeed($this->categories, $id);
  186. $this->get_name = $f === null ? '' : $f->name();
  187. break;
  188. case 'i': // Priority important feeds
  189. $this->get_type = 'important';
  190. break;
  191. case 's': // Starred. Deprecated: use $state instead
  192. $this->get_type = 'favorite';
  193. break;
  194. case 't': // Tag (label)
  195. $this->get_type = 'label';
  196. $l = $this->labels[$id] ?? null;
  197. $this->get_name = $l === null ? '' : $l->name();
  198. break;
  199. case 'T': // Any tag (label)
  200. $this->get_type = 'all_labels';
  201. break;
  202. }
  203. if ($this->get_name === '' && in_array($matches['type'], ['c', 'f', 't'], true)) {
  204. $this->deprecated = true;
  205. }
  206. }
  207. }
  208. /**
  209. * Check if the current user query is deprecated.
  210. * It is deprecated if the category or the feed used in the query are
  211. * not existing.
  212. */
  213. public function isDeprecated(): bool {
  214. return $this->deprecated;
  215. }
  216. /**
  217. * Check if the user query has parameters.
  218. */
  219. public function hasParameters(): bool {
  220. if ($this->get_type !== 'all') {
  221. return true;
  222. }
  223. if ($this->hasSearch()) {
  224. return true;
  225. }
  226. if (!in_array($this->state, [
  227. 0,
  228. FreshRSS_Entry::STATE_READ | FreshRSS_Entry::STATE_NOT_READ,
  229. FreshRSS_Entry::STATE_READ | FreshRSS_Entry::STATE_NOT_READ | FreshRSS_Entry::STATE_FAVORITE | FreshRSS_Entry::STATE_NOT_FAVORITE
  230. ], true)) {
  231. return true;
  232. }
  233. if ($this->order !== '' && $this->order !== FreshRSS_Context::userConf()->sort_order) {
  234. return true;
  235. }
  236. if ($this->sort !== '' && $this->sort !== FreshRSS_Context::userConf()->sort) {
  237. return true;
  238. }
  239. return false;
  240. }
  241. /**
  242. * Check if there is a search in the search object
  243. */
  244. public function hasSearch(): bool {
  245. return $this->search->toString() !== '';
  246. }
  247. public function getGet(): string {
  248. return $this->get;
  249. }
  250. public function getGetName(): string {
  251. return $this->get_name;
  252. }
  253. public function getGetType(): string {
  254. return $this->get_type;
  255. }
  256. public function getName(): string {
  257. return $this->name;
  258. }
  259. public function getOrder(): string {
  260. return $this->order ?: FreshRSS_Context::userConf()->sort_order;
  261. }
  262. public function getSort(): string {
  263. return $this->sort ?: FreshRSS_Context::userConf()->sort;
  264. }
  265. public function getSearch(): FreshRSS_BooleanSearch {
  266. return $this->search;
  267. }
  268. public function getState(): int {
  269. $state = $this->state;
  270. if (!($state & FreshRSS_Entry::STATE_READ) && !($state & FreshRSS_Entry::STATE_NOT_READ)) {
  271. $state |= FreshRSS_Entry::STATE_READ | FreshRSS_Entry::STATE_NOT_READ;
  272. }
  273. if (!($state & FreshRSS_Entry::STATE_FAVORITE) && !($state & FreshRSS_Entry::STATE_NOT_FAVORITE)) {
  274. $state |= FreshRSS_Entry::STATE_FAVORITE | FreshRSS_Entry::STATE_NOT_FAVORITE;
  275. }
  276. return $state;
  277. }
  278. public function getUrl(): string {
  279. return $this->url;
  280. }
  281. public function getToken(): string {
  282. return $this->token;
  283. }
  284. public function setToken(string $token): void {
  285. $this->token = $token;
  286. }
  287. public function setShareRss(bool $shareRss): void {
  288. $this->shareRss = $shareRss;
  289. }
  290. public function shareRss(): bool {
  291. return $this->shareRss;
  292. }
  293. public function setShareOpml(bool $shareOpml): void {
  294. $this->shareOpml = $shareOpml;
  295. }
  296. public function shareOpml(): bool {
  297. return $this->shareOpml;
  298. }
  299. public function setIncludeUserLabels(bool $includeUserLabels): void {
  300. $this->includeUserLabels = $includeUserLabels;
  301. }
  302. public function includeUserLabels(): bool {
  303. return $this->includeUserLabels;
  304. }
  305. public function setExcludeArticleTags(bool $excludeArticleTags): void {
  306. $this->excludeArticleTags = $excludeArticleTags;
  307. }
  308. public function excludeArticleTags(): bool {
  309. return $this->excludeArticleTags;
  310. }
  311. public function setUserLabelPrefix(string $userLabelPrefix): void {
  312. $this->userLabelPrefix = $userLabelPrefix;
  313. }
  314. public function userLabelPrefix(): string {
  315. return $this->userLabelPrefix;
  316. }
  317. protected function sharedUrl(bool $xmlEscaped = true): string {
  318. $currentUser = Minz_User::name() ?? '';
  319. return Minz_Url::display("/api/query.php?user={$currentUser}&t={$this->token}", $xmlEscaped ? 'html' : '', true);
  320. }
  321. public function sharedUrlRss(bool $xmlEscaped = true): string {
  322. if ($this->shareRss && $this->token !== '') {
  323. return $this->sharedUrl($xmlEscaped) . ($xmlEscaped ? '&amp;' : '&') . 'f=rss';
  324. }
  325. return '';
  326. }
  327. public function sharedUrlGreader(bool $xmlEscaped = true): string {
  328. if ($this->shareOpml && $this->token !== '') {
  329. return $this->sharedUrl($xmlEscaped) . ($xmlEscaped ? '&amp;' : '&') . 'f=greader';
  330. }
  331. return '';
  332. }
  333. public function sharedUrlHtml(bool $xmlEscaped = true): string {
  334. if ($this->shareRss && $this->token !== '') {
  335. return $this->sharedUrl($xmlEscaped) . ($xmlEscaped ? '&amp;' : '&') . 'f=html';
  336. }
  337. return '';
  338. }
  339. /**
  340. * OPML is only safe for some query types, otherwise it risks leaking unwanted feed information.
  341. */
  342. public function safeForOpml(): bool {
  343. return in_array($this->get_type, ['all', 'category', 'feed'], true);
  344. }
  345. public function sharedUrlOpml(bool $xmlEscaped = true): string {
  346. if ($this->shareOpml && $this->token !== '' && $this->safeForOpml()) {
  347. return $this->sharedUrl($xmlEscaped) . ($xmlEscaped ? '&amp;' : '&') . 'f=opml';
  348. }
  349. return '';
  350. }
  351. public function getDescription(): string {
  352. return $this->description;
  353. }
  354. public function setDescription(string $description): void {
  355. $this->description = $description;
  356. }
  357. public function getImageUrl(): string {
  358. return $this->imageUrl;
  359. }
  360. public function setImageUrl(string $imageUrl): void {
  361. $this->imageUrl = $imageUrl;
  362. }
  363. /**
  364. * Remove queries where $get is appearing.
  365. * @param string $get the get attribute which should be removed.
  366. * @param array<int,array{get?:string,name?:string,order?:string,sort?:string,search?:string,state?:int,url?:string,token?:string,
  367. * shareRss?:bool,shareOpml?:bool,description?:string,imageUrl?:string}> $queries an array of queries.
  368. * @return array<int,array{get?:string,name?:string,order?:string,sort?:string,search?:string,state?:int,url?:string,token?:string,
  369. * shareRss?:bool,shareOpml?:bool,description?:string,imageUrl?:string}> without queries where $get is appearing.
  370. */
  371. public static function remove_query_by_get(string $get, array $queries): array {
  372. $final_queries = [];
  373. foreach ($queries as $query) {
  374. if (empty($query['get']) || $query['get'] !== $get) {
  375. $final_queries[] = $query;
  376. }
  377. }
  378. return $final_queries;
  379. }
  380. }