UserQuery.php 11 KB

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