mirror of
https://github.com/FreshRSS/FreshRSS.git
synced 2026-03-05 15:06:28 -05:00
#fix https://github.com/FreshRSS/FreshRSS/issues/5183 And set protected methods as private since there is no subclass, and in order to get warnings for unused methods, which would have spotted the bug.
584 lines
16 KiB
PHP
584 lines
16 KiB
PHP
<?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
|
|
FreshRSS_Context::initSystem();
|
|
|
|
// check if API is enabled globally
|
|
if (FreshRSS_Context::$system_conf == null || !FreshRSS_Context::$system_conf->api_enabled) {
|
|
Minz_Log::warning('Fever API: service unavailable!');
|
|
Minz_Log::debug('Fever API: serviceUnavailable() ' . debugInfo(), API_LOG);
|
|
header('HTTP/1.1 503 Service Unavailable');
|
|
header('Content-Type: text/plain; charset=UTF-8');
|
|
die('Service Unavailable!');
|
|
}
|
|
|
|
Minz_Session::init('FreshRSS', true);
|
|
// ================================================================================================
|
|
|
|
// <Debug>
|
|
$ORIGINAL_INPUT = file_get_contents('php://input', false, null, 0, 1048576) ?: '';;
|
|
|
|
function debugInfo(): string {
|
|
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;
|
|
$log = sensitive_log([
|
|
'date' => date('c'),
|
|
'headers' => $ALL_HEADERS,
|
|
'_SERVER' => $_SERVER,
|
|
'_GET' => $_GET,
|
|
'_POST' => $_POST,
|
|
'_COOKIE' => $_COOKIE,
|
|
'INPUT' => $ORIGINAL_INPUT,
|
|
]);
|
|
return print_r($log, true);
|
|
}
|
|
|
|
//Minz_Log::debug('----------------------------------------------------------------', API_LOG);
|
|
//Minz_Log::debug(debugInfo(), API_LOG);
|
|
// </Debug>
|
|
|
|
final class FeverDAO extends Minz_ModelPdo
|
|
{
|
|
/**
|
|
* @param array<string|int> $values
|
|
* @param array<string,string|int> $bindArray
|
|
*/
|
|
private function bindParamArray(string $prefix, array $values, array &$bindArray): string {
|
|
$str = '';
|
|
for ($i = 0; $i < count($values); $i++) {
|
|
$str .= ':' . $prefix . $i . ',';
|
|
$bindArray[$prefix . $i] = $values[$i];
|
|
}
|
|
return rtrim($str, ',');
|
|
}
|
|
|
|
/**
|
|
* @param array<string|int> $feed_ids
|
|
* @param array<string> $entry_ids
|
|
* @return FreshRSS_Entry[]
|
|
*/
|
|
public function findEntries(array $feed_ids, array $entry_ids, string $max_id, string $since_id): array {
|
|
$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 `_entry` WHERE';
|
|
|
|
if (!empty($entry_ids)) {
|
|
$bindEntryIds = $this->bindParamArray('id', $entry_ids, $values);
|
|
$sql .= " id IN($bindEntryIds)";
|
|
} elseif ($max_id != '') {
|
|
$sql .= ' id < :id';
|
|
$values[':id'] = $max_id;
|
|
$order = ' ORDER BY id DESC';
|
|
} elseif ($since_id != '') {
|
|
$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->pdo->prepare($sql);
|
|
if ($stm && $stm->execute($values)) {
|
|
$result = $stm->fetchAll(PDO::FETCH_ASSOC);
|
|
|
|
$entries = array();
|
|
foreach ($result as $dao) {
|
|
$entries[] = FreshRSS_Entry::fromArray($dao);
|
|
}
|
|
|
|
return $entries;
|
|
}
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Class FeverAPI
|
|
*/
|
|
final class FeverAPI
|
|
{
|
|
const API_LEVEL = 3;
|
|
const STATUS_OK = 1;
|
|
const STATUS_ERR = 0;
|
|
|
|
/** @var FreshRSS_EntryDAO */
|
|
private $entryDAO;
|
|
|
|
/** @var FreshRSS_FeedDAO */
|
|
private $feedDAO;
|
|
|
|
/**
|
|
* 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(): bool {
|
|
if (FreshRSS_Context::$system_conf === null) {
|
|
throw new FreshRSS_Context_Exception('System configuration not initialised!');
|
|
}
|
|
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);
|
|
FreshRSS_Context::$user_conf = FreshRSS_Context::initUser($username); // Assignment to help PHPStan
|
|
if (FreshRSS_Context::$user_conf != null && $feverKey === FreshRSS_Context::$user_conf->feverKey && FreshRSS_Context::$user_conf->enabled) {
|
|
Minz_Translate::init(FreshRSS_Context::$user_conf->language);
|
|
$this->entryDAO = FreshRSS_Factory::createEntryDao();
|
|
$this->feedDAO = FreshRSS_Factory::createFeedDao();
|
|
return true;
|
|
} else {
|
|
Minz_Translate::init();
|
|
}
|
|
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;
|
|
}
|
|
|
|
public function isAuthenticatedApiUser(): bool {
|
|
$this->authenticate();
|
|
return FreshRSS_Context::$user_conf !== null;
|
|
}
|
|
|
|
/**
|
|
* This does all the processing, since the fever api does not have a specific variable that specifies the operation
|
|
* @return array<string,mixed>
|
|
* @throws Exception
|
|
*/
|
|
public function process(): array {
|
|
$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']) && ctype_digit($_REQUEST['id'])) {
|
|
$id = intval($_REQUEST['id']);
|
|
$before = intval($_REQUEST['before'] ?? '0');
|
|
switch (strtolower($_REQUEST['mark'])) {
|
|
case 'item':
|
|
switch ($_REQUEST['as']) {
|
|
case 'read':
|
|
$this->setItemAsRead($id);
|
|
break;
|
|
case 'saved':
|
|
$this->setItemAsSaved($id);
|
|
break;
|
|
case 'unread':
|
|
$this->setItemAsUnread($id);
|
|
break;
|
|
case 'unsaved':
|
|
$this->setItemAsUnsaved($id);
|
|
break;
|
|
}
|
|
break;
|
|
case 'feed':
|
|
switch ($_REQUEST['as']) {
|
|
case 'read':
|
|
$this->setFeedAsRead($id, $before);
|
|
break;
|
|
}
|
|
break;
|
|
case 'group':
|
|
switch ($_REQUEST['as']) {
|
|
case 'read':
|
|
$this->setGroupAsRead($id, $before);
|
|
break;
|
|
}
|
|
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 array<string,mixed> $reply
|
|
*/
|
|
public function wrap(int $status, array $reply = array()): string {
|
|
$arr = array('api_version' => self::API_LEVEL, 'auth' => $status);
|
|
|
|
if ($status === self::STATUS_OK) {
|
|
$arr['last_refreshed_on_time'] = $this->lastRefreshedOnTime();
|
|
$arr = array_merge($arr, $reply);
|
|
}
|
|
|
|
return json_encode($arr) ?: '';
|
|
}
|
|
|
|
/**
|
|
* every authenticated method includes last_refreshed_on_time
|
|
*/
|
|
private function lastRefreshedOnTime(): int {
|
|
$lastUpdate = 0;
|
|
|
|
$entries = $this->feedDAO->listFeedsOrderUpdate(-1, 1);
|
|
$feed = current($entries);
|
|
|
|
if (!empty($feed)) {
|
|
$lastUpdate = $feed->lastUpdate();
|
|
}
|
|
|
|
return $lastUpdate;
|
|
}
|
|
|
|
/** @return array<array<string,string|int>> */
|
|
private function getFeeds(): array {
|
|
$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<array<string,int|string>> */
|
|
private function getGroups(): array {
|
|
$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<array<string,int|string>> */
|
|
private function getFavicons(): array {
|
|
if (FreshRSS_Context::$system_conf == null) {
|
|
return [];
|
|
}
|
|
$favicons = array();
|
|
$salt = FreshRSS_Context::$system_conf->salt;
|
|
$myFeeds = $this->feedDAO->listFeeds();
|
|
|
|
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) ?: 0) . ';base64,' . base64_encode(file_get_contents($filename) ?: '')
|
|
);
|
|
}
|
|
|
|
return $favicons;
|
|
}
|
|
|
|
/**
|
|
* @return int|false
|
|
*/
|
|
private function getTotalItems() {
|
|
return $this->entryDAO->count();
|
|
}
|
|
|
|
/**
|
|
* @return array<array<string,int|string>>
|
|
*/
|
|
private function getFeedsGroup(): array {
|
|
$groups = array();
|
|
$ids = array();
|
|
$myFeeds = $this->feedDAO->listFeeds();
|
|
|
|
foreach ($myFeeds as $feed) {
|
|
$ids[$feed->categoryId()][] = $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<string>
|
|
*/
|
|
private function getLinks(): array {
|
|
return array();
|
|
}
|
|
|
|
/**
|
|
* @param array<string> $ids
|
|
*/
|
|
private function entriesToIdList(array $ids = array()): string {
|
|
return implode(',', array_values($ids));
|
|
}
|
|
|
|
private function getUnreadItemIds(): string {
|
|
$entries = $this->entryDAO->listIdsWhere('a', '', FreshRSS_Entry::STATE_NOT_READ, 'ASC', 0) ?: [];
|
|
return $this->entriesToIdList($entries);
|
|
}
|
|
|
|
private function getSavedItemIds(): string {
|
|
$entries = $this->entryDAO->listIdsWhere('a', '', FreshRSS_Entry::STATE_FAVORITE, 'ASC', 0) ?: [];
|
|
return $this->entriesToIdList($entries);
|
|
}
|
|
|
|
/**
|
|
* @return integer|false
|
|
*/
|
|
private function setItemAsRead(int $id) {
|
|
return $this->entryDAO->markRead($id, true);
|
|
}
|
|
|
|
/**
|
|
* @return integer|false
|
|
*/
|
|
private function setItemAsUnread(int $id) {
|
|
return $this->entryDAO->markRead($id, false);
|
|
}
|
|
|
|
/**
|
|
* @return integer|false
|
|
*/
|
|
private function setItemAsSaved(int $id) {
|
|
return $this->entryDAO->markFavorite($id, true);
|
|
}
|
|
|
|
/**
|
|
* @return integer|false
|
|
*/
|
|
private function setItemAsUnsaved(int $id) {
|
|
return $this->entryDAO->markFavorite($id, false);
|
|
}
|
|
|
|
/** @return array<array<string,string|int>> */
|
|
private function getItems(): array {
|
|
$feed_ids = array();
|
|
$entry_ids = array();
|
|
$max_id = '';
|
|
$since_id = '';
|
|
|
|
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']);
|
|
$feeds = [];
|
|
foreach ($group_ids as $id) {
|
|
$category = $categoryDAO->searchById($id); //TODO: Transform to SQL query without loop! Consider FreshRSS_CategoryDAO::listCategories(true)
|
|
if ($category == null) {
|
|
continue;
|
|
}
|
|
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 = '';
|
|
}
|
|
} elseif (isset($_REQUEST['with_ids'])) {
|
|
$entry_ids = explode(',', $_REQUEST['with_ids']);
|
|
} elseif (isset($_REQUEST['since_id'])) {
|
|
// use the since_id argument to request the next $item_limit items
|
|
$since_id = '' . $_REQUEST['since_id'];
|
|
if (!ctype_digit($since_id)) {
|
|
$since_id = '';
|
|
}
|
|
}
|
|
|
|
$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->feedId(),
|
|
'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
|
|
*/
|
|
private function convertBeforeToId(int $beforeTimestamp): string {
|
|
return $beforeTimestamp == 0 ? '0' : $beforeTimestamp . '000000';
|
|
}
|
|
|
|
/**
|
|
* @return integer|false
|
|
*/
|
|
private function setFeedAsRead(int $id, int $before) {
|
|
$before = $this->convertBeforeToId($before);
|
|
return $this->entryDAO->markReadFeed($id, $before);
|
|
}
|
|
|
|
/**
|
|
* @return integer|false
|
|
*/
|
|
private function setGroupAsRead(int $id, int $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());
|
|
}
|