You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
634 lines
15 KiB
634 lines
15 KiB
<?php |
|
/** |
|
* Fever API for FreshRSS |
|
* Version 0.1 |
|
* Author: Kevin Papst / https://github.com/kevinpapst |
|
* |
|
* Inspired by: |
|
* TinyTinyRSS Fever API plugin @dasmurphy |
|
* See https://github.com/dasmurphy/tinytinyrss-fever-plugin |
|
*/ |
|
|
|
// ================================================================================================ |
|
// BOOTSTRAP FreshRSS |
|
require(__DIR__ . '/../../constants.php'); |
|
require(LIB_PATH . '/lib_rss.php'); //Includes class autoloader |
|
Minz_Configuration::register('system', DATA_PATH . '/config.php', FRESHRSS_PATH . '/config.default.php'); |
|
|
|
// check if API is enabled globally |
|
FreshRSS_Context::$system_conf = Minz_Configuration::get('system'); |
|
if (!FreshRSS_Context::$system_conf->api_enabled) { |
|
Minz_Log::warning('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); |
|
$user_conf = get_user_configuration($username); |
|
if ($user_conf != null && $feverKey === $user_conf->feverKey) { |
|
FreshRSS_Context::$user_conf = $user_conf; |
|
Minz_Session::_param('currentUser', $username); |
|
} |
|
} |
|
} |
|
} |
|
|
|
/** |
|
* @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('Refresh items for fever API - 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()); |
|
}
|
|
|