4
0

query.php 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244
  1. <?php
  2. declare(strict_types=1);
  3. header('X-Content-Type-Options: nosniff');
  4. require dirname(__DIR__, 2) . '/constants.php';
  5. require LIB_PATH . '/lib_rss.php'; //Includes class autoloader
  6. Minz_Request::init();
  7. $token = Minz_Request::paramString('t', plaintext: true);
  8. if (!ctype_alnum($token)) {
  9. header('HTTP/1.1 422 Unprocessable Entity');
  10. header('Content-Type: text/plain; charset=UTF-8');
  11. die('Invalid token `t`!' . $token);
  12. }
  13. $format = Minz_Request::paramString('f', plaintext: true);
  14. if (!in_array($format, ['atom', 'greader', 'html', 'json', 'opml', 'rss'], true)) {
  15. header('HTTP/1.1 422 Unprocessable Entity');
  16. header('Content-Type: text/plain; charset=UTF-8');
  17. die('Invalid format `f`!');
  18. }
  19. $user = Minz_Request::paramString('user', plaintext: true);
  20. if (!FreshRSS_user_Controller::checkUsername($user)) {
  21. header('HTTP/1.1 422 Unprocessable Entity');
  22. header('Content-Type: text/plain; charset=UTF-8');
  23. die('Invalid user!');
  24. }
  25. Minz_Session::init('FreshRSS', true);
  26. FreshRSS_Context::initSystem();
  27. if (!FreshRSS_Context::hasSystemConf() || !FreshRSS_Context::systemConf()->api_enabled) {
  28. header('HTTP/1.1 503 Service Unavailable');
  29. header('Content-Type: text/plain; charset=UTF-8');
  30. die('Service Unavailable!');
  31. }
  32. if (Minz_Request::pathInfo() !== '') {
  33. // Do not allow trailing slashes
  34. header('HTTP/1.1 400 Bad Request');
  35. die('Invalid path!');
  36. }
  37. FreshRSS_Context::initUser($user);
  38. if (!FreshRSS_Context::hasUserConf() || !FreshRSS_Context::userConf()->enabled) {
  39. usleep(rand(100, 10000)); //Primitive mitigation of scanning for users
  40. header('HTTP/1.1 404 Not Found');
  41. header('Content-Type: text/plain; charset=UTF-8');
  42. die('User not found!');
  43. } else {
  44. usleep(rand(20, 200));
  45. }
  46. require LIB_PATH . '/http-conditional.php';
  47. $dateLastModification = max(
  48. FreshRSS_UserDAO::ctime($user),
  49. FreshRSS_UserDAO::mtime($user),
  50. @filemtime(DATA_PATH . '/config.php') ?: 0
  51. );
  52. // TODO: Consider taking advantage of $feedMode, only for monotonous queries {all, categories, feeds} and not dynamic ones {read/unread, favourites, user labels}
  53. if (!file_exists(DATA_PATH . '/no-cache.txt') && httpConditional($dateLastModification ?: time(), 0, 0, false, PHP_COMPRESSION, false)) {
  54. exit(); //No need to send anything
  55. }
  56. Minz_Translate::init(FreshRSS_Context::userConf()->language);
  57. Minz_ExtensionManager::init();
  58. Minz_ExtensionManager::enableByList(FreshRSS_Context::userConf()->extensions_enabled, 'user');
  59. $query = null;
  60. $userSearch = null;
  61. foreach (FreshRSS_Context::userConf()->queries as $raw_query) {
  62. if (!empty($raw_query['token']) && hash_equals($raw_query['token'], $token)) {
  63. switch ($format) {
  64. case 'atom':
  65. case 'html':
  66. case 'rss':
  67. if (empty($raw_query['shareRss'])) {
  68. continue 2;
  69. }
  70. break;
  71. case 'greader':
  72. case 'json':
  73. case 'opml':
  74. if (empty($raw_query['shareOpml'])) {
  75. continue 2;
  76. }
  77. break;
  78. default:
  79. continue 2;
  80. }
  81. $query = new FreshRSS_UserQuery($raw_query, FreshRSS_Context::categories(), FreshRSS_Context::labels());
  82. Minz_Request::_param('get', $query->getGet());
  83. if (Minz_Request::paramString('order', plaintext: true) === '') {
  84. Minz_Request::_param('order', $query->getOrder());
  85. }
  86. if (Minz_Request::paramString('sort', plaintext: true) === '') {
  87. Minz_Request::_param('sort', $query->getSort());
  88. }
  89. Minz_Request::_param('state', (string)$query->getState());
  90. // Note: we disallow references to user queries in public user search to avoid sniffing internal user queries
  91. try {
  92. $userSearch = new FreshRSS_BooleanSearch(Minz_Request::paramString('search', plaintext: true), 0, 'AND', allowUserQueries: false);
  93. } catch (Minz_BadRequestException $e) {
  94. header('HTTP/1.1 400 Bad Request');
  95. header('Content-Type: text/plain; charset=UTF-8');
  96. die($e->getMessage());
  97. }
  98. // Only the search of the user query is passed as a string; the user search is combined structurally below
  99. Minz_Request::_param('search', $query->getSearch()->toString());
  100. break;
  101. }
  102. }
  103. if ($query === null || $userSearch === null) {
  104. usleep(rand(100, 10000));
  105. header('HTTP/1.1 404 Not Found');
  106. header('Content-Type: text/plain; charset=UTF-8');
  107. die('User query not found!');
  108. }
  109. $view = new FreshRSS_View();
  110. try {
  111. FreshRSS_Context::updateUsingRequest(false);
  112. if ($userSearch->toString() !== '') {
  113. if (FreshRSS_Context::$search->toString() === '') {
  114. FreshRSS_Context::$search = $userSearch;
  115. } else {
  116. // Combine with a structural AND, so that the user search cannot alter the search of the user query
  117. $combinedSearch = new FreshRSS_BooleanSearch('');
  118. $combinedSearch->add(FreshRSS_Context::$search);
  119. $combinedSearch->add($userSearch);
  120. FreshRSS_Context::$search = $combinedSearch;
  121. }
  122. }
  123. $view->entries = FreshRSS_index_Controller::listEntriesByContext();
  124. if (!$view->entries->valid()) { // Init the generator to consume the aggregated search and catch potential exceptions
  125. $view->entries = new EmptyIterator();
  126. }
  127. Minz_Request::_param('search', $userSearch->toString()); // Restore user search for display and exports
  128. FreshRSS_Context::$search = $userSearch; // Restore user search for display and exports
  129. } catch (Minz_Exception) {
  130. Minz_Error::error(400, 'Bad user query!');
  131. die();
  132. }
  133. $get = FreshRSS_Context::currentGet(true);
  134. $type = (string)$get[0];
  135. $id = (int)$get[1];
  136. switch ($type) {
  137. case 'c': // Category
  138. $cat = FreshRSS_Context::categories()[$id] ?? null;
  139. if ($cat === null) {
  140. Minz_Error::error(404, "Category {$id} not found!");
  141. die();
  142. }
  143. $view->categories = [$cat->id() => $cat];
  144. break;
  145. case 'f': // Feed
  146. $feed = FreshRSS_Category::findFeed(FreshRSS_Context::categories(), $id);
  147. if ($feed === null) {
  148. Minz_Error::error(404, "Feed {$id} not found!");
  149. die();
  150. }
  151. $view->feeds = [$id => $feed];
  152. $view->categories = [];
  153. break;
  154. default:
  155. $view->categories = FreshRSS_Context::categories();
  156. break;
  157. }
  158. $view->disable_aside = true;
  159. $view->excludeMutedFeeds = true;
  160. $view->internal_rendering = true;
  161. $view->userQuery = $query;
  162. $view->html_url = $query->sharedUrlHtml();
  163. $view->rss_url = $query->sharedUrlRss();
  164. $view->rss_title = $query->getName();
  165. $view->image_url = $query->getImageUrl();
  166. $view->description = $query->getDescription() ?: _t('index.feed.rss_of', $view->rss_title);
  167. $view->includeUserLabels = $query->includeUserLabels();
  168. $view->excludeArticleTags = $query->excludeArticleTags();
  169. $view->userLabelPrefix = $query->userLabelPrefix();
  170. $view->entryIdsTagNames = [];
  171. if ($view->includeUserLabels && in_array($format, ['rss', 'atom'], true)) {
  172. $entries = iterator_to_array($view->entries, preserve_keys: false); // TODO: Optimise: avoid iterator_to_array if possible
  173. $view->entries = $entries;
  174. if (!empty($entries)) {
  175. $tagDAO = FreshRSS_Factory::createTagDao();
  176. $view->entryIdsTagNames = $tagDAO->getEntryIdsTagNames($entries);
  177. }
  178. }
  179. if ($query->getName() != '') {
  180. FreshRSS_View::_title($query->getName());
  181. }
  182. FreshRSS_Context::systemConf()->allow_anonymous = true;
  183. header('Access-Control-Allow-Methods: GET');
  184. header('Access-Control-Allow-Origin: *');
  185. header('Access-Control-Max-Age: 600');
  186. header('Cache-Control: public, max-age=60');
  187. if (($_SERVER['REQUEST_METHOD'] ?? '') === 'OPTIONS') {
  188. header('HTTP/1.1 204 No Content');
  189. exit();
  190. }
  191. if (in_array($format, ['rss', 'atom'], true)) {
  192. header('Content-Type: application/rss+xml; charset=utf-8');
  193. header("Content-Security-Policy: default-src 'none'; sandbox; frame-ancestors " .
  194. (FreshRSS_Context::systemConf()->attributeString('csp.frame-ancestors') ?? "'none'"));
  195. $view->_layout(null);
  196. $view->_path('index/rss.phtml');
  197. } elseif (in_array($format, ['greader', 'json'], true)) {
  198. header('Content-Type: application/json; charset=utf-8');
  199. header("Content-Security-Policy: default-src 'none'; sandbox; frame-ancestors " .
  200. (FreshRSS_Context::systemConf()->attributeString('csp.frame-ancestors') ?? "'none'"));
  201. $view->_layout(null);
  202. $view->type = 'query/' . $token;
  203. $view->list_title = $query->getName();
  204. $view->entryIdsTagNames = []; // Do not export user labels for privacy
  205. $view->_path('helpers/export/articles.phtml');
  206. } elseif ($format === 'opml') {
  207. if (!$query->safeForOpml()) {
  208. Minz_Error::error(404, 'OPML not allowed for this user query!');
  209. die();
  210. }
  211. header('Content-Type: application/xml; charset=utf-8');
  212. header("Content-Security-Policy: default-src 'none'; sandbox; frame-ancestors " .
  213. (FreshRSS_Context::systemConf()->attributeString('csp.frame-ancestors') ?? "'none'"));
  214. $view->_layout(null);
  215. $view->_path('index/opml.phtml');
  216. } else {
  217. header("Content-Security-Policy: default-src 'self'; frame-src *; img-src * data:; media-src *; frame-ancestors " .
  218. (FreshRSS_Context::systemConf()->attributeString('csp.frame-ancestors') ?? "'none'"));
  219. $view->_layout('layout');
  220. $view->_path('index/html.phtml');
  221. }
  222. $view->build();