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.
599 lines
15 KiB
599 lines
15 KiB
<?php |
|
/** |
|
* Fever API for FreshRSS |
|
* Version 0.1 |
|
* Author: Kevin Papst / https://github.com/kevinpapst |
|
* Documentation: https://feedafever.com/api |
|
* |
|
* 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('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'); |
|
// ================================================================================================ |
|
|
|
// <Debug> |
|
$ORIGINAL_INPUT = file_get_contents('php://input', false, null, 0, 1048576); |
|
|
|
function debugInfo() { |
|
if (function_exists('getallheaders')) { |
|
$ALL_HEADERS = getallheaders(); |
|
} else { //nginx http://php.net/getallheaders#84262 |
|
$ALL_HEADERS = array(); |
|
foreach ($_SERVER as $name => $value) { |
|
if (substr($name, 0, 5) === 'HTTP_') { |
|
$ALL_HEADERS[str_replace(' ', '-', ucwords(strtolower(str_replace('_', ' ', substr($name, 5)))))] = $value; |
|
} |
|
} |
|
} |
|
global $ORIGINAL_INPUT; |
|
return print_r( |
|
array( |
|
'date' => date('c'), |
|
'headers' => $ALL_HEADERS, |
|
'_SERVER' => $_SERVER, |
|
'_GET' => $_GET, |
|
'_POST' => $_POST, |
|
'_COOKIE' => $_COOKIE, |
|
'INPUT' => $ORIGINAL_INPUT |
|
), true); |
|
} |
|
|
|
//Minz_Log::debug('----------------------------------------------------------------', API_LOG); |
|
//Minz_Log::debug(debugInfo(), API_LOG); |
|
// </Debug> |
|
|
|
class FeverDAO extends Minz_ModelPdo |
|
{ |
|
/** |
|
* @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 = ''; |
|
$entryDAO = FreshRSS_Factory::createEntryDao(); |
|
|
|
$sql = 'SELECT id, guid, title, author, ' |
|
. ($entryDAO->isCompressed() ? 'UNCOMPRESS(content_bin) AS content' : 'content') |
|
. ', link, date, is_read, is_favorite, id_feed ' |
|
. 'FROM `' . $this->prefix . 'entry` WHERE'; |
|
|
|
if (!empty($entry_ids)) { |
|
$bindEntryIds = $this->bindParamArray('id', $entry_ids, $values); |
|
$sql .= " id IN($bindEntryIds)"; |
|
} elseif ($max_id != null) { |
|
$sql .= ' id < :id'; |
|
$values[':id'] = $max_id; |
|
$order = ' ORDER BY id DESC'; |
|
} elseif ($since_id != null) { |
|
$sql .= ' id > :id'; |
|
$values[':id'] = $since_id; |
|
$order = ' ORDER BY id ASC'; |
|
} else { |
|
$sql .= ' 1=1'; |
|
} |
|
|
|
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[] = FreshRSS_EntryDAO::daoToEntry($dao); |
|
} |
|
|
|
return $entries; |
|
} |
|
} |
|
|
|
/** |
|
* Class FeverAPI |
|
*/ |
|
class FeverAPI |
|
{ |
|
const API_LEVEL = 3; |
|
const STATUS_OK = 1; |
|
const STATUS_ERR = 0; |
|
|
|
private $entryDAO = null; |
|
private $feedDAO = null; |
|
|
|
/** |
|
* 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; |
|
$this->entryDAO = FreshRSS_Factory::createEntryDao(); |
|
$this->feedDAO = FreshRSS_Factory::createFeedDao(); |
|
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; |
|
} |
|
|
|
/** |
|
* 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(); |
|
} |
|
|
|
$id = isset($_REQUEST['id']) ? '' . $_REQUEST['id'] : ''; |
|
if (isset($_REQUEST['mark'], $_REQUEST['as'], $_REQUEST['id']) && ctype_digit($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)) { |
|
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; |
|
|
|
$entries = $this->feedDAO->listFeedsOrderUpdate(-1, 1); |
|
$feed = current($entries); |
|
|
|
if (!empty($feed)) { |
|
$lastUpdate = $feed->lastUpdate(); |
|
} |
|
|
|
return $lastUpdate; |
|
} |
|
|
|
/** |
|
* @return array |
|
*/ |
|
protected function getFeeds() |
|
{ |
|
$feeds = array(); |
|
$myFeeds = $this->feedDAO->listFeeds(); |
|
|
|
/** @var FreshRSS_Feed $feed */ |
|
foreach ($myFeeds as $feed) { |
|
$feeds[] = array( |
|
'id' => $feed->id(), |
|
'favicon_id' => $feed->id(), |
|
'title' => escapeToUnicodeAlternative($feed->name(), true), |
|
'url' => htmlspecialchars_decode($feed->url(), ENT_QUOTES), |
|
'site_url' => htmlspecialchars_decode($feed->website(), ENT_QUOTES), |
|
'is_spark' => 0, // unsupported |
|
'last_updated_on_time' => $feed->lastUpdate(), |
|
); |
|
} |
|
|
|
return $feeds; |
|
} |
|
|
|
/** |
|
* @return array |
|
*/ |
|
protected function getGroups() |
|
{ |
|
$groups = array(); |
|
|
|
$categoryDAO = FreshRSS_Factory::createCategoryDao(); |
|
$categories = $categoryDAO->listCategories(false, false); |
|
|
|
/** @var FreshRSS_Category $category */ |
|
foreach ($categories as $category) { |
|
$groups[] = array( |
|
'id' => $category->id(), |
|
'title' => escapeToUnicodeAlternative($category->name(), true), |
|
); |
|
} |
|
|
|
return $groups; |
|
} |
|
|
|
/** |
|
* @return array |
|
*/ |
|
protected function getFavicons() |
|
{ |
|
$favicons = array(); |
|
$salt = FreshRSS_Context::$system_conf->salt; |
|
$myFeeds = $this->feedDAO->listFeeds(); |
|
|
|
/** @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() |
|
{ |
|
return $this->entryDAO->count(); |
|
} |
|
|
|
/** |
|
* @return array |
|
*/ |
|
protected function getFeedsGroup() |
|
{ |
|
$groups = array(); |
|
$ids = array(); |
|
$myFeeds = $this->feedDAO->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() |
|
{ |
|
$entries = $this->entryDAO->listIdsWhere('a', '', FreshRSS_Entry::STATE_NOT_READ, 'ASC', 0); |
|
return $this->entriesToIdList($entries); |
|
} |
|
|
|
/** |
|
* @return string |
|
*/ |
|
protected function getSavedItemIds() |
|
{ |
|
$entries = $this->entryDAO->listIdsWhere('a', '', FreshRSS_Entry::STATE_FAVORITE, 'ASC', 0); |
|
return $this->entriesToIdList($entries); |
|
} |
|
|
|
protected function setItemAsRead($id) |
|
{ |
|
return $this->entryDAO->markRead($id, true); |
|
} |
|
|
|
protected function setItemAsUnread($id) |
|
{ |
|
return $this->entryDAO->markRead($id, false); |
|
} |
|
|
|
protected function setItemAsSaved($id) |
|
{ |
|
return $this->entryDAO->markFavorite($id, true); |
|
} |
|
|
|
protected function setItemAsUnsaved($id) |
|
{ |
|
return $this->entryDAO->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']); |
|
} |
|
|
|
if (isset($_REQUEST['group_ids'])) { |
|
$categoryDAO = FreshRSS_Factory::createCategoryDao(); |
|
$group_ids = explode(',', $_REQUEST['group_ids']); |
|
foreach ($group_ids as $id) { |
|
/** @var FreshRSS_Category $category */ |
|
$category = $categoryDAO->searchById($id); //TODO: Transform to SQL query without loop! Consider FreshRSS_CategoryDAO::listCategories(true) |
|
/** @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 |
|
$max_id = '' . $_REQUEST['max_id']; |
|
if (!ctype_digit($max_id)) { |
|
$max_id = null; |
|
} |
|
} 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 = '' . $_REQUEST['since_id']; |
|
if (!ctype_digit($since_id)) { |
|
$since_id = null; |
|
} |
|
} |
|
|
|
$items = array(); |
|
|
|
$feverDAO = new FeverDAO(); |
|
$entries = $feverDAO->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 ($entry == null) { |
|
continue; |
|
} |
|
$items[] = array( |
|
'id' => $entry->id(), |
|
'feed_id' => $entry->feed(false), |
|
'title' => escapeToUnicodeAlternative($entry->title(), false), |
|
'author' => escapeToUnicodeAlternative(trim($entry->authors(true), '; '), false), |
|
'html' => $entry->content(), |
|
'url' => htmlspecialchars_decode($entry->link(), ENT_QUOTES), |
|
'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) |
|
{ |
|
return $beforeTimestamp == 0 ? 0 : $beforeTimestamp . '000000'; |
|
} |
|
|
|
protected function setFeedAsRead($id, $before) |
|
{ |
|
$before = $this->convertBeforeToId($before); |
|
return $this->entryDAO->markReadFeed($id, $before); |
|
} |
|
|
|
protected function setGroupAsRead($id, $before) |
|
{ |
|
$before = $this->convertBeforeToId($before); |
|
|
|
// special case to mark all items as read |
|
if ($id == 0) { |
|
return $this->entryDAO->markReadEntries($before); |
|
} |
|
|
|
return $this->entryDAO->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()); |
|
}
|
|
|