Skip to content

Commit

Permalink
API /reader/api/0/stream/items/contents (FreshRSS#1774)
Browse files Browse the repository at this point in the history
* API /reader/api/0/stream/items/contents

For FeedMe

* Fix continuation

* Continuation in stream/items/ids

* Fix multiple continuations

* Allow empty POST tokens

For FeedMe.
This token is not used by e.g. The Old Reader API.
There is the Authorization header anyway.
TODO: Check security consequences

* API compatibility FeedMe: add/remove feed

FeedMe uses GET for some parameters typically given by POST

* A bit of sanitization

* Links to FeedMe

* API favicons more robust when base_url is not set

* Changelog FeedMe
  • Loading branch information
Alkarex committed Feb 8, 2018
1 parent 023dbea commit 6648414
Show file tree
Hide file tree
Showing 7 changed files with 149 additions and 61 deletions.
4 changes: 3 additions & 1 deletion CHANGELOG.md
@@ -1,7 +1,9 @@
# FreshRSS changelog

## 2018-XX-XX FreshRSS 1.9.1-dev
## 2018-02-XX FreshRSS 1.9.1-dev

* API
* Add compatibility with FeedMe 3.5.3+ on Android [#1774](https://github.com/FreshRSS/FreshRSS/pull/1774)
* Features
* Ability to pause feeds, and to hide them from categories [#1750](https://github.com/FreshRSS/FreshRSS/pull/1750)
* Security
Expand Down
1 change: 1 addition & 0 deletions README.fr.md
Expand Up @@ -177,6 +177,7 @@ Tout client supportant une API de type Google Reader. Sélection :

* Android
* [News+](https://play.google.com/store/apps/details?id=com.noinnion.android.newsplus) avec [News+ Google Reader extension](https://play.google.com/store/apps/details?id=com.noinnion.android.newsplus.extension.google_reader) (Propriétaire)
* [FeedMe 3.5.3+](https://play.google.com/store/apps/details?id=com.seazon.feedme) (Propriétaire)
* [EasyRSS](https://github.com/Alkarex/EasyRSS) (Libre, [F-Droid](https://f-droid.org/fr/packages/org.freshrss.easyrss/))
* GNU/Linux
* [FeedReader 2.0+](https://jangernert.github.io/FeedReader/) (Libre)
1 change: 1 addition & 0 deletions README.md
Expand Up @@ -183,6 +183,7 @@ Any client supporting a Google Reader-like API. Selection:

* Android
* [News+](https://play.google.com/store/apps/details?id=com.noinnion.android.newsplus) with [News+ Google Reader extension](https://play.google.com/store/apps/details?id=com.noinnion.android.newsplus.extension.google_reader) (Closed source)
* [FeedMe 3.5.3+](https://play.google.com/store/apps/details?id=com.seazon.feedme) (Closed source)
* [EasyRSS](https://github.com/Alkarex/EasyRSS) (Open source, [F-Droid](https://f-droid.org/packages/org.freshrss.easyrss/))
* GNU/Linux
* [FeedReader 2.0+](https://jangernert.github.io/FeedReader/) (Open source)
Expand Down
23 changes: 21 additions & 2 deletions app/Models/EntryDAO.php
Expand Up @@ -628,10 +628,12 @@ protected function sqlListEntriesWhere($alias = '', $filter = null, $state = Fre
$firstId = $order === 'DESC' ? '9000000000'. '000000' : '0';
}*/
if ($firstId !== '') {
$search .= 'AND ' . $alias . 'id ' . ($order === 'DESC' ? '<=' : '>=') . $firstId . ' ';
$search .= 'AND ' . $alias . 'id ' . ($order === 'DESC' ? '<=' : '>=') . ' ? ';
$values[] = $firstId;
}
if ($date_min > 0) {
$search .= 'AND ' . $alias . 'id >= ' . $date_min . '000000 ';
$search .= 'AND ' . $alias . 'id >= ? ';
$values[] = $date_min . '000000';
}
if ($filter) {
if ($filter->getMinDate()) {
Expand Down Expand Up @@ -781,6 +783,23 @@ public function listWhere($type = 'a', $id = '', $state = FreshRSS_Entry::STATE_
return self::daoToEntries($stm->fetchAll(PDO::FETCH_ASSOC));
}

public function listByIds($ids, $order = 'DESC') {
if (count($ids) < 1) {
return array();
}

$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 id IN (' . str_repeat('?,', count($ids) - 1). '?) '
. 'ORDER BY id ' . $order;

$stm = $this->bd->prepare($sql);
$stm->execute($ids);
return self::daoToEntries($stm->fetchAll(PDO::FETCH_ASSOC));
}

public function listIdsWhere($type = 'a', $id = '', $state = FreshRSS_Entry::STATE_ALL, $order = 'DESC', $limit = 1, $firstId = '', $filter = '', $date_min = 0) { //For API
list($values, $sql) = $this->sqlListWhere($type, $id, $state, $order, $limit, $firstId, $filter, $date_min);

Expand Down
1 change: 1 addition & 0 deletions docs/en/users/06_Mobile_access.md
Expand Up @@ -46,6 +46,7 @@ This page assumes you have completed the [server setup](../admins/02_Installatio
7. Pick a client supporting a Google Reader-like API. Selection:
* Android
* [News+](https://play.google.com/store/apps/details?id=com.noinnion.android.newsplus) with [News+ Google Reader extension](https://play.google.com/store/apps/details?id=com.noinnion.android.newsplus.extension.google_reader) (Closed source)
* [FeedMe 3.5.3+](https://play.google.com/store/apps/details?id=com.seazon.feedme) (Closed source)
* [EasyRSS](https://github.com/Alkarex/EasyRSS) (Open source, [F-Droid](https://f-droid.org/packages/org.freshrss.easyrss/))
* Linux
* [FeedReader 2.0+](https://jangernert.github.io/FeedReader/) (Open source)
1 change: 1 addition & 0 deletions docs/fr/users/06_Mobile_access.md
Expand Up @@ -44,6 +44,7 @@ Tout client supportant une API de type Google Reader. Sélection :

* Android
* [News+](https://play.google.com/store/apps/details?id=com.noinnion.android.newsplus) avec [News+ Google Reader extension](https://play.google.com/store/apps/details?id=com.noinnion.android.newsplus.extension.google_reader) (Propriétaire)
* [FeedMe 3.5.3+](https://play.google.com/store/apps/details?id=com.seazon.feedme) (Propriétaire)
* [EasyRSS](https://github.com/Alkarex/EasyRSS) (Libre, F-Droid)
* Linux
* [FeedReader 2.0+](https://jangernert.github.io/FeedReader/) (Libre)
179 changes: 121 additions & 58 deletions p/api/greader.php
Expand Up @@ -216,9 +216,13 @@ function token($conf) {
function checkToken($conf, $token) {
//http://code.google.com/p/google-reader-api/wiki/ActionToken
$user = Minz_Session::param('currentUser', '_');
if ($user !== '_' && $token == '') {
return true; //FeedMe //TODO: Check security consequences
}
if ($token === str_pad(sha1(FreshRSS_Context::$system_conf->salt . $user . $conf->apiPasswordHash), 57, 'Z')) {
return true;
}
Minz_Log::warning('Invalid POST token: ' . $token, API_LOG);
unauthorized();
}

Expand Down Expand Up @@ -266,6 +270,8 @@ function subscriptionList() {
$res = $stm->fetchAll(PDO::FETCH_ASSOC);

$salt = FreshRSS_Context::$system_conf->salt;
$faviconsUrl = Minz_Url::display('/f.php?', '', true);
$faviconsUrl = str_replace('/api/greader.php/reader/api/0/subscription', '', $faviconsUrl); //Security if base_url is not set properly
$subscriptions = array();

foreach ($res as $line) {
Expand All @@ -282,7 +288,7 @@ function subscriptionList() {
//'firstitemmsec' => 0,
'url' => $line['url'],
'htmlUrl' => $line['website'],
'iconUrl' => Minz_Url::display('/f.php?' . hash('crc32b', $salt . $line['url']), '', true),
'iconUrl' => $faviconsUrl . hash('crc32b', $salt . $line['url']),
);
}

Expand Down Expand Up @@ -324,6 +330,9 @@ function subscriptionEdit($streamNames, $titles, $action, $add = '', $remove = '
$addCatId = 1; //Default category
}
$feedDAO = FreshRSS_Factory::createFeedDao();
if (!is_array($streamNames) || count($streamNames) < 1) {
badRequest();
}
for ($i = count($streamNames) - 1; $i >= 0; $i--) {
$streamName = $streamNames[$i]; //feed/http://example.net/sample.xml ; feed/338
if (strpos($streamName, 'feed/') === 0) {
Expand Down Expand Up @@ -435,6 +444,51 @@ function unreadCount() { //http://blog.martindoms.com/2009/10/16/using-the-googl
exit();
}

function entriesToArray($entries) {
$items = array();
foreach ($entries as $entry) {
$f_id = $entry->feed();
if (isset($arrayFeedCategoryNames[$f_id])) {
$c_name = $arrayFeedCategoryNames[$f_id]['c_name'];
$f_name = $arrayFeedCategoryNames[$f_id]['name'];
} else {
$c_name = '_';
$f_name = '_';
}
$item = array(
'id' => /*'tag:google.com,2005:reader/item/' .*/ dec2hex($entry->id()), //64-bit hexa http://code.google.com/p/google-reader-api/wiki/ItemId
'crawlTimeMsec' => substr($entry->id(), 0, -3),
'timestampUsec' => '' . $entry->id(), //EasyRSS
'published' => $entry->date(true),
'title' => $entry->title(),
'summary' => array('content' => $entry->content()),
'alternate' => array(
array('href' => htmlspecialchars_decode($entry->link(), ENT_QUOTES)),
),
'categories' => array(
'user/-/state/com.google/reading-list',
'user/-/label/' . $c_name,
),
'origin' => array(
'streamId' => 'feed/' . $f_id,
'title' => $f_name, //EasyRSS
//'htmlUrl' => $line['f_website'],
),
);
if ($entry->author() != '') {
$item['author'] = $entry->author();
}
if ($entry->isRead()) {
$item['categories'][] = 'user/-/state/com.google/read';
}
if ($entry->isFavorite()) {
$item['categories'][] = 'user/-/state/com.google/starred';
}
$items[] = $item;
}
return $items;
}

function streamContents($path, $include_target, $start_time, $count, $order, $exclude_target, $continuation) {
//http://code.google.com/p/pyrfeed/wiki/GoogleReaderAPI
//http://blog.martindoms.com/2009/10/16/using-the-google-reader-api-part-2/#feed
Expand Down Expand Up @@ -476,73 +530,37 @@ function streamContents($path, $include_target, $start_time, $count, $order, $ex
break;
}

if (!empty($continuation)) {
if ($continuation != '') {
$count++; //Shift by one element
}

$entryDAO = FreshRSS_Factory::createEntryDao();
$entries = $entryDAO->listWhere($type, $include_target, $state, $order === 'o' ? 'ASC' : 'DESC', $count, $continuation, new FreshRSS_Search(''), $start_time);

$items = array();
foreach ($entries as $entry) {
$f_id = $entry->feed();
if (isset($arrayFeedCategoryNames[$f_id])) {
$c_name = $arrayFeedCategoryNames[$f_id]['c_name'];
$f_name = $arrayFeedCategoryNames[$f_id]['name'];
} else {
$c_name = '_';
$f_name = '_';
}
$item = array(
'id' => /*'tag:google.com,2005:reader/item/' .*/ dec2hex($entry->id()), //64-bit hexa http://code.google.com/p/google-reader-api/wiki/ItemId
'crawlTimeMsec' => substr($entry->id(), 0, -3),
'timestampUsec' => '' . $entry->id(), //EasyRSS
'published' => $entry->date(true),
'title' => $entry->title(),
'summary' => array('content' => $entry->content()),
'alternate' => array(
array('href' => htmlspecialchars_decode($entry->link(), ENT_QUOTES)),
),
'categories' => array(
'user/-/state/com.google/reading-list',
'user/-/label/' . $c_name,
),
'origin' => array(
'streamId' => 'feed/' . $f_id,
'title' => $f_name, //EasyRSS
//'htmlUrl' => $line['f_website'],
),
);
if ($entry->author() != '') {
$item['author'] = $entry->author();
}
if ($entry->isRead()) {
$item['categories'][] = 'user/-/state/com.google/read';
}
if ($entry->isFavorite()) {
$item['categories'][] = 'user/-/state/com.google/starred';
}
$items[] = $item;
}
$items = entriesToArray($entries);

if (!empty($continuation)) {
if ($continuation != '') {
array_shift($items); //Discard first element that was already sent in the previous response
$count--;
}

$response = array(
'id' => 'user/-/state/com.google/reading-list',
'updated' => time(),
'items' => $items,
);
if ((count($entries) >= $count) && (!empty($entry))) {
$response['continuation'] = $entry->id();
if (count($entries) >= $count) {
$entry = end($entries);
if ($entry != false) {
$response['continuation'] = $entry->id();
}
}

echo json_encode($response), "\n";
exit();
}

function streamContentsItemsIds($streamId, $start_time, $count, $order, $exclude_target) {
function streamContentsItemsIds($streamId, $start_time, $count, $order, $exclude_target, $continuation) {
//http://code.google.com/p/google-reader-api/wiki/ApiStreamItemsIds
//http://code.google.com/p/pyrfeed/wiki/GoogleReaderAPI
//http://blog.martindoms.com/2009/10/16/using-the-google-reader-api-part-2/#feed
Expand Down Expand Up @@ -572,8 +590,17 @@ function streamContentsItemsIds($streamId, $start_time, $count, $order, $exclude
break;
}

if ($continuation != '') {
$count++; //Shift by one element
}

$entryDAO = FreshRSS_Factory::createEntryDao();
$ids = $entryDAO->listIdsWhere($type, $id, $state, $order === 'o' ? 'ASC' : 'DESC', $count, '', new FreshRSS_Search(''), $start_time);
$ids = $entryDAO->listIdsWhere($type, $id, $state, $order === 'o' ? 'ASC' : 'DESC', $count, $continuation, new FreshRSS_Search(''), $start_time);

if ($continuation != '') {
array_shift($ids); //Discard first element that was already sent in the previous response
$count--;
}

if (empty($ids)) { //For News+ bug https://github.com/noinnion/newsplus/issues/84#issuecomment-57834632
$ids[] = 0;
Expand All @@ -585,9 +612,39 @@ function streamContentsItemsIds($streamId, $start_time, $count, $order, $exclude
);
}

echo json_encode(array(
$response = array(
'itemRefs' => $itemRefs,
)), "\n";
);
if (count($ids) >= $count) {
$id = end($ids);
if ($id != false) {
$response['continuation'] = $id;
}
}

echo json_encode($response), "\n";
exit();
}

function streamContentsItems($e_ids, $order) {
header('Content-Type: application/json; charset=UTF-8');

foreach ($e_ids as $i => $e_id) {
$e_ids[$i] = hex2dec(basename($e_id)); //Strip prefix 'tag:google.com,2005:reader/item/'
}

$entryDAO = FreshRSS_Factory::createEntryDao();
$entries = $entryDAO->listByIds($e_ids, $order === 'o' ? 'ASC' : 'DESC');

$items = entriesToArray($entries);

$response = array(
'id' => 'user/-/state/com.google/reading-list',
'updated' => time(),
'items' => $items,
);

echo json_encode($response), "\n";
exit();
}

Expand Down Expand Up @@ -726,7 +783,10 @@ function markAllAsRead($streamId, $olderThanId) {
* all items in a timestamp range, it will have a continuation attribute.
* The same request can be re-issued with the value of that attribute put
* in this parameter to get more items */
$continuation = isset($_GET['c']) ? $_GET['c'] : '';
$continuation = isset($_GET['c']) ? trim($_GET['c']) : '';
if (!ctype_digit($continuation)) {
$continuation = '';
}
if (isset($pathInfos[5]) && $pathInfos[5] === 'contents' && isset($pathInfos[6])) {
if (isset($pathInfos[7])) {
if ($pathInfos[6] === 'feed') {
Expand Down Expand Up @@ -755,7 +815,10 @@ function markAllAsRead($streamId, $olderThanId) {
* be repeated to fetch the item IDs from multiple streams at once
* (more efficient from a backend perspective than multiple requests). */
$streamId = $_GET['s'];
streamContentsItemsIds($streamId, $start_time, $count, $order, $exclude_target);
streamContentsItemsIds($streamId, $start_time, $count, $order, $exclude_target, $continuation);
} else if ($pathInfos[6] === 'contents' && isset($_POST['i'])) { //FeedMe
$e_ids = multiplePosts('i'); //item IDs
streamContentsItems($e_ids, $order);
}
}
break;
Expand All @@ -775,16 +838,16 @@ function markAllAsRead($streamId, $olderThanId) {
subscriptionList($_GET['output']);
break;
case 'edit':
if (isset($_POST['s']) && isset($_POST['ac'])) {
if (isset($_REQUEST['s']) && isset($_REQUEST['ac'])) {
//StreamId to operate on. The parameter may be repeated to edit multiple subscriptions at once
$streamNames = multiplePosts('s');
$streamNames = empty($_POST['s']) && isset($_GET['s']) ? array($_GET['s']) : multiplePosts('s');
/* Title to use for the subscription. For the `subscribe` action,
* if not specified then the feed's current title will be used. Can
* be used with the `edit` action to rename a subscription */
$titles = multiplePosts('t');
$action = $_POST['ac']; //Action to perform on the given StreamId. Possible values are `subscribe`, `unsubscribe` and `edit`
$add = isset($_POST['a']) ? $_POST['a'] : ''; //StreamId to add the subscription to (generally a user label)
$remove = isset($_POST['r']) ? $_POST['r'] : ''; //StreamId to remove the subscription from (generally a user label)
$titles = empty($_POST['t']) && isset($_GET['t']) ? array($_GET['t']) : multiplePosts('t');
$action = $_REQUEST['ac']; //Action to perform on the given StreamId. Possible values are `subscribe`, `unsubscribe` and `edit`
$add = isset($_REQUEST['a']) ? $_REQUEST['a'] : ''; //StreamId to add the subscription to (generally a user label)
$remove = isset($_REQUEST['r']) ? $_REQUEST['r'] : ''; //StreamId to remove the subscription from (generally a user label)
subscriptionEdit($streamNames, $titles, $action, $add, $remove);
}
break;
Expand Down

0 comments on commit 6648414

Please sign in to comment.