feedController.php 39 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162
  1. <?php
  2. declare(strict_types=1);
  3. /**
  4. * Controller to handle every feed actions.
  5. */
  6. class FreshRSS_feed_Controller extends FreshRSS_ActionController {
  7. /**
  8. * This action is called before every other action in that class. It is
  9. * the common boiler plate for every action. It is triggered by the
  10. * underlying framework.
  11. */
  12. public function firstAction(): void {
  13. if (!FreshRSS_Auth::hasAccess()) {
  14. // Token is useful in the case that anonymous refresh is forbidden
  15. // and CRON task cannot be used with php command so the user can
  16. // set a CRON task to refresh his feeds by using token inside url
  17. $token = FreshRSS_Context::userConf()->token;
  18. $token_param = Minz_Request::paramString('token');
  19. $token_is_ok = ($token != '' && $token == $token_param);
  20. $action = Minz_Request::actionName();
  21. $allow_anonymous_refresh = FreshRSS_Context::systemConf()->allow_anonymous_refresh;
  22. if ($action !== 'actualize' ||
  23. !($allow_anonymous_refresh || $token_is_ok)) {
  24. Minz_Error::error(403);
  25. }
  26. }
  27. }
  28. /**
  29. * @param array<string,mixed> $attributes
  30. * @throws FreshRSS_AlreadySubscribed_Exception
  31. * @throws FreshRSS_BadUrl_Exception
  32. * @throws FreshRSS_Feed_Exception
  33. * @throws FreshRSS_FeedNotAdded_Exception
  34. * @throws Minz_FileNotExistException
  35. */
  36. public static function addFeed(string $url, string $title = '', int $cat_id = 0, string $new_cat_name = '',
  37. string $http_auth = '', array $attributes = [], int $kind = FreshRSS_Feed::KIND_RSS): FreshRSS_Feed {
  38. FreshRSS_UserDAO::touch();
  39. if (function_exists('set_time_limit')) {
  40. @set_time_limit(300);
  41. }
  42. $catDAO = FreshRSS_Factory::createCategoryDao();
  43. $url = trim($url);
  44. /** @var string|null $urlHooked */
  45. $urlHooked = Minz_ExtensionManager::callHook('check_url_before_add', $url);
  46. if ($urlHooked === null) {
  47. throw new FreshRSS_FeedNotAdded_Exception($url);
  48. }
  49. $url = $urlHooked;
  50. $cat = null;
  51. if ($cat_id > 0) {
  52. $cat = $catDAO->searchById($cat_id);
  53. }
  54. if ($cat === null && $new_cat_name != '') {
  55. $new_cat_id = $catDAO->addCategory(['name' => $new_cat_name]);
  56. $cat_id = $new_cat_id > 0 ? $new_cat_id : $cat_id;
  57. $cat = $catDAO->searchById($cat_id);
  58. }
  59. if ($cat === null) {
  60. $catDAO->checkDefault();
  61. }
  62. $feed = new FreshRSS_Feed($url); //Throws FreshRSS_BadUrl_Exception
  63. $title = trim($title);
  64. if ($title !== '') {
  65. $feed->_name($title);
  66. }
  67. $feed->_kind($kind);
  68. $feed->_attributes($attributes);
  69. $feed->_httpAuth($http_auth);
  70. if ($cat === null) {
  71. $feed->_categoryId(FreshRSS_CategoryDAO::DEFAULTCATEGORYID);
  72. } else {
  73. $feed->_category($cat);
  74. }
  75. switch ($kind) {
  76. case FreshRSS_Feed::KIND_RSS:
  77. case FreshRSS_Feed::KIND_RSS_FORCED:
  78. $feed->load(true); //Throws FreshRSS_Feed_Exception, Minz_FileNotExistException
  79. break;
  80. case FreshRSS_Feed::KIND_HTML_XPATH:
  81. case FreshRSS_Feed::KIND_XML_XPATH:
  82. $feed->_website($url);
  83. break;
  84. }
  85. $feedDAO = FreshRSS_Factory::createFeedDao();
  86. if ($feedDAO->searchByUrl($feed->url())) {
  87. throw new FreshRSS_AlreadySubscribed_Exception($url, $feed->name());
  88. }
  89. /** @var FreshRSS_Feed|null $feed */
  90. $feed = Minz_ExtensionManager::callHook('feed_before_insert', $feed);
  91. if ($feed === null) {
  92. throw new FreshRSS_FeedNotAdded_Exception($url);
  93. }
  94. $id = $feedDAO->addFeedObject($feed);
  95. if (!$id) {
  96. // There was an error in database… we cannot say what here.
  97. throw new FreshRSS_FeedNotAdded_Exception($url);
  98. }
  99. $feed->_id($id);
  100. // Ok, feed has been added in database. Now we have to refresh entries.
  101. [, , $nb_new_articles] = self::actualizeFeeds($id, $url);
  102. if ($nb_new_articles > 0) {
  103. self::commitNewEntries();
  104. }
  105. return $feed;
  106. }
  107. /**
  108. * This action subscribes to a feed.
  109. *
  110. * It can be reached by both GET and POST requests.
  111. *
  112. * GET request displays a form to add and configure a feed.
  113. * Request parameter is:
  114. * - url_rss (default: false)
  115. *
  116. * POST request adds a feed in database.
  117. * Parameters are:
  118. * - url_rss (default: false)
  119. * - category (default: false)
  120. * - http_user (default: false)
  121. * - http_pass (default: false)
  122. * It tries to get website information from RSS feed.
  123. * If no category is given, feed is added to the default one.
  124. *
  125. * If url_rss is false, nothing happened.
  126. */
  127. public function addAction(): void {
  128. $url = Minz_Request::paramString('url_rss');
  129. if ($url === '') {
  130. // No url, do nothing
  131. Minz_Request::forward([
  132. 'c' => 'subscription',
  133. 'a' => 'index',
  134. ], true);
  135. }
  136. $feedDAO = FreshRSS_Factory::createFeedDao();
  137. $url_redirect = [
  138. 'c' => 'subscription',
  139. 'a' => 'add',
  140. 'params' => [],
  141. ];
  142. $limits = FreshRSS_Context::systemConf()->limits;
  143. $this->view->feeds = $feedDAO->listFeeds();
  144. if (count($this->view->feeds) >= $limits['max_feeds']) {
  145. Minz_Request::bad(_t('feedback.sub.feed.over_max', $limits['max_feeds']), $url_redirect);
  146. }
  147. if (Minz_Request::isPost()) {
  148. $cat = Minz_Request::paramInt('category');
  149. // HTTP information are useful if feed is protected behind a
  150. // HTTP authentication
  151. $user = Minz_Request::paramString('http_user');
  152. $pass = Minz_Request::paramString('http_pass');
  153. $http_auth = '';
  154. if ($user != '' && $pass != '') { //TODO: Sanitize
  155. $http_auth = $user . ':' . $pass;
  156. }
  157. $cookie = Minz_Request::paramString('curl_params_cookie');
  158. $cookie_file = Minz_Request::paramBoolean('curl_params_cookiefile');
  159. $max_redirs = Minz_Request::paramInt('curl_params_redirects');
  160. $useragent = Minz_Request::paramString('curl_params_useragent');
  161. $proxy_address = Minz_Request::paramString('curl_params');
  162. $proxy_type = Minz_Request::paramString('proxy_type');
  163. $request_method = Minz_Request::paramString('curl_method');
  164. $request_fields = Minz_Request::paramString('curl_fields', true);
  165. $opts = [];
  166. if ($proxy_type !== '') {
  167. $opts[CURLOPT_PROXY] = $proxy_address;
  168. $opts[CURLOPT_PROXYTYPE] = (int)$proxy_type;
  169. }
  170. if ($cookie !== '') {
  171. $opts[CURLOPT_COOKIE] = $cookie;
  172. }
  173. if ($cookie_file) {
  174. // Pass empty cookie file name to enable the libcurl cookie engine
  175. // without reading any existing cookie data.
  176. $opts[CURLOPT_COOKIEFILE] = '';
  177. }
  178. if ($max_redirs !== 0) {
  179. $opts[CURLOPT_MAXREDIRS] = $max_redirs;
  180. $opts[CURLOPT_FOLLOWLOCATION] = 1;
  181. }
  182. if ($useragent !== '') {
  183. $opts[CURLOPT_USERAGENT] = $useragent;
  184. }
  185. if ($request_method === 'POST') {
  186. $opts[CURLOPT_POST] = true;
  187. if ($request_fields !== '') {
  188. $opts[CURLOPT_POSTFIELDS] = $request_fields;
  189. if (json_decode($request_fields, true) !== null) {
  190. $opts[CURLOPT_HTTPHEADER] = ['Content-Type: application/json'];
  191. }
  192. }
  193. }
  194. $attributes = [
  195. 'curl_params' => empty($opts) ? null : $opts,
  196. ];
  197. $attributes['ssl_verify'] = Minz_Request::paramTernary('ssl_verify');
  198. $timeout = Minz_Request::paramInt('timeout');
  199. $attributes['timeout'] = $timeout > 0 ? $timeout : null;
  200. $feed_kind = Minz_Request::paramInt('feed_kind') ?: FreshRSS_Feed::KIND_RSS;
  201. if ($feed_kind === FreshRSS_Feed::KIND_HTML_XPATH || $feed_kind === FreshRSS_Feed::KIND_XML_XPATH) {
  202. $xPathSettings = [];
  203. if (Minz_Request::paramString('xPathFeedTitle') !== '') {
  204. $xPathSettings['feedTitle'] = Minz_Request::paramString('xPathFeedTitle', true);
  205. }
  206. if (Minz_Request::paramString('xPathItem') !== '') {
  207. $xPathSettings['item'] = Minz_Request::paramString('xPathItem', true);
  208. }
  209. if (Minz_Request::paramString('xPathItemTitle') !== '') {
  210. $xPathSettings['itemTitle'] = Minz_Request::paramString('xPathItemTitle', true);
  211. }
  212. if (Minz_Request::paramString('xPathItemContent') !== '') {
  213. $xPathSettings['itemContent'] = Minz_Request::paramString('xPathItemContent', true);
  214. }
  215. if (Minz_Request::paramString('xPathItemUri') !== '') {
  216. $xPathSettings['itemUri'] = Minz_Request::paramString('xPathItemUri', true);
  217. }
  218. if (Minz_Request::paramString('xPathItemAuthor') !== '') {
  219. $xPathSettings['itemAuthor'] = Minz_Request::paramString('xPathItemAuthor', true);
  220. }
  221. if (Minz_Request::paramString('xPathItemTimestamp') !== '') {
  222. $xPathSettings['itemTimestamp'] = Minz_Request::paramString('xPathItemTimestamp', true);
  223. }
  224. if (Minz_Request::paramString('xPathItemTimeFormat') !== '') {
  225. $xPathSettings['itemTimeFormat'] = Minz_Request::paramString('xPathItemTimeFormat', true);
  226. }
  227. if (Minz_Request::paramString('xPathItemThumbnail') !== '') {
  228. $xPathSettings['itemThumbnail'] = Minz_Request::paramString('xPathItemThumbnail', true);
  229. }
  230. if (Minz_Request::paramString('xPathItemCategories') !== '') {
  231. $xPathSettings['itemCategories'] = Minz_Request::paramString('xPathItemCategories', true);
  232. }
  233. if (Minz_Request::paramString('xPathItemUid') !== '') {
  234. $xPathSettings['itemUid'] = Minz_Request::paramString('xPathItemUid', true);
  235. }
  236. if (!empty($xPathSettings)) {
  237. $attributes['xpath'] = $xPathSettings;
  238. }
  239. } elseif ($feed_kind === FreshRSS_Feed::KIND_JSON_DOTPATH) {
  240. $jsonSettings = [];
  241. if (Minz_Request::paramString('jsonFeedTitle') !== '') {
  242. $jsonSettings['feedTitle'] = Minz_Request::paramString('jsonFeedTitle', true);
  243. }
  244. if (Minz_Request::paramString('jsonItem') !== '') {
  245. $jsonSettings['item'] = Minz_Request::paramString('jsonItem', true);
  246. }
  247. if (Minz_Request::paramString('jsonItemTitle') !== '') {
  248. $jsonSettings['itemTitle'] = Minz_Request::paramString('jsonItemTitle', true);
  249. }
  250. if (Minz_Request::paramString('jsonItemContent') !== '') {
  251. $jsonSettings['itemContent'] = Minz_Request::paramString('jsonItemContent', true);
  252. }
  253. if (Minz_Request::paramString('jsonItemUri') !== '') {
  254. $jsonSettings['itemUri'] = Minz_Request::paramString('jsonItemUri', true);
  255. }
  256. if (Minz_Request::paramString('jsonItemAuthor') !== '') {
  257. $jsonSettings['itemAuthor'] = Minz_Request::paramString('jsonItemAuthor', true);
  258. }
  259. if (Minz_Request::paramString('jsonItemTimestamp') !== '') {
  260. $jsonSettings['itemTimestamp'] = Minz_Request::paramString('jsonItemTimestamp', true);
  261. }
  262. if (Minz_Request::paramString('jsonItemTimeFormat') !== '') {
  263. $jsonSettings['itemTimeFormat'] = Minz_Request::paramString('jsonItemTimeFormat', true);
  264. }
  265. if (Minz_Request::paramString('jsonItemThumbnail') !== '') {
  266. $jsonSettings['itemThumbnail'] = Minz_Request::paramString('jsonItemThumbnail', true);
  267. }
  268. if (Minz_Request::paramString('jsonItemCategories') !== '') {
  269. $jsonSettings['itemCategories'] = Minz_Request::paramString('jsonItemCategories', true);
  270. }
  271. if (Minz_Request::paramString('jsonItemUid') !== '') {
  272. $jsonSettings['itemUid'] = Minz_Request::paramString('jsonItemUid', true);
  273. }
  274. if (!empty($jsonSettings)) {
  275. $attributes['json_dotpath'] = $jsonSettings;
  276. }
  277. }
  278. try {
  279. $feed = self::addFeed($url, '', $cat, '', $http_auth, $attributes, $feed_kind);
  280. } catch (FreshRSS_BadUrl_Exception $e) {
  281. // Given url was not a valid url!
  282. Minz_Log::warning($e->getMessage());
  283. Minz_Request::bad(_t('feedback.sub.feed.invalid_url', $url), $url_redirect);
  284. return;
  285. } catch (FreshRSS_Feed_Exception $e) {
  286. // Something went bad (timeout, server not found, etc.)
  287. Minz_Log::warning($e->getMessage());
  288. Minz_Request::bad(_t('feedback.sub.feed.internal_problem', _url('index', 'logs')), $url_redirect);
  289. return;
  290. } catch (Minz_FileNotExistException $e) {
  291. // Cache directory doesn’t exist!
  292. Minz_Log::error($e->getMessage());
  293. Minz_Request::bad(_t('feedback.sub.feed.internal_problem', _url('index', 'logs')), $url_redirect);
  294. return;
  295. } catch (FreshRSS_AlreadySubscribed_Exception $e) {
  296. Minz_Request::bad(_t('feedback.sub.feed.already_subscribed', $e->feedName()), $url_redirect);
  297. return;
  298. } catch (FreshRSS_FeedNotAdded_Exception $e) {
  299. Minz_Request::bad(_t('feedback.sub.feed.not_added', $e->url()), $url_redirect);
  300. return;
  301. }
  302. // Entries are in DB, we redirect to feed configuration page.
  303. $url_redirect['a'] = 'feed';
  304. $url_redirect['params']['id'] = '' . $feed->id();
  305. Minz_Request::good(_t('feedback.sub.feed.added', $feed->name()), $url_redirect);
  306. } else {
  307. // GET request: we must ask confirmation to user before adding feed.
  308. FreshRSS_View::prependTitle(_t('sub.feed.title_add') . ' · ');
  309. $catDAO = FreshRSS_Factory::createCategoryDao();
  310. $this->view->categories = $catDAO->listCategories(false) ?: [];
  311. $this->view->feed = new FreshRSS_Feed($url);
  312. try {
  313. // We try to get more information about the feed.
  314. $this->view->feed->load(true);
  315. $this->view->load_ok = true;
  316. } catch (Exception $e) {
  317. $this->view->load_ok = false;
  318. }
  319. $feed = $feedDAO->searchByUrl($this->view->feed->url());
  320. if ($feed) {
  321. // Already subscribe so we redirect to the feed configuration page.
  322. $url_redirect['a'] = 'feed';
  323. $url_redirect['params']['id'] = $feed->id();
  324. Minz_Request::good(_t('feedback.sub.feed.already_subscribed', $feed->name()), $url_redirect);
  325. }
  326. }
  327. }
  328. /**
  329. * This action remove entries from a given feed.
  330. *
  331. * It should be reached by a POST action.
  332. *
  333. * Parameter is:
  334. * - id (default: false)
  335. */
  336. public function truncateAction(): void {
  337. $id = Minz_Request::paramInt('id');
  338. $url_redirect = [
  339. 'c' => 'subscription',
  340. 'a' => 'index',
  341. 'params' => ['id' => $id],
  342. ];
  343. if (!Minz_Request::isPost()) {
  344. Minz_Request::forward($url_redirect, true);
  345. }
  346. $feedDAO = FreshRSS_Factory::createFeedDao();
  347. $n = $feedDAO->truncate($id);
  348. invalidateHttpCache();
  349. if ($n === false) {
  350. Minz_Request::bad(_t('feedback.sub.feed.error'), $url_redirect);
  351. } else {
  352. Minz_Request::good(_t('feedback.sub.feed.n_entries_deleted', $n), $url_redirect);
  353. }
  354. }
  355. /**
  356. * @return array{0:int,1:FreshRSS_Feed|null,2:int} Number of updated feeds, first feed or null, number of new articles
  357. * @throws FreshRSS_BadUrl_Exception
  358. */
  359. public static function actualizeFeeds(?int $feed_id = null, ?string $feed_url = null, ?int $maxFeeds = null, ?SimplePie $simplePiePush = null): array {
  360. if (function_exists('set_time_limit')) {
  361. @set_time_limit(300);
  362. }
  363. if (!is_int($feed_id) || $feed_id <= 0) {
  364. $feed_id = null;
  365. }
  366. if (!is_string($feed_url) || trim($feed_url) === '') {
  367. $feed_url = null;
  368. }
  369. if (!is_int($maxFeeds) || $maxFeeds <= 0) {
  370. $maxFeeds = PHP_INT_MAX;
  371. }
  372. $feedDAO = FreshRSS_Factory::createFeedDao();
  373. $entryDAO = FreshRSS_Factory::createEntryDao();
  374. // Create a list of feeds to actualize.
  375. $feeds = [];
  376. if ($feed_id !== null || $feed_url !== null) {
  377. $feed = $feed_id !== null ? $feedDAO->searchById($feed_id) : $feedDAO->searchByUrl($feed_url);
  378. if ($feed !== null && $feed->id() > 0) {
  379. $feeds[] = $feed;
  380. $feed_id = $feed->id();
  381. }
  382. } else {
  383. $feeds = $feedDAO->listFeedsOrderUpdate(-1);
  384. // Hydrate category for each feed to avoid that each feed has to make an SQL request
  385. $categories = [];
  386. $catDAO = FreshRSS_Factory::createCategoryDao();
  387. foreach ($catDAO->listCategories(false, false) as $category) {
  388. $categories[$category->id()] = $category;
  389. }
  390. foreach ($feeds as $feed) {
  391. $category = $categories[$feed->categoryId()] ?? null;
  392. if ($category !== null) {
  393. $feed->_category($category);
  394. }
  395. }
  396. }
  397. // WebSub (PubSubHubbub) support
  398. $pubsubhubbubEnabledGeneral = FreshRSS_Context::systemConf()->pubsubhubbub_enabled;
  399. $pshbMinAge = time() - (3600 * 24); //TODO: Make a configuration.
  400. $updated_feeds = 0;
  401. $nb_new_articles = 0;
  402. foreach ($feeds as $feed) {
  403. /** @var FreshRSS_Feed|null $feed */
  404. $feed = Minz_ExtensionManager::callHook('feed_before_actualize', $feed);
  405. if (null === $feed) {
  406. continue;
  407. }
  408. $url = $feed->url(); //For detection of HTTP 301
  409. $pubSubHubbubEnabled = $pubsubhubbubEnabledGeneral && $feed->pubSubHubbubEnabled();
  410. if ($simplePiePush === null && $feed_id === null && $pubSubHubbubEnabled && ($feed->lastUpdate() > $pshbMinAge)) {
  411. //$text = 'Skip pull of feed using PubSubHubbub: ' . $url;
  412. //Minz_Log::debug($text);
  413. //Minz_Log::debug($text, PSHB_LOG);
  414. continue; //When PubSubHubbub is used, do not pull refresh so often
  415. }
  416. if ($feed->mute()) {
  417. continue; //Feed refresh is disabled
  418. }
  419. $mtime = $feed->cacheModifiedTime() ?: 0;
  420. $ttl = $feed->ttl();
  421. if ($ttl === FreshRSS_Feed::TTL_DEFAULT) {
  422. $ttl = FreshRSS_Context::userConf()->ttl_default;
  423. }
  424. if ($simplePiePush === null && $feed_id === null && (time() <= $feed->lastUpdate() + $ttl)) {
  425. //Too early to refresh from source, but check whether the feed was updated by another user
  426. $ε = 10; // negligible offset errors in seconds
  427. if ($mtime <= 0 ||
  428. $feed->lastUpdate() + $ε >= $mtime ||
  429. time() + $ε >= $mtime + FreshRSS_Context::systemConf()->limits['cache_duration']) { // is cache still valid?
  430. continue; //Nothing newer from other users
  431. }
  432. Minz_Log::debug('Feed ' . $feed->url(false) . ' was updated at ' . date('c', $feed->lastUpdate()) .
  433. ', and at ' . date('c', $mtime) . ' by another user; take advantage of newer cache.');
  434. }
  435. if (!$feed->lock()) {
  436. Minz_Log::notice('Feed already being actualized: ' . $feed->url(false));
  437. continue;
  438. }
  439. $feedIsNew = $feed->lastUpdate() <= 0;
  440. $feedIsEmpty = false;
  441. $feedIsUnchanged = false;
  442. try {
  443. if ($simplePiePush !== null) {
  444. $simplePie = $simplePiePush; //Used by WebSub
  445. } elseif ($feed->kind() === FreshRSS_Feed::KIND_HTML_XPATH) {
  446. $simplePie = $feed->loadHtmlXpath();
  447. if ($simplePie === null) {
  448. throw new FreshRSS_Feed_Exception('HTML+XPath Web scraping failed for [' . $feed->url(false) . ']');
  449. }
  450. } elseif ($feed->kind() === FreshRSS_Feed::KIND_XML_XPATH) {
  451. $simplePie = $feed->loadHtmlXpath();
  452. if ($simplePie === null) {
  453. throw new FreshRSS_Feed_Exception('XML+XPath parsing failed for [' . $feed->url(false) . ']');
  454. }
  455. } elseif ($feed->kind() === FreshRSS_Feed::KIND_JSON_DOTPATH) {
  456. $simplePie = $feed->loadJson();
  457. if ($simplePie === null) {
  458. throw new FreshRSS_Feed_Exception('JSON dotpath parsing failed for [' . $feed->url(false) . ']');
  459. }
  460. } elseif ($feed->kind() === FreshRSS_Feed::KIND_JSONFEED) {
  461. $simplePie = $feed->loadJson();
  462. if ($simplePie === null) {
  463. throw new FreshRSS_Feed_Exception('JSON Feed parsing failed for [' . $feed->url(false) . ']');
  464. }
  465. } else {
  466. $simplePie = $feed->load(false, $feedIsNew);
  467. }
  468. if ($simplePie === null) {
  469. // Feed is cached and unchanged
  470. $newGuids = [];
  471. $entries = [];
  472. $feedIsEmpty = false; // We do not know
  473. $feedIsUnchanged = true;
  474. } else {
  475. $newGuids = $feed->loadGuids($simplePie);
  476. $entries = $feed->loadEntries($simplePie);
  477. $feedIsEmpty = $simplePiePush !== null && empty($newGuids);
  478. $feedIsUnchanged = false;
  479. }
  480. $mtime = $feed->cacheModifiedTime() ?: time();
  481. } catch (FreshRSS_Feed_Exception $e) {
  482. Minz_Log::warning($e->getMessage());
  483. $feedDAO->updateLastUpdate($feed->id(), true);
  484. if ($e->getCode() === 410) {
  485. // HTTP 410 Gone
  486. Minz_Log::warning('Muting gone feed: ' . $feed->url(false));
  487. $feedDAO->mute($feed->id(), true);
  488. }
  489. $feed->unlock();
  490. continue;
  491. }
  492. $needFeedCacheRefresh = false;
  493. $nbMarkedUnread = 0;
  494. if (count($newGuids) > 0) {
  495. if ($feed->attributeBoolean('read_when_same_title_in_feed') === null) {
  496. $readWhenSameTitleInFeed = (int)FreshRSS_Context::userConf()->mark_when['same_title_in_feed'];
  497. } elseif ($feed->attributeBoolean('read_when_same_title_in_feed') === false) {
  498. $readWhenSameTitleInFeed = 0;
  499. } else {
  500. $readWhenSameTitleInFeed = $feed->attributeInt('read_when_same_title_in_feed') ?? 0;
  501. }
  502. if ($readWhenSameTitleInFeed > 0) {
  503. $titlesAsRead = array_flip($feedDAO->listTitles($feed->id(), $readWhenSameTitleInFeed));
  504. } else {
  505. $titlesAsRead = [];
  506. }
  507. $mark_updated_article_unread = $feed->attributeBoolean('mark_updated_article_unread') ?? FreshRSS_Context::userConf()->mark_updated_article_unread;
  508. // For this feed, check existing GUIDs already in database.
  509. $existingHashForGuids = $entryDAO->listHashForFeedGuids($feed->id(), $newGuids) ?: [];
  510. /** @var array<string,bool> $newGuids */
  511. $newGuids = [];
  512. // Add entries in database if possible.
  513. /** @var FreshRSS_Entry $entry */
  514. foreach ($entries as $entry) {
  515. if (isset($newGuids[$entry->guid()])) {
  516. continue; //Skip subsequent articles with same GUID
  517. }
  518. $newGuids[$entry->guid()] = true;
  519. $entry->_lastSeen($mtime);
  520. if (isset($existingHashForGuids[$entry->guid()])) {
  521. $existingHash = $existingHashForGuids[$entry->guid()];
  522. if (strcasecmp($existingHash, $entry->hash()) !== 0) {
  523. //This entry already exists but has been updated
  524. //Minz_Log::debug('Entry with GUID `' . $entry->guid() . '` updated in feed ' . $feed->url(false) .
  525. //', old hash ' . $existingHash . ', new hash ' . $entry->hash());
  526. $entry->_isFavorite(null); // Do not change favourite state
  527. $entry->_isRead($mark_updated_article_unread ? false : null); //Change is_read according to policy.
  528. if ($mark_updated_article_unread) {
  529. Minz_ExtensionManager::callHook('entry_auto_unread', $entry, 'updated_article');
  530. }
  531. $entry = Minz_ExtensionManager::callHook('entry_before_insert', $entry);
  532. if (!($entry instanceof FreshRSS_Entry)) {
  533. // An extension has returned a null value, there is nothing to insert.
  534. continue;
  535. }
  536. if (!$entry->isRead()) {
  537. $needFeedCacheRefresh = true; //Maybe
  538. $nbMarkedUnread++;
  539. }
  540. // If the entry has changed, there is a good chance for the full content to have changed as well.
  541. $entry->loadCompleteContent(true);
  542. if (!$entryDAO->inTransaction()) {
  543. $entryDAO->beginTransaction();
  544. }
  545. $entryDAO->updateEntry($entry->toArray());
  546. }
  547. } else {
  548. $id = uTimeString();
  549. $entry->_id($id);
  550. $entry = Minz_ExtensionManager::callHook('entry_before_insert', $entry);
  551. if (!($entry instanceof FreshRSS_Entry)) {
  552. // An extension has returned a null value, there is nothing to insert.
  553. continue;
  554. }
  555. $entry->applyFilterActions($titlesAsRead);
  556. if ($readWhenSameTitleInFeed > 0) {
  557. $titlesAsRead[$entry->title()] = true;
  558. }
  559. if ($pubSubHubbubEnabled && !$simplePiePush) { //We use push, but have discovered an article by pull!
  560. $text = 'An article was discovered by pull although we use PubSubHubbub!: Feed ' .
  561. SimplePie_Misc::url_remove_credentials($url) .
  562. ' GUID ' . $entry->guid();
  563. Minz_Log::warning($text, PSHB_LOG);
  564. Minz_Log::warning($text);
  565. $pubSubHubbubEnabled = false;
  566. $feed->pubSubHubbubError(true);
  567. }
  568. if (!$entryDAO->inTransaction()) {
  569. $entryDAO->beginTransaction();
  570. }
  571. $entryDAO->addEntry($entry->toArray(), true);
  572. $nb_new_articles++;
  573. }
  574. }
  575. // N.B.: Applies to _entry table and not _entrytmp:
  576. $entryDAO->updateLastSeen($feed->id(), array_keys($newGuids), $mtime);
  577. } elseif ($feedIsUnchanged) {
  578. // Feed cache was unchanged, so mark as seen the same entries as last time
  579. if (!$entryDAO->inTransaction()) {
  580. $entryDAO->beginTransaction();
  581. }
  582. $entryDAO->updateLastSeenUnchanged($feed->id(), $mtime);
  583. }
  584. unset($entries);
  585. if (rand(0, 30) === 1) { // Remove old entries once in 30.
  586. if (!$entryDAO->inTransaction()) {
  587. $entryDAO->beginTransaction();
  588. }
  589. $nb = $feed->cleanOldEntries();
  590. if ($nb > 0) {
  591. $needFeedCacheRefresh = true;
  592. }
  593. }
  594. $feedDAO->updateLastUpdate($feed->id(), false, $mtime);
  595. if ($feed->keepMaxUnread() !== null && ($feed->nbNotRead() + $nbMarkedUnread > $feed->keepMaxUnread())) {
  596. Minz_Log::debug('Existing unread entries (' . ($feed->nbNotRead() + $nbMarkedUnread) . ') exceeding max number of ' .
  597. $feed->keepMaxUnread() . ' for [' . $feed->url(false) . ']');
  598. $needFeedCacheRefresh |= ($feed->markAsReadMaxUnread() != false);
  599. }
  600. if ($simplePiePush === null) {
  601. // Do not call for WebSub events, as we do not know the list of articles still on the upstream feed.
  602. $needFeedCacheRefresh |= ($feed->markAsReadUponGone($feedIsEmpty, $mtime) != false);
  603. }
  604. if ($needFeedCacheRefresh) {
  605. $feedDAO->updateCachedValues($feed->id());
  606. }
  607. if ($entryDAO->inTransaction()) {
  608. $entryDAO->commit();
  609. }
  610. $feedProperties = [];
  611. if ($pubsubhubbubEnabledGeneral && $feed->hubUrl() && $feed->selfUrl()) { //selfUrl has priority for WebSub
  612. if ($feed->selfUrl() !== $url) { // https://github.com/pubsubhubbub/PubSubHubbub/wiki/Moving-Feeds-or-changing-Hubs
  613. $selfUrl = checkUrl($feed->selfUrl());
  614. if ($selfUrl) {
  615. Minz_Log::debug('WebSub unsubscribe ' . $feed->url(false));
  616. if (!$feed->pubSubHubbubSubscribe(false)) { //Unsubscribe
  617. Minz_Log::warning('Error while WebSub unsubscribing from ' . $feed->url(false));
  618. }
  619. $feed->_url($selfUrl, false);
  620. Minz_Log::notice('Feed ' . $url . ' canonical address moved to ' . $feed->url(false));
  621. $feedDAO->updateFeed($feed->id(), ['url' => $feed->url()]);
  622. }
  623. }
  624. } elseif ($feed->url() !== $url) { // HTTP 301 Moved Permanently
  625. Minz_Log::notice('Feed ' . SimplePie_Misc::url_remove_credentials($url) .
  626. ' moved permanently to ' . SimplePie_Misc::url_remove_credentials($feed->url(false)));
  627. $feedProperties['url'] = $feed->url();
  628. }
  629. if ($simplePie != null) {
  630. if ($feed->name(true) === '') {
  631. //HTML to HTML-PRE //ENT_COMPAT except '&'
  632. $name = strtr(html_only_entity_decode($simplePie->get_title()), ['<' => '&lt;', '>' => '&gt;', '"' => '&quot;']);
  633. $feed->_name($name);
  634. $feedProperties['name'] = $feed->name(false);
  635. }
  636. if (trim($feed->website()) === '') {
  637. $website = html_only_entity_decode($simplePie->get_link());
  638. $feed->_website($website == '' ? $feed->url() : $website);
  639. $feedProperties['website'] = $feed->website();
  640. $feed->faviconPrepare();
  641. }
  642. if (trim($feed->description()) === '') {
  643. $description = html_only_entity_decode($simplePie->get_description());
  644. if ($description !== '') {
  645. $feed->_description($description);
  646. $feedProperties['description'] = $feed->description();
  647. }
  648. }
  649. }
  650. if (!empty($feedProperties)) {
  651. $ok = $feedDAO->updateFeed($feed->id(), $feedProperties);
  652. if (!$ok && $feedIsNew) {
  653. //Cancel adding new feed in case of database error at first actualize
  654. $feedDAO->deleteFeed($feed->id());
  655. $feed->unlock();
  656. break;
  657. }
  658. }
  659. $feed->faviconPrepare();
  660. if ($pubsubhubbubEnabledGeneral && $feed->pubSubHubbubPrepare()) {
  661. Minz_Log::notice('WebSub subscribe ' . $feed->url(false));
  662. if (!$feed->pubSubHubbubSubscribe(true)) { //Subscribe
  663. Minz_Log::warning('Error while WebSub subscribing to ' . $feed->url(false));
  664. }
  665. }
  666. $feed->unlock();
  667. $updated_feeds++;
  668. unset($feed);
  669. gc_collect_cycles();
  670. if ($updated_feeds >= $maxFeeds) {
  671. break;
  672. }
  673. }
  674. return [$updated_feeds, reset($feeds) ?: null, $nb_new_articles];
  675. }
  676. /**
  677. * @param array<int,int> $newUnreadEntriesPerFeed
  678. * @return int|false The number of articles marked as read, of false if error
  679. */
  680. private static function keepMaxUnreads(array $newUnreadEntriesPerFeed) {
  681. $affected = 0;
  682. $feedDAO = FreshRSS_Factory::createFeedDao();
  683. $feeds = $feedDAO->listFeedsOrderUpdate(-1);
  684. foreach ($feeds as $feed) {
  685. if (!empty($newUnreadEntriesPerFeed[$feed->id()]) && $feed->keepMaxUnread() !== null &&
  686. ($feed->nbNotRead() + $newUnreadEntriesPerFeed[$feed->id()] > $feed->keepMaxUnread())) {
  687. Minz_Log::debug('New unread entries (' . ($feed->nbNotRead() + $newUnreadEntriesPerFeed[$feed->id()]) . ') exceeding max number of ' .
  688. $feed->keepMaxUnread() . ' for [' . $feed->url(false) . ']');
  689. $n = $feed->markAsReadMaxUnread();
  690. if ($n === false) {
  691. $affected = false;
  692. break;
  693. } else {
  694. $affected += $n;
  695. }
  696. }
  697. }
  698. if ($feedDAO->updateCachedValues() === false) {
  699. $affected = false;
  700. }
  701. return $affected;
  702. }
  703. /**
  704. * Auto-add labels to new articles.
  705. * @param int $nbNewEntries The number of top recent entries to process.
  706. * @return int|false The number of new labels added, or false in case of error.
  707. */
  708. private static function applyLabelActions(int $nbNewEntries) {
  709. $tagDAO = FreshRSS_Factory::createTagDao();
  710. $labels = FreshRSS_Context::labels();
  711. $labels = array_filter($labels, static function (FreshRSS_Tag $label) {
  712. return !empty($label->filtersAction('label'));
  713. });
  714. if (count($labels) <= 0) {
  715. return 0;
  716. }
  717. $entryDAO = FreshRSS_Factory::createEntryDao();
  718. /** @var array<array{id_tag:int,id_entry:string}> $applyLabels */
  719. $applyLabels = [];
  720. foreach (FreshRSS_Entry::fromTraversable($entryDAO->selectAll($nbNewEntries)) as $entry) {
  721. foreach ($labels as $label) {
  722. $label->applyFilterActions($entry, $applyLabel);
  723. if ($applyLabel) {
  724. $applyLabels[] = [
  725. 'id_tag' => $label->id(),
  726. 'id_entry' => $entry->id(),
  727. ];
  728. }
  729. }
  730. }
  731. return $tagDAO->tagEntries($applyLabels);
  732. }
  733. public static function commitNewEntries(): bool {
  734. $entryDAO = FreshRSS_Factory::createEntryDao();
  735. $newUnreadEntriesPerFeed = $entryDAO->newUnreadEntriesPerFeed();
  736. $nbNewEntries = array_sum($newUnreadEntriesPerFeed);
  737. if ($nbNewEntries > 0) {
  738. if (!$entryDAO->inTransaction()) {
  739. $entryDAO->beginTransaction();
  740. }
  741. if ($entryDAO->commitNewEntries()) {
  742. self::keepMaxUnreads($newUnreadEntriesPerFeed);
  743. self::applyLabelActions($nbNewEntries);
  744. }
  745. if ($entryDAO->inTransaction()) {
  746. $entryDAO->commit();
  747. }
  748. }
  749. $databaseDAO = FreshRSS_Factory::createDatabaseDAO();
  750. $databaseDAO->minorDbMaintenance();
  751. return true;
  752. }
  753. /**
  754. * This action actualizes entries from one or several feeds.
  755. *
  756. * Parameters are:
  757. * - id (default: null): Feed ID, or set to -1 to commit new articles to the main database
  758. * - url (default: null): Feed URL (instead of feed ID)
  759. * - maxFeeds (default: 10): Max number of feeds to refresh
  760. * - noCommit (default: 0): Set to 1 to prevent committing the new articles to the main database
  761. * If id and url are not specified, all the feeds are actualized, within the limits of maxFeeds.
  762. */
  763. public function actualizeAction(): int {
  764. Minz_Session::_param('actualize_feeds', false);
  765. $id = Minz_Request::paramInt('id');
  766. $url = Minz_Request::paramString('url');
  767. $maxFeeds = Minz_Request::paramInt('maxFeeds') ?: 10;
  768. $noCommit = ($_POST['noCommit'] ?? 0) == 1;
  769. if ($id === -1 && !$noCommit) { //Special request only to commit & refresh DB cache
  770. $updated_feeds = 0;
  771. $feed = null;
  772. self::commitNewEntries();
  773. } else {
  774. if ($id === 0 && $url === '') {
  775. FreshRSS_category_Controller::refreshDynamicOpmls();
  776. }
  777. [$updated_feeds, $feed, $nbNewArticles] = self::actualizeFeeds($id, $url, $maxFeeds);
  778. if (!$noCommit && $nbNewArticles > 0) {
  779. FreshRSS_feed_Controller::commitNewEntries();
  780. }
  781. }
  782. if (Minz_Request::paramBoolean('ajax')) {
  783. // Most of the time, ajax request is for only one feed. But since
  784. // there are several parallel requests, we should return that there
  785. // are several updated feeds.
  786. Minz_Request::setGoodNotification(_t('feedback.sub.feed.actualizeds'));
  787. // No layout in ajax request.
  788. $this->view->_layout(null);
  789. } elseif ($feed instanceof FreshRSS_Feed) {
  790. // Redirect to the main page with correct notification.
  791. Minz_Request::good(_t('feedback.sub.feed.actualized', $feed->name()), [
  792. 'params' => ['get' => 'f_' . $id]
  793. ]);
  794. } elseif ($updated_feeds >= 1) {
  795. Minz_Request::good(_t('feedback.sub.feed.n_actualized', $updated_feeds), []);
  796. } else {
  797. Minz_Request::good(_t('feedback.sub.feed.no_refresh'), []);
  798. }
  799. return $updated_feeds;
  800. }
  801. /**
  802. * @throws Minz_ConfigurationNamespaceException
  803. * @throws Minz_PDOConnectionException
  804. */
  805. public static function renameFeed(int $feed_id, string $feed_name): bool {
  806. if ($feed_id <= 0 || $feed_name === '') {
  807. return false;
  808. }
  809. FreshRSS_UserDAO::touch();
  810. $feedDAO = FreshRSS_Factory::createFeedDao();
  811. return $feedDAO->updateFeed($feed_id, ['name' => $feed_name]) === 1;
  812. }
  813. public static function moveFeed(int $feed_id, int $cat_id, string $new_cat_name = ''): bool {
  814. if ($feed_id <= 0 || ($cat_id <= 0 && $new_cat_name === '')) {
  815. return false;
  816. }
  817. FreshRSS_UserDAO::touch();
  818. $catDAO = FreshRSS_Factory::createCategoryDao();
  819. if ($cat_id > 0) {
  820. $cat = $catDAO->searchById($cat_id);
  821. $cat_id = $cat === null ? 0 : $cat->id();
  822. }
  823. if ($cat_id <= 1 && $new_cat_name != '') {
  824. $cat_id = $catDAO->addCategory(['name' => $new_cat_name]);
  825. }
  826. if ($cat_id <= 1) {
  827. $catDAO->checkDefault();
  828. $cat_id = FreshRSS_CategoryDAO::DEFAULTCATEGORYID;
  829. }
  830. $feedDAO = FreshRSS_Factory::createFeedDao();
  831. return $feedDAO->updateFeed($feed_id, ['category' => $cat_id]) === 1;
  832. }
  833. /**
  834. * This action changes the category of a feed.
  835. *
  836. * This page must be reached by a POST request.
  837. *
  838. * Parameters are:
  839. * - f_id (default: false)
  840. * - c_id (default: false)
  841. * If c_id is false, default category is used.
  842. *
  843. * @todo should handle order of the feed inside the category.
  844. */
  845. public function moveAction(): void {
  846. if (!Minz_Request::isPost()) {
  847. Minz_Request::forward(['c' => 'subscription'], true);
  848. }
  849. $feed_id = Minz_Request::paramInt('f_id');
  850. $cat_id = Minz_Request::paramInt('c_id');
  851. if (self::moveFeed($feed_id, $cat_id)) {
  852. // TODO: return something useful
  853. // Log a notice to prevent "Empty IF statement" warning in PHP_CodeSniffer
  854. Minz_Log::notice('Moved feed `' . $feed_id . '` in the category `' . $cat_id . '`');
  855. } else {
  856. Minz_Log::warning('Cannot move feed `' . $feed_id . '` in the category `' . $cat_id . '`');
  857. Minz_Error::error(404);
  858. }
  859. }
  860. public static function deleteFeed(int $feed_id): bool {
  861. FreshRSS_UserDAO::touch();
  862. $feedDAO = FreshRSS_Factory::createFeedDao();
  863. if ($feedDAO->deleteFeed($feed_id)) {
  864. // TODO: Delete old favicon
  865. // Remove related queries
  866. /** @var array<array{'get'?:string,'name'?:string,'order'?:string,'search'?:string,'state'?:int,'url'?:string}> $queries */
  867. $queries = remove_query_by_get('f_' . $feed_id, FreshRSS_Context::userConf()->queries);
  868. FreshRSS_Context::userConf()->queries = $queries;
  869. FreshRSS_Context::userConf()->save();
  870. return true;
  871. }
  872. return false;
  873. }
  874. /**
  875. * This action deletes a feed.
  876. *
  877. * This page must be reached by a POST request.
  878. * If there are related queries, they are deleted too.
  879. *
  880. * Parameters are:
  881. * - id (default: false)
  882. */
  883. public function deleteAction(): void {
  884. $from = Minz_Request::paramString('from');
  885. $id = Minz_Request::paramInt('id');
  886. switch ($from) {
  887. case 'stats':
  888. $redirect_url = ['c' => 'stats', 'a' => 'idle'];
  889. break;
  890. case 'normal':
  891. $get = Minz_Request::paramString('get');
  892. if ($get) {
  893. $redirect_url = ['c' => 'index', 'a' => 'normal', 'params' => ['get' => $get]];
  894. } else {
  895. $redirect_url = ['c' => 'index', 'a' => 'normal'];
  896. }
  897. break;
  898. default:
  899. $redirect_url = ['c' => 'subscription', 'a' => 'index'];
  900. if (!Minz_Request::isPost()) {
  901. Minz_Request::forward($redirect_url, true);
  902. }
  903. }
  904. if (self::deleteFeed($id)) {
  905. Minz_Request::good(_t('feedback.sub.feed.deleted'), $redirect_url);
  906. } else {
  907. Minz_Request::bad(_t('feedback.sub.feed.error'), $redirect_url);
  908. }
  909. }
  910. /**
  911. * This action force clears the cache of a feed.
  912. *
  913. * Parameters are:
  914. * - id (mandatory - no default): Feed ID
  915. *
  916. */
  917. public function clearCacheAction(): void {
  918. //Get Feed.
  919. $id = Minz_Request::paramInt('id');
  920. $feedDAO = FreshRSS_Factory::createFeedDao();
  921. $feed = $feedDAO->searchById($id);
  922. if ($feed === null) {
  923. Minz_Request::bad(_t('feedback.sub.feed.not_found'), []);
  924. return;
  925. }
  926. $feed->clearCache();
  927. Minz_Request::good(_t('feedback.sub.feed.cache_cleared', $feed->name()), [
  928. 'params' => ['get' => 'f_' . $feed->id()],
  929. ]);
  930. }
  931. /**
  932. * This action forces reloading the articles of a feed.
  933. *
  934. * Parameters are:
  935. * - id (mandatory - no default): Feed ID
  936. *
  937. * @throws FreshRSS_BadUrl_Exception
  938. */
  939. public function reloadAction(): void {
  940. if (function_exists('set_time_limit')) {
  941. @set_time_limit(300);
  942. }
  943. //Get Feed ID.
  944. $feed_id = Minz_Request::paramInt('id');
  945. $limit = Minz_Request::paramInt('reload_limit') ?: 10;
  946. $feedDAO = FreshRSS_Factory::createFeedDao();
  947. $entryDAO = FreshRSS_Factory::createEntryDao();
  948. $feed = $feedDAO->searchById($feed_id);
  949. if ($feed === null) {
  950. Minz_Request::bad(_t('feedback.sub.feed.not_found'), []);
  951. return;
  952. }
  953. //Re-fetch articles as if the feed was new.
  954. $feedDAO->updateFeed($feed->id(), [ 'lastUpdate' => 0 ]);
  955. [, , $nb_new_articles] = self::actualizeFeeds($feed_id);
  956. if ($nb_new_articles > 0) {
  957. FreshRSS_feed_Controller::commitNewEntries();
  958. }
  959. //Extract all feed entries from database, load complete content and store them back in database.
  960. $entries = $entryDAO->listWhere('f', $feed_id, FreshRSS_Entry::STATE_ALL, 'DESC', $limit);
  961. //We need another DB connection in parallel for unbuffered streaming
  962. Minz_ModelPdo::$usesSharedPdo = false;
  963. if (FreshRSS_Context::systemConf()->db['type'] === 'mysql') {
  964. // Second parallel connection for unbuffered streaming: MySQL
  965. $entryDAO2 = FreshRSS_Factory::createEntryDao();
  966. } else {
  967. // Single connection for buffered queries (in memory): SQLite, PostgreSQL
  968. //TODO: Consider an unbuffered query for PostgreSQL
  969. $entryDAO2 = $entryDAO;
  970. }
  971. foreach ($entries as $entry) {
  972. if ($entry->loadCompleteContent(true)) {
  973. $entryDAO2->updateEntry($entry->toArray());
  974. }
  975. }
  976. Minz_ModelPdo::$usesSharedPdo = true;
  977. //Give feedback to user.
  978. Minz_Request::good(_t('feedback.sub.feed.reloaded', $feed->name()), [
  979. 'params' => ['get' => 'f_' . $feed->id()]
  980. ]);
  981. }
  982. /**
  983. * This action creates a preview of a content-selector.
  984. *
  985. * Parameters are:
  986. * - id (mandatory - no default): Feed ID
  987. * - selector (mandatory - no default): Selector to preview
  988. *
  989. */
  990. public function contentSelectorPreviewAction(): void {
  991. //Configure.
  992. $this->view->fatalError = '';
  993. $this->view->selectorSuccess = false;
  994. $this->view->htmlContent = '';
  995. $this->view->_layout(null);
  996. $this->_csp([
  997. 'default-src' => "'self'",
  998. 'frame-src' => '*',
  999. 'img-src' => '* data:',
  1000. 'media-src' => '*',
  1001. ]);
  1002. //Get parameters.
  1003. $feed_id = Minz_Request::paramInt('id');
  1004. $content_selector = Minz_Request::paramString('selector');
  1005. if (!$content_selector) {
  1006. $this->view->fatalError = _t('feedback.sub.feed.selector_preview.selector_empty');
  1007. return;
  1008. }
  1009. //Check Feed ID validity.
  1010. $entryDAO = FreshRSS_Factory::createEntryDao();
  1011. $entries = $entryDAO->listWhere('f', $feed_id);
  1012. $entry = null;
  1013. //Get first entry (syntax robust for Generator or Array)
  1014. foreach ($entries as $myEntry) {
  1015. $entry = $myEntry;
  1016. }
  1017. if ($entry == null) {
  1018. $this->view->fatalError = _t('feedback.sub.feed.selector_preview.no_entries');
  1019. return;
  1020. }
  1021. //Get feed.
  1022. $feed = $entry->feed();
  1023. if ($feed === null) {
  1024. $this->view->fatalError = _t('feedback.sub.feed.selector_preview.no_feed');
  1025. return;
  1026. }
  1027. $attributes = $feed->attributes();
  1028. $attributes['path_entries_filter'] = Minz_Request::paramString('selector_filter', true);
  1029. //Fetch & select content.
  1030. try {
  1031. $fullContent = FreshRSS_Entry::getContentByParsing(
  1032. htmlspecialchars_decode($entry->link(), ENT_QUOTES),
  1033. htmlspecialchars_decode($content_selector, ENT_QUOTES),
  1034. $attributes
  1035. );
  1036. if ($fullContent != '') {
  1037. $this->view->selectorSuccess = true;
  1038. $this->view->htmlContent = $fullContent;
  1039. } else {
  1040. $this->view->selectorSuccess = false;
  1041. $this->view->htmlContent = $entry->content(false);
  1042. }
  1043. } catch (Exception $e) {
  1044. $this->view->fatalError = _t('feedback.sub.feed.selector_preview.http_error');
  1045. }
  1046. }
  1047. }