view->html_url = Minz_Url::display(['c' => 'index', 'a' => 'index'], 'html', 'root'); } /** * This action only redirect on the default view mode (normal or global) */ public function indexAction(): void { $preferred_output = FreshRSS_Context::userConf()->view_mode; $viewMode = FreshRSS_ViewMode::getAllModes()[$preferred_output] ?? null; // Fallback to 'normal' if the preferred mode was not found if ($viewMode === null) { Minz_Request::setBadNotification(_t('feedback.extensions.invalid_view_mode', $preferred_output)); $viewMode = FreshRSS_ViewMode::getAllModes()['normal']; } Minz_Request::forward([ 'c' => $viewMode->controller(), 'a' => $viewMode->action(), ]); } /** * @return '.future'|'.today'|'.yesterday'|'' */ private static function dayRelative(int $timestamp, bool $mayBeFuture): string { static $today = null; if (!is_int($today)) { $today = strtotime('today') ?: 0; } if ($today <= 0) { return ''; } elseif ($mayBeFuture && ($timestamp >= $today + 86400)) { return '.future'; } elseif ($timestamp >= $today) { return '.today'; } elseif ($timestamp >= $today - 86400) { return '.yesterday'; } return ''; } /** * Content for displaying a transition between entries when sorting by specific criteria. */ public static function transition(FreshRSS_Entry $entry): string { return match (FreshRSS_Context::$sort) { 'id' => _t('index.feed.received' . self::dayRelative($entry->dateAdded(raw: true), mayBeFuture: false)) . ' — ' . timestamptodate($entry->dateAdded(raw: true), hour: false), 'date' => _t('index.feed.published' . self::dayRelative($entry->date(raw: true), mayBeFuture: true)) . ' — ' . timestamptodate($entry->date(raw: true), hour: false), 'lastUserModified' => _t('index.feed.userModified' . self::dayRelative($entry->lastUserModified(), mayBeFuture: false)) . ' — ' . timestamptodate($entry->lastUserModified(), hour: false), 'c.name' => $entry->feed()?->category()?->name() ?? '', 'f.name' => $entry->feed()?->name() ?? '', default => '', }; } /** * Produce a hyperlink to the next transition of entries. */ public static function transitionLink(FreshRSS_Entry $entry, int $offset = 0): string { if (in_array(FreshRSS_Context::$sort, ['c.name', 'f.name'], true)) { return Minz_Url::display(Minz_Request::modifiedCurrentRequest([ 'get' => match (FreshRSS_Context::$sort) { 'c.name' => 'c_' . ($entry->feed()?->category()?->id() ?? '0'), 'f.name' => 'f_' . ($entry->feed()?->id() ?? '0'), }, ])); } $operator = match (FreshRSS_Context::$sort) { 'id' => 'date', 'date' => 'pubdate', 'lastUserModified' => 'userdate', default => throw new InvalidArgumentException('Unsupported sort criterion for transition: ' . FreshRSS_Context::$sort), }; $offset = FreshRSS_Context::$order === 'ASC' ? $offset : -$offset; $timestamp = match (FreshRSS_Context::$sort) { 'id' => $entry->dateAdded(raw: true), 'date' => $entry->date(raw: true), 'lastUserModified' => $entry->lastUserModified(), default => throw new InvalidArgumentException('Unsupported sort criterion for transition: ' . FreshRSS_Context::$sort), }; $searchString = $operator . ':' . ($offset < 0 ? '/' : '') . date('Y-m-d', $timestamp + ($offset * 86400)) . ($offset > 0 ? '/' : ''); return Minz_Url::display(Minz_Request::modifiedCurrentRequest([ 'search' => FreshRSS_Context::$search->__toString() === '' ? $searchString : FreshRSS_Context::$search->enforce(new FreshRSS_Search($searchString))->__toString(), ])); } /** * This action displays the normal view of FreshRSS. */ public function normalAction(): void { $allow_anonymous = FreshRSS_Context::systemConf()->allow_anonymous; if (!FreshRSS_Auth::hasAccess() && !$allow_anonymous) { Minz_Request::forward(['c' => 'auth', 'a' => 'login']); return; } $id = Minz_Request::paramInt('id'); if ($id !== 0) { $view = Minz_Request::paramString('a'); $url_redirect = ['c' => 'subscription', 'a' => 'feed', 'params' => ['id' => (string)$id, 'from' => $view]]; Minz_Request::forward($url_redirect, true); return; } try { FreshRSS_Context::updateUsingRequest(true); } catch (FreshRSS_Context_Exception $e) { Minz_Error::error(404); } $this->_csp([ 'default-src' => "'self'", 'frame-src' => '*', 'img-src' => '* data: blob:', 'frame-ancestors' => FreshRSS_Context::systemConf()->attributeString('csp.frame-ancestors') ?? "'none'", 'media-src' => '*', ]); $this->view->categories = FreshRSS_Context::categories(); $this->view->rss_title = FreshRSS_Context::$name . ' | ' . FreshRSS_View::title(); $title = FreshRSS_Context::$name; $search = FreshRSS_Context::$search->__toString(); if ($search !== '') { $title = '“' . htmlspecialchars($search, ENT_COMPAT, 'UTF-8') . '”'; } if (FreshRSS_Context::$get_unread > 0) { $title = '(' . FreshRSS_Context::$get_unread . ') ' . $title; } FreshRSS_View::prependTitle($title . ' · '); if (FreshRSS_Context::$id_max === '0') { FreshRSS_Context::$id_max = uTimeString(); } $this->view->callbackBeforeFeeds = static function (FreshRSS_View $view) { $view->nbUnreadTags = 0; if (Minz_Request::paramBoolean('ajax')) { // Disable label counts for AJAX requests: faster and not needed $view->tags = FreshRSS_Context::labels(precounts: false); return; } $view->tags = FreshRSS_Context::labels(precounts: true); foreach ($view->tags as $tag) { $view->nbUnreadTags += $tag->nbUnread(); } }; $this->view->callbackBeforeEntries = static function (FreshRSS_View $view) { try { // +1 to account for paging logic $view->entries = FreshRSS_index_Controller::listEntriesByContext(FreshRSS_Context::$number + 1); ob_start(); //Buffer "one entry at a time" } catch (FreshRSS_EntriesGetter_Exception $e) { Minz_Log::notice($e->getMessage()); Minz_Error::error(404); } }; $this->view->callbackBeforePagination = static function (?FreshRSS_View $view, int $nbEntries, FreshRSS_Entry $lastEntry) { if ($nbEntries > FreshRSS_Context::$number) { //We have enough entries: we discard the last one to use it for the next articles' page ob_clean(); FreshRSS_Context::$continuation_id = $lastEntry->id(); } else { FreshRSS_Context::$continuation_id = '0'; } ob_end_flush(); }; } /** * This action displays the reader view of FreshRSS. * * @todo: change this view into specific CSS rules? */ public function readerAction(): void { $this->normalAction(); } /** * This action displays the global view of FreshRSS. */ public function globalAction(): void { $allow_anonymous = FreshRSS_Context::systemConf()->allow_anonymous; if (!FreshRSS_Auth::hasAccess() && !$allow_anonymous) { Minz_Request::forward(['c' => 'auth', 'a' => 'login']); return; } FreshRSS_View::appendScript(Minz_Url::display('/scripts/extra.js?' . @filemtime(PUBLIC_PATH . '/scripts/extra.js'))); FreshRSS_View::appendScript(Minz_Url::display('/scripts/global_view.js?' . @filemtime(PUBLIC_PATH . '/scripts/global_view.js'))); try { FreshRSS_Context::updateUsingRequest(true); } catch (FreshRSS_Context_Exception) { Minz_Error::error(404); } $this->view->categories = FreshRSS_Context::categories(); $this->view->rss_title = FreshRSS_Context::$name . ' | ' . FreshRSS_View::title(); $title = _t('index.feed.title_global'); if (FreshRSS_Context::$get_unread > 0) { $title = '(' . FreshRSS_Context::$get_unread . ') ' . $title; } FreshRSS_View::prependTitle($title . ' · '); $this->_csp([ 'default-src' => "'self'", 'frame-src' => '*', 'img-src' => '* data: blob:', 'frame-ancestors' => FreshRSS_Context::systemConf()->attributeString('csp.frame-ancestors') ?? "'none'", 'media-src' => '*', ]); } /** * This action displays the RSS feed of FreshRSS. */ #[Deprecated('See user query RSS sharing instead')] public function rssAction(): void { $allow_anonymous = FreshRSS_Context::systemConf()->allow_anonymous; // Check if user has access. if (!FreshRSS_Auth::hasAccess() && !$allow_anonymous) { Minz_Error::error(403); } try { FreshRSS_Context::updateUsingRequest(false); } catch (FreshRSS_Context_Exception $e) { Minz_Error::error(404); } try { $this->view->entries = FreshRSS_index_Controller::listEntriesByContext(); } catch (FreshRSS_EntriesGetter_Exception $e) { Minz_Log::notice($e->getMessage()); Minz_Error::error(404); } $this->view->html_url = Minz_Url::display('', 'html', true); $this->view->rss_title = FreshRSS_Context::$name . ' | ' . FreshRSS_View::title(); $queryString = $_SERVER['QUERY_STRING'] ?? ''; $this->view->rss_url = htmlspecialchars( PUBLIC_TO_INDEX_PATH . '/' . ($queryString === '' || !is_string($queryString) ? '' : '?' . $queryString), ENT_COMPAT, 'UTF-8'); // No layout for RSS output. $this->view->_layout(null); header('Content-Type: application/rss+xml; charset=utf-8'); } #[Deprecated('See user query OPML sharing instead')] public function opmlAction(): void { $allow_anonymous = FreshRSS_Context::systemConf()->allow_anonymous; // Check if user has access. if (!FreshRSS_Auth::hasAccess() && !$allow_anonymous) { Minz_Error::error(403); } try { FreshRSS_Context::updateUsingRequest(false); } catch (FreshRSS_Context_Exception) { Minz_Error::error(404); } $get = FreshRSS_Context::currentGet(true); $type = (string)$get[0]; $id = (int)$get[1]; $this->view->excludeMutedFeeds = $type !== 'f'; // Exclude muted feeds except when we focus on a feed switch ($type) { case 'a': // All PRIORITY_MAIN_STREAM case 'A': // All except PRIORITY_HIDDEN case 'Z': // All including PRIORITY_HIDDEN $this->view->categories = FreshRSS_Context::categories(); break; case 'c': $cat = FreshRSS_Context::categories()[$id] ?? null; if ($cat == null) { Minz_Error::error(404); return; } $this->view->categories = [$cat->id() => $cat]; break; case 'f': // We most likely already have the feed object in cache $feed = FreshRSS_Category::findFeed(FreshRSS_Context::categories(), $id); if ($feed === null) { $feedDAO = FreshRSS_Factory::createFeedDao(); $feed = $feedDAO->searchById($id); if ($feed == null) { Minz_Error::error(404); return; } } $this->view->feeds = [$feed->id() => $feed]; break; case 's': case 't': case 'T': default: Minz_Error::error(404); return; } // No layout for OPML output. $this->view->_layout(null); header('Content-Type: application/xml; charset=utf-8'); } /** * This method returns a list of entries based on the Context object. * @param int $postsPerPage override `FreshRSS_Context::$number` * @return Traversable * @throws FreshRSS_EntriesGetter_Exception */ public static function listEntriesByContext(?int $postsPerPage = null): Traversable { $entryDAO = FreshRSS_Factory::createEntryDao(); $get = FreshRSS_Context::currentGet(true); if (is_array($get)) { $type = $get[0]; $id = (int)($get[1]); } else { $type = $get; $id = 0; } $id_min = '0'; if (FreshRSS_Context::$sinceHours > 0) { $id_min = (time() - (FreshRSS_Context::$sinceHours * 3600)) . '000000'; } $continuation_values = []; if (FreshRSS_Context::$continuation_id !== '0') { if (in_array(FreshRSS_Context::$sort, ['c.name', 'date', 'f.name', 'link', 'title', 'lastUserModified', 'length'], true)) { $pagingEntry = $entryDAO->searchById(FreshRSS_Context::$continuation_id); if ($pagingEntry !== null && in_array(FreshRSS_Context::$sort, ['c.name', 'f.name'], true)) { // We most likely already have the feed object in cache $feed = FreshRSS_Category::findFeed(FreshRSS_Context::categories(), $pagingEntry->feedId()); if ($feed !== null) { $pagingEntry->_feed($feed); } } $continuation_values[] = $pagingEntry === null ? 0 : match (FreshRSS_Context::$sort) { 'c.name' => $pagingEntry->feed()?->categoryId() === FreshRSS_CategoryDAO::DEFAULTCATEGORYID ? FreshRSS_CategoryDAO::DEFAULT_CATEGORY_NAME : $pagingEntry->feed()?->category()?->name() ?? '', 'date' => $pagingEntry->date(raw: true), 'f.name' => $pagingEntry->feed()?->name() ?? '', 'link' => $pagingEntry->link(raw: true), 'title' => $pagingEntry->title(), 'lastUserModified' => $pagingEntry->lastUserModified(), 'length' => $pagingEntry->sqlContentLength() ?? 0, }; if ($pagingEntry !== null && FreshRSS_Context::$sort === 'c.name') { // Secondary sort criterion $continuation_values[] = $pagingEntry->feed()?->name() ?? ''; } } elseif (FreshRSS_Context::$sort === 'rand') { FreshRSS_Context::$continuation_id = '0'; } } foreach ($entryDAO->listWhere( $type, $id, FreshRSS_Context::$state, FreshRSS_Context::$search, id_min: $id_min, id_max: FreshRSS_Context::$id_max, sort: FreshRSS_Context::$sort, order: FreshRSS_Context::$order, continuation_id: FreshRSS_Context::$continuation_id, continuation_values: $continuation_values, limit: $postsPerPage ?? FreshRSS_Context::$number, offset: FreshRSS_Context::$offset) as $entry) { yield $entry; } } /** * This action displays the about page of FreshRSS. */ public function aboutAction(): void { FreshRSS_View::prependTitle(_t('index.about.title') . ' · '); } /** * This action displays the EULA/TOS (Terms of Service) page of FreshRSS. * This page is enabled only if admin created a data/tos.html file. * The content of the page is the content of data/tos.html. * It returns 404 if there is no EULA/TOS. */ public function tosAction(): void { $terms_of_service = file_get_contents(TOS_FILENAME); if ($terms_of_service === false) { Minz_Error::error(404); return; } $this->view->terms_of_service = $terms_of_service; $this->view->can_register = !FreshRSS_user_Controller::max_registrations_reached(); FreshRSS_View::prependTitle(_t('index.tos.title') . ' · '); } /** * This action displays logs of FreshRSS for the current user. */ public function logsAction(): void { if (!FreshRSS_Auth::hasAccess()) { Minz_Error::error(403); } FreshRSS_View::prependTitle(_t('index.log.title') . ' · '); if (Minz_Request::isPost()) { FreshRSS_LogDAO::truncate(); } $logs = FreshRSS_LogDAO::lines(); //TODO: ask only the necessary lines //gestion pagination $page = Minz_Request::paramInt('page') ?: 1; $this->view->logsPaginator = new Minz_Paginator($logs); $this->view->logsPaginator->_nbItemsPerPage(50); $this->view->logsPaginator->_currentPage($page); } }