Explorar o código

API /reader/api/0/stream/items/contents (#1774)

* 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
Alexandre Alapetite %!s(int64=8) %!d(string=hai) anos
pai
achega
79f8b440d1
Modificáronse 7 ficheiros con 149 adicións e 61 borrados
  1. 3 1
      CHANGELOG.md
  2. 1 0
      README.fr.md
  3. 1 0
      README.md
  4. 21 2
      app/Models/EntryDAO.php
  5. 1 0
      docs/en/users/06_Mobile_access.md
  6. 1 0
      docs/fr/users/06_Mobile_access.md
  7. 121 58
      p/api/greader.php

+ 3 - 1
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

+ 1 - 0
README.fr.md

@@ -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 - 0
README.md

@@ -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)

+ 21 - 2
app/Models/EntryDAO.php

@@ -628,10 +628,12 @@ class FreshRSS_EntryDAO extends Minz_ModelPdo implements FreshRSS_Searchable {
 			$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()) {
@@ -781,6 +783,23 @@ class FreshRSS_EntryDAO extends Minz_ModelPdo implements FreshRSS_Searchable {
 		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);
 

+ 1 - 0
docs/en/users/06_Mobile_access.md

@@ -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 - 0
docs/fr/users/06_Mobile_access.md

@@ -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)

+ 121 - 58
p/api/greader.php

@@ -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();
 }
 
@@ -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) {
@@ -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']),
 		);
 	}
 
@@ -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) {
@@ -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
@@ -476,57 +530,18 @@ 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(
@@ -534,15 +549,18 @@ function streamContents($path, $include_target, $start_time, $count, $order, $ex
 		'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
@@ -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;
@@ -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();
 }
 
@@ -726,7 +783,10 @@ if (count($pathInfos) < 3) {
 			 * 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') {
@@ -755,7 +815,10 @@ if (count($pathInfos) < 3) {
 					 * 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;
@@ -775,16 +838,16 @@ if (count($pathInfos) < 3) {
 						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;