api_enabled) { Minz_Log::warning('Fever API: serviceUnavailable() ' . debugInfo(), API_LOG); header('HTTP/1.1 503 Service Unavailable'); header('Content-Type: text/plain; charset=UTF-8'); die('Service Unavailable!'); } ini_set('session.use_cookies', '0'); register_shutdown_function('session_destroy'); Minz_Session::init('FreshRSS'); // ================================================================================================ class FeverAPI_EntryDAO extends FreshRSS_EntryDAO { /** * @return array */ public function countFever() { $values = array( 'total' => 0, 'min' => 0, 'max' => 0, ); $sql = 'SELECT COUNT(id) as `total`, MIN(id) as `min`, MAX(id) as `max` FROM `' . $this->prefix . 'entry`'; $stm = $this->bd->prepare($sql); $stm->execute(); $result = $stm->fetchAll(PDO::FETCH_ASSOC); if (!empty($result[0])) { $values = $result[0]; } return $values; } /** * @param string $prefix * @param array $values * @param array $bindArray * @return string */ protected function bindParamArray($prefix, $values, &$bindArray) { $str = ''; for ($i = 0; $i < count($values); $i++) { $str .= ':' . $prefix . $i . ','; $bindArray[$prefix . $i] = $values[$i]; } return rtrim($str, ','); } /** * @param array $feed_ids * @param array $entry_ids * @param int|null $max_id * @param int|null $since_id * @return FreshRSS_Entry[] */ public function findEntries(array $feed_ids, array $entry_ids, $max_id, $since_id) { $values = array(); $order = ''; $sql = 'SELECT id, guid, title, author, ' . ($this->isCompressed() ? 'UNCOMPRESS(content_bin) AS content' : 'content') . ', link, date, is_read, is_favorite, id_feed, tags ' . 'FROM `' . $this->prefix . 'entry` WHERE'; if (!empty($entry_ids)) { $bindEntryIds = $this->bindParamArray("id", $entry_ids, $values); $sql .= " id IN($bindEntryIds)"; } else if (!empty($max_id)) { $sql .= ' id < :id'; $values[':id'] = $max_id; $order = ' ORDER BY id DESC'; } else { $sql .= ' id > :id'; $values[':id'] = $since_id; $order = ' ORDER BY id ASC'; } if (!empty($feed_ids)) { $bindFeedIds = $this->bindParamArray("feed", $feed_ids, $values); $sql .= " AND id_feed IN($bindFeedIds)"; } $sql .= $order; $sql .= ' LIMIT 50'; $stm = $this->bd->prepare($sql); $stm->execute($values); $result = $stm->fetchAll(PDO::FETCH_ASSOC); $entries = array(); foreach ($result as $dao) { $entries[] = self::daoToEntry($dao); } return $entries; } } /** * Class FeverAPI */ class FeverAPI { const API_LEVEL = 3; const STATUS_OK = 1; const STATUS_ERR = 0; /** * Authenticate the user * * API Password sent from client is the result of the md5 sum of * your FreshRSS "username:your-api-password" combination */ private function authenticate() { FreshRSS_Context::$user_conf = null; Minz_Session::_param('currentUser'); $feverKey = empty($_POST['api_key']) ? '' : substr(trim($_POST['api_key']), 0, 128); if (ctype_xdigit($feverKey)) { $feverKey = strtolower($feverKey); $username = @file_get_contents(DATA_PATH . '/fever/.key-' . sha1(FreshRSS_Context::$system_conf->salt) . '-' . $feverKey . '.txt', false); if ($username != false) { $username = trim($username); Minz_Session::_param('currentUser', $username); $user_conf = get_user_configuration($username); if ($user_conf != null && $feverKey === $user_conf->feverKey) { FreshRSS_Context::$user_conf = $user_conf; return true; } Minz_Log::error('Fever API: Reset API password for user: ' . $username, API_LOG); Minz_Log::error('Fever API: Please reset your API password!'); Minz_Session::_param('currentUser'); } Minz_Log::warning('Fever API: wrong credentials! ' . $feverKey, API_LOG); } return false; } /** * @return bool */ public function isAuthenticatedApiUser() { $this->authenticate(); if (FreshRSS_Context::$user_conf !== null) { return true; } return false; } /** * @return FreshRSS_FeedDAO */ protected function getDaoForFeeds() { return new FreshRSS_FeedDAO(); } /** * @return FreshRSS_CategoryDAO */ protected function getDaoForCategories() { return new FreshRSS_CategoryDAO(); } /** * @return FeverAPI_EntryDAO */ protected function getDaoForEntries() { return new FeverAPI_EntryDAO(); } /** * This does all the processing, since the fever api does not have a specific variable that specifies the operation * * @return array * @throws Exception */ public function process() { $response_arr = array(); if (!$this->isAuthenticatedApiUser()) { throw new Exception('No user given or user is not allowed to access API'); } if (isset($_REQUEST["groups"])) { $response_arr["groups"] = $this->getGroups(); $response_arr["feeds_groups"] = $this->getFeedsGroup(); } if (isset($_REQUEST["feeds"])) { $response_arr["feeds"] = $this->getFeeds(); $response_arr["feeds_groups"] = $this->getFeedsGroup(); } if (isset($_REQUEST["favicons"])) { $response_arr["favicons"] = $this->getFavicons(); } if (isset($_REQUEST["items"])) { $response_arr["total_items"] = $this->getTotalItems(); $response_arr["items"] = $this->getItems(); } if (isset($_REQUEST["links"])) { $response_arr["links"] = $this->getLinks(); } if (isset($_REQUEST["unread_item_ids"])) { $response_arr["unread_item_ids"] = $this->getUnreadItemIds(); } if (isset($_REQUEST["saved_item_ids"])) { $response_arr["saved_item_ids"] = $this->getSavedItemIds(); } if (isset($_REQUEST["mark"], $_REQUEST["as"], $_REQUEST["id"]) && is_numeric($_REQUEST["id"])) { $method_name = "set" . ucfirst($_REQUEST["mark"]) . "As" . ucfirst($_REQUEST["as"]); $allowedMethods = array( 'setFeedAsRead', 'setGroupAsRead', 'setItemAsRead', 'setItemAsSaved', 'setItemAsUnread', 'setItemAsUnsaved' ); if (in_array($method_name, $allowedMethods)) { $id = intval($_REQUEST["id"]); switch (strtolower($_REQUEST["mark"])) { case 'item': $this->{$method_name}($id); break; case 'feed': case 'group': $before = (isset($_REQUEST["before"])) ? $_REQUEST["before"] : null; $this->{$method_name}($id, $before); break; } switch ($_REQUEST["as"]) { case "read": case "unread": $response_arr["unread_item_ids"] = $this->getUnreadItemIds(); break; case 'saved': case 'unsaved': $response_arr["saved_item_ids"] = $this->getSavedItemIds(); break; } } } return $response_arr; } /** * Returns the complete JSON, with 'api_version' and status as 'auth'. * * @param int $status * @param array $reply * @return string */ public function wrap($status, array $reply = array()) { $arr = array('api_version' => self::API_LEVEL, 'auth' => $status); if ($status === self::STATUS_OK) { $arr['last_refreshed_on_time'] = (string) $this->lastRefreshedOnTime(); $arr = array_merge($arr, $reply); } return json_encode($arr); } /** * every authenticated method includes last_refreshed_on_time * * @return int */ protected function lastRefreshedOnTime() { $lastUpdate = 0; $dao = $this->getDaoForFeeds(); $entries = $dao->listFeedsOrderUpdate(-1, 1); $feed = current($entries); if (!empty($feed)) { $lastUpdate = $feed->lastUpdate(); } return $lastUpdate; } /** * @return array */ protected function getFeeds() { $feeds = array(); $dao = $this->getDaoForFeeds(); $myFeeds = $dao->listFeeds(); /** @var FreshRSS_Feed $feed */ foreach ($myFeeds as $feed) { $feeds[] = array( "id" => $feed->id(), "favicon_id" => $feed->id(), "title" => $feed->name(), "url" => $feed->url(), "site_url" => $feed->website(), "is_spark" => 0, // unsupported "last_updated_on_time" => $feed->lastUpdate() ); } return $feeds; } /** * @return array */ protected function getGroups() { $groups = array(); $dao = $this->getDaoForCategories(); $categories = $dao->listCategories(false, false); /** @var FreshRSS_Category $category */ foreach ($categories as $category) { $groups[] = array( 'id' => $category->id(), 'title' => $category->name() ); } return $groups; } /** * @return array */ protected function getFavicons() { $favicons = array(); $dao = $this->getDaoForFeeds(); $myFeeds = $dao->listFeeds(); $salt = FreshRSS_Context::$system_conf->salt; /** @var FreshRSS_Feed $feed */ foreach ($myFeeds as $feed) { $id = hash('crc32b', $salt . $feed->url()); $filename = DATA_PATH . '/favicons/' . $id . '.ico'; if (!file_exists($filename)) { continue; } $favicons[] = array( "id" => $feed->id(), "data" => image_type_to_mime_type(exif_imagetype($filename)) . ";base64," . base64_encode(file_get_contents($filename)) ); } return $favicons; } /** * @return int */ protected function getTotalItems() { $total_items = 0; $dao = $this->getDaoForEntries(); $result = $dao->countFever(); if (!empty($result)) { $total_items = $result['total']; } return $total_items; } /** * @return array */ protected function getFeedsGroup() { $groups = array(); $ids = array(); $dao = $this->getDaoForFeeds(); $myFeeds = $dao->listFeeds(); /** @var FreshRSS_Feed $feed */ foreach ($myFeeds as $feed) { $ids[$feed->category()][] = $feed->id(); } foreach($ids as $category => $feedIds) { $groups[] = array( 'group_id' => $category, 'feed_ids' => implode(',', $feedIds) ); } return $groups; } /** * AFAIK there is no 'hot links' alternative in FreshRSS * @return array */ protected function getLinks() { return array(); } /** * @param array $ids * @return string */ protected function entriesToIdList($ids = array()) { return implode(',', array_values($ids)); } /** * @return string */ protected function getUnreadItemIds() { $dao = $this->getDaoForEntries(); $entries = $dao->listIdsWhere('a', '', FreshRSS_Entry::STATE_NOT_READ, 'ASC', 0); return $this->entriesToIdList($entries); } /** * @return string */ protected function getSavedItemIds() { $dao = $this->getDaoForEntries(); $entries = $dao->listIdsWhere('a', '', FreshRSS_Entry::STATE_FAVORITE, 'ASC', 0); return $this->entriesToIdList($entries); } protected function setItemAsRead($id) { $dao = $this->getDaoForEntries(); $dao->markRead($id, true); } protected function setItemAsUnread($id) { $dao = $this->getDaoForEntries(); $dao->markRead($id, false); } protected function setItemAsSaved($id) { $dao = $this->getDaoForEntries(); $dao->markFavorite($id, true); } protected function setItemAsUnsaved($id) { $dao = $this->getDaoForEntries(); $dao->markFavorite($id, false); } /** * @return array */ protected function getItems() { $feed_ids = array(); $entry_ids = array(); $max_id = null; $since_id = null; if (isset($_REQUEST["feed_ids"]) || isset($_REQUEST["group_ids"])) { if (isset($_REQUEST["feed_ids"])) { $feed_ids = explode(",", $_REQUEST["feed_ids"]); } $dao = $this->getDaoForCategories(); if (isset($_REQUEST["group_ids"])) { $group_ids = explode(",", $_REQUEST["group_ids"]); foreach ($group_ids as $id) { /** @var FreshRSS_Category $category */ $category = $dao->searchById($id); /** @var FreshRSS_Feed $feed */ foreach ($category->feeds() as $feed) { $feeds[] = $feed->id(); } } $feed_ids = array_unique($feeds); } } if (isset($_REQUEST["max_id"])) { // use the max_id argument to request the previous $item_limit items if (is_numeric($_REQUEST["max_id"])) { $max = ($_REQUEST["max_id"] > 0) ? intval($_REQUEST["max_id"]) : 0; if ($max) { $max_id = $max; } } } else if (isset($_REQUEST["with_ids"])) { $entry_ids = explode(",", $_REQUEST["with_ids"]); } else { // use the since_id argument to request the next $item_limit items $since_id = isset($_REQUEST["since_id"]) && is_numeric($_REQUEST["since_id"]) ? intval($_REQUEST["since_id"]) : 0; } $items = array(); $dao = $this->getDaoForEntries(); $entries = $dao->findEntries($feed_ids, $entry_ids, $max_id, $since_id); // Load list of extensions and enable the "system" ones. Minz_ExtensionManager::init(); foreach($entries as $item) { /** @var FreshRSS_Entry $entry */ $entry = Minz_ExtensionManager::callHook('entry_before_display', $item); if (is_null($entry)) { continue; } $items[] = array( "id" => $entry->id(), "feed_id" => $entry->feed(false), "title" => $entry->title(), "author" => $entry->author(), "html" => $entry->content(), "url" => $entry->link(), "is_saved" => $entry->isFavorite() ? 1 : 0, "is_read" => $entry->isRead() ? 1 : 0, "created_on_time" => $entry->date(true) ); } return $items; } /** * TODO replace by a dynamic fetch for id <= $before timestamp * * @param int $beforeTimestamp * @return int */ protected function convertBeforeToId($beforeTimestamp) { // if before is zero, set it to now so feeds all items are read from before this point in time if ($beforeTimestamp == 0) { $before = time(); } $before = PHP_INT_MAX; return $before; } protected function setFeedAsRead($id, $before) { $before = $this->convertBeforeToId($before); $dao = $this->getDaoForEntries(); return $dao->markReadFeed($id, $before); } protected function setGroupAsRead($id, $before) { $dao = $this->getDaoForEntries(); // special case to mark all items as read if ($id === 0) { $result = $dao->countFever(); if (!empty($result)) { return $dao->markReadEntries($result['max']); } } $before = $this->convertBeforeToId($before); return $dao->markReadCat($id, $before); } } // ================================================================================================ // refresh is not allowed yet, probably we find a way to support it later if (isset($_REQUEST["refresh"])) { Minz_Log::warning('Fever API: Refresh items - notImplemented()', API_LOG); header('HTTP/1.1 501 Not Implemented'); header('Content-Type: text/plain; charset=UTF-8'); die('Not Implemented!'); } // Start the Fever API handling $handler = new FeverAPI(); header("Content-Type: application/json; charset=UTF-8"); if (!$handler->isAuthenticatedApiUser()) { echo $handler->wrap(FeverAPI::STATUS_ERR, array()); } else { echo $handler->wrap(FeverAPI::STATUS_OK, $handler->process()); }