Просмотр исходного кода

Add Fever API and user documentation (#1836)

* added fever api and documentation

* spaces to tabs

* fixed code format

* added links

* added utf8 to header

* removed XML support

* removed before check, as we have to convert it afterwards

* added sandboxed setting (currently disabled)
added support for extensions using entry_before_display

* listFeedsOrderUpdate LIMIT

https://github.com/FreshRSS/FreshRSS/pull/1836/files#r175287881

* removed custom sql by using FreshRSS_FeedDAO::listFeedsOrderUpdate()

* fixed mark all as read

* replaced custom sql for getUnread() and getStarred() with dao functions

* removed sanitization functions

* Rework fever login

* Fix config bug

Plus documentation

* Fix array syntax

For compatibility with PHP 5.3

* Disable cookies and session for API

* Fix currentUser

* added response header and error log

* adjusted phpdoc to match new authentication

* Mechanism to delete old keys

* replace PHP_INT_MAX with zero to disable limit

* replace method_exists with check for explicit methods

* removed Press support and smaller refactoring + updated docu

* Rewrite bindParamArray

Avoid one of the SQL injection risks

* Docs and readme

* Fix API link

* Simplify reverse key check

Using userConfig
Kevin Papst 7 лет назад
Родитель
Сommit
8f1bad60d0

+ 39 - 10
README.fr.md

@@ -1,3 +1,6 @@
+[![Build Status][travis-badge]][travis-link]
+
+* Lire ce document sur [github.com/FreshRSS/FreshRSS/](https://github.com/FreshRSS/FreshRSS/blob/master/README.md) pour avoir les images et liens corrects.
 * [English version](README.md)
 
 # FreshRSS
@@ -54,6 +57,8 @@ Nous sommes une communauté amicale.
 6. Des paramètres de configuration avancée peuvent être vues dans [config.default.php](config.default.php) et modifiées dans `data/config.php`.
 7. Avec Apache, activer [`AllowEncodedSlashes`](https://httpd.apache.org/docs/trunk/mod/core.html#allowencodedslashes) pour une meilleure compatibilité avec les clients mobiles.
 
+Plus d’informations sur l’installation et la configuration serveur peuvent être trouvées dans [notre documentation](https://freshrss.github.io/FreshRSS/fr/users/01_Installation.md).
+
 ## Installation automatisée
 * [Docker](./Docker/)
 * [![Cloudron](https://cloudron.io/img/button.svg)](https://cloudron.io/button.html?app=org.freshrss.cloudronapp)
@@ -107,6 +112,8 @@ sudo git pull
 sudo chown -R :www-data . && sudo chmod -R g+r . && sudo chmod -R g+w ./data/
 ```
 
+Voir la [documentation de la ligne de commande](cli/README.md) pour plus de détails.
+
 ## Contrôle d’accès
 Il est requis pour le mode multi-utilisateur, et recommandé dans tous les cas, de limiter l’accès à votre FreshRSS. Au choix :
 * En utilisant l’identification par formulaire (requiert JavaScript, et PHP 5.5+ recommandé)
@@ -150,11 +157,42 @@ mysqldump --skip-comments --disable-keys --user=<db_user> --password --host <db_
 ```
 
 
-# Extensions 
+# Extensions
 FreshRSS permet l’ajout d’extensions en plus des fonctionnalités natives.
 Voir le [dépôt dédié à ces extensions](https://github.com/FreshRSS/Extensions).
 
 
+# APIs et applications natives
+
+FreshRSS supporte l’accès depuis des applications native pour Linux, Android, iOS, et OS X, grâce à deux APIs distinctes.
+
+## Via l’API compatible Google Reader
+
+Voir notre [documentation sur l’accès mobile](https://freshrss.github.io/FreshRSS/fr/users/06_Mobile_access.html).
+
+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)
+
+## Via l’API compatible Fever
+
+Voir notre [documentation sur l’API Fever](https://freshrss.github.io/FreshRSS/fr/users/06_Fever_API.html) page.
+
+Tout client supportant une API de type Fever ; Sélection :
+
+* iOS
+	* [Fiery Feeds](https://itunes.apple.com/app/fiery-feeds-rss-reader/id1158763303)
+	* [Unread](https://itunes.apple.com/app/unread-rss-reader/id1252376153)
+
+* MacOS:
+	* [Readkit](https://itunes.apple.com/app/readkit/id588726889)
+
+
 # Bibliothèques incluses
 * [SimplePie](https://simplepie.org/)
 * [MINZ](https://github.com/marienfressinaud/MINZ)
@@ -174,12 +212,3 @@ Voir le [dépôt dédié à ces extensions](https://github.com/FreshRSS/Extensio
 * [password_compat](https://github.com/ircmaxell/password_compat)
 
 
-# [Clients compatibles](https://freshrss.github.io/FreshRSS/fr/users/06_Mobile_access.html)
-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)

+ 35 - 14
README.md

@@ -57,7 +57,7 @@ We are a friendly community.
 6. Advanced configuration settings can be seen in [config.default.php](config.default.php) and modified in `data/config.php`.
 7. When using Apache, enable [`AllowEncodedSlashes`](https://httpd.apache.org/docs/trunk/mod/core.html#allowencodedslashes) for better compatibility with mobile clients.
 
-More information about installation and server configuration can be found in [our documentation](https://freshrss.github.io/FreshRSS/en/admins/02_Installation.html). 
+More information about installation and server configuration can be found in [our documentation](https://freshrss.github.io/FreshRSS/en/admins/02_Installation.html).
 
 ## Automated install
 * [Docker](./Docker/)
@@ -111,6 +111,7 @@ cd /usr/share/FreshRSS
 sudo git pull
 sudo chown -R :www-data . && sudo chmod -R g+r . && sudo chmod -R g+w ./data/
 ```
+
 See more commands and git commands in the [Command-Line Interface documentation](cli/README.md).
 
 ## Access control
@@ -156,9 +157,40 @@ mysqldump --skip-comments --disable-keys --user=<db_user> --password --host <db_
 ```
 
 
-# Extensions 
+# Extensions
 FreshRSS supports further customizations by adding extensions on top of its core functionality.
-See the [repository dedicated to those extensions](https://github.com/FreshRSS/Extensions). 
+See the [repository dedicated to those extensions](https://github.com/FreshRSS/Extensions).
+
+
+# APIs & native apps
+
+FreshRSS supports access from native apps for Linux, Android, iOS, and OS X, via two distinct APIs.
+
+## Google Reader-like API
+
+There is more information available about our Google Reader compatible API on the page [mobile access](https://freshrss.github.io/FreshRSS/en/users/06_Mobile_access.html).
+
+Supported clients are:
+
+* 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)
+
+## Fever API
+
+See our [Fever API documentation](https://freshrss.github.io/FreshRSS/en/users/06_Fever_API.html) page.
+
+Supported clients are:
+
+* iOS
+	* [Fiery Feeds](https://itunes.apple.com/app/fiery-feeds-rss-reader/id1158763303)
+	* [Unread](https://itunes.apple.com/app/unread-rss-reader/id1252376153)
+
+* MacOS:
+	* [Readkit](https://itunes.apple.com/app/readkit/id588726889)
 
 
 # Included libraries
@@ -179,16 +211,5 @@ See the [repository dedicated to those extensions](https://github.com/FreshRSS/E
 * [Services_JSON](https://pear.php.net/pepr/pepr-proposal-show.php?id=198)
 * [password_compat](https://github.com/ircmaxell/password_compat)
 
-
-# [Compatible clients](https://freshrss.github.io/FreshRSS/en/users/06_Mobile_access.html)
-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)
-
 [travis-badge]:https://travis-ci.org/FreshRSS/FreshRSS.svg?branch=master
 [travis-link]:https://travis-ci.org/FreshRSS/FreshRSS

+ 19 - 0
app/Controllers/userController.php

@@ -44,6 +44,14 @@ class FreshRSS_user_Controller extends Minz_ActionController {
 		return preg_match('/^' . self::USERNAME_PATTERN . '$/', $username) === 1;
 	}
 
+	public static function deleteFeverKey($username) {
+		$userConfig = get_user_configuration($username);
+		if ($userConfig !== null && ctype_xdigit($userConfig->feverKey)) {
+			return @unlink(DATA_PATH . '/fever/.key-' . sha1(FreshRSS_Context::$system_conf->salt) . '-' . $userConfig->feverKey . '.txt');
+		}
+		return false;
+	}
+
 	public static function updateUser($user, $passwordPlain, $apiPasswordPlain, $userConfigUpdated = array()) {
 		$userConfig = get_user_configuration($user);
 		if ($userConfig === null) {
@@ -58,6 +66,16 @@ class FreshRSS_user_Controller extends Minz_ActionController {
 		if ($apiPasswordPlain != '') {
 			$apiPasswordHash = self::hashPassword($apiPasswordPlain);
 			$userConfig->apiPasswordHash = $apiPasswordHash;
+
+			@mkdir(DATA_PATH . '/fever/', 0770, true);
+			self::deleteFeverKey($user);
+			$userConfig->feverKey = strtolower(md5($user . ':' . $apiPasswordPlain));
+			$ok = file_put_contents(DATA_PATH . '/fever/.key-' . sha1(FreshRSS_Context::$system_conf->salt) . '-' . $userConfig->feverKey . '.txt', $user) !== false;
+
+			if (!$ok) {
+				Minz_Log::warning('Could not save API credentials for fever API', ADMIN_LOG);
+				return $ok;
+			}
 		}
 
 		if (is_array($userConfigUpdated)) {
@@ -258,6 +276,7 @@ class FreshRSS_user_Controller extends Minz_ActionController {
 			$ok &= $userDAO->deleteUser($username);
 			$ok &= recursive_unlink($user_data);
 			array_map('unlink', glob(PSHB_PATH . '/feeds/*/' . $username . '.txt'));
+			self::deleteFeverKey();
 		}
 		return $ok;
 	}

+ 1 - 1
app/Models/EntryDAO.php

@@ -801,7 +801,7 @@ class FreshRSS_EntryDAO extends Minz_ModelPdo implements FreshRSS_Searchable {
 			. 'WHERE ' . $where
 			. $search
 			. 'ORDER BY e.id ' . $order
-			. ($limit > 0 ? ' LIMIT ' . $limit : ''));	//TODO: See http://explainextended.com/2009/10/23/mysql-order-by-limit-performance-late-row-lookups/
+			. ($limit > 0 ? ' LIMIT ' . intval($limit) : ''));	//TODO: See http://explainextended.com/2009/10/23/mysql-order-by-limit-performance-late-row-lookups/
 	}
 
 	public function listWhereRaw($type = 'a', $id = '', $state = FreshRSS_Entry::STATE_ALL, $order = 'DESC', $limit = 1, $firstId = '', $filters = null, $date_min = 0) {

+ 3 - 2
app/Models/FeedDAO.php

@@ -299,13 +299,14 @@ class FreshRSS_FeedDAO extends Minz_ModelPdo implements FreshRSS_Searchable {
 	/**
 	 * Use $defaultCacheDuration == -1 to return all feeds, without filtering them by TTL.
 	 */
-	public function listFeedsOrderUpdate($defaultCacheDuration = 3600) {
+	public function listFeedsOrderUpdate($defaultCacheDuration = 3600, $limit = 0) {
 		$this->updateTTL();
 		$sql = 'SELECT id, url, name, website, `lastUpdate`, `pathEntries`, `httpAuth`, keep_history, ttl, attributes '
 		     . 'FROM `' . $this->prefix . 'feed` '
 		     . ($defaultCacheDuration < 0 ? '' : 'WHERE ttl >= ' . FreshRSS_Feed::TTL_DEFAULT
 		     . ' AND `lastUpdate` < (' . (time() + 60) . '-(CASE WHEN ttl=' . FreshRSS_Feed::TTL_DEFAULT . ' THEN ' . intval($defaultCacheDuration) . ' ELSE ttl END)) ')
-		     . 'ORDER BY `lastUpdate`';
+		     . 'ORDER BY `lastUpdate` '
+		     . ($limit < 1 ? '' : 'LIMIT ' . intval($limit));
 		$stm = $this->bd->prepare($sql);
 		if ($stm && $stm->execute()) {
 			return self::daoToFeed($stm->fetchAll(PDO::FETCH_ASSOC));

+ 1 - 0
cli/prepare.php

@@ -7,6 +7,7 @@ $dirs = array(
 	'/cache',
 	'/extensions-data',
 	'/favicons',
+	'/fever',
 	'/PubSubHubbub',
 	'/PubSubHubbub/feeds',
 	'/PubSubHubbub/keys',

+ 2 - 0
config-user.default.php

@@ -9,6 +9,8 @@ return array (
 	'token' => '',
 	'passwordHash' => '',
 	'apiPasswordHash' => '',
+	//feverKey is md5($user . ':' . $apiPasswordPlain)
+	'feverKey' => '',
 	'posts_per_page' => 20,
 	'since_hours_posts_per_rss' => 168,
 	'min_posts_per_rss' => 2,

+ 1 - 0
data/fever/.gitignore

@@ -0,0 +1 @@
+*.txt

+ 13 - 0
data/fever/index.html

@@ -0,0 +1,13 @@
+<!DOCTYPE html>
+<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en-GB" lang="en-GB">
+<head>
+<meta charset="UTF-8" />
+<meta http-equiv="Refresh" content="0; url=/" />
+<title>Redirection</title>
+<meta name="robots" content="noindex" />
+</head>
+
+<body>
+<p><a href="/">Redirection</a></p>
+</body>
+</html>

+ 110 - 0
docs/en/users/06_Fever_API.md

@@ -0,0 +1,110 @@
+# FreshRSS - Fever API implementation
+
+## RSS clients
+
+There are many RSS clients existing supporting Fever APIs but they seem to understand the Fever API a bit differently.
+If your favourite client does not work properly with this API, create an issue and we will have a look.
+But we can **only** do that for free clients.
+
+### Usage & Authentication
+
+Before you can start to use this API, you have to enable and setup API access, which is [documented here](https://freshrss.github.io/FreshRSS/en/users/06_Mobile_access.html),
+and then re-set the user’s API password.
+
+Then point your mobile application to the URL of `fever.php` (e.g. `https://freshrss.example.net/api/fever.php`).
+
+## Compatibility
+
+Tested with:
+
+- iOS
+  - [Fiery Feeds](https://itunes.apple.com/app/fiery-feeds-rss-reader/id1158763303)
+  - [Unread](https://itunes.apple.com/app/unread-rss-reader/id1252376153)
+
+- MacOS
+  - [Readkit](https://itunes.apple.com/app/readkit/id588726889)
+
+- Android
+  -Until now, we don't know about compatible Android clients. Please leave your feedback, if you tested the Fever API with Android apps.
+  - Please note, that *Press* is NOT compatible: it was a popular RSS client with Fever support, but its development stopped a while ago. It uses the Fever API in a wrong way, which we don't support.
+
+## Features
+
+Following features are implemented:
+
+- fetching categories
+- fetching feeds
+- fetching RSS items (new, favorites, unread, by_id, by_feed, by_category, since)
+- fetching favicons
+- setting read marker for item(s)
+- setting starred marker for item(s)
+- setting read marker for feed
+- setting read marker for category
+- supports FreshRSS extensions, which use th `entry_before_display` hook
+
+Following features are not supported:
+- **Hot Links** aka **hot** as there is nothing in FreshRSS yet that is similar or could be used to simulate it
+
+## Testing and error search
+
+If this API does not work as expected in your RSS reader, you can test it manually with a tool like [Postman](https://www.getpostman.com/).
+
+Configure a POST request to the URL https://freshrss.example.net/api/fever.php?api which should give you the result:
+```json
+{
+	"api_version": 3,
+	"auth": 0
+}
+```
+Great, the base setup seems to work!
+
+Now lets try an authenticated call. Fever uses an `api_key`, which is the MD5 hash of `"$username:$apiPassword"`.
+Assuming the user is `kevin` and the password `freshrss`, here is a command-line example to compute the resulting `api_key`
+
+```sh
+api_key=`echo -n "kevin:freshrss" | md5sum | cut -d' ' -f1`
+```
+
+Add a body to your POST request encoded as `form-data` and one key named `api_key` with the value `your-password-hash`:
+
+```sh
+curl -s -F "api_key=$api_key" 'https://freshrss.example.net/api/fever.php?api'
+```
+
+This shoud give:
+```json
+{
+	"api_version": 3,
+	"auth": 1,
+	"last_refreshed_on_time": "1520013061"
+}
+```
+Perfect, you are authenticated and can now start testing the more advanced features. Therefor change the URL and append the possible API actions to your request parameters. Check the [original Fever documentation](https://feedafever.com/api) for more infos.
+
+Some basic calls are:
+
+- https://freshrss.example.net/api/fever.php?api&items
+- https://freshrss.example.net/api/fever.php?api&feeds
+- https://freshrss.example.net/api/fever.php?api&groups
+- https://freshrss.example.net/api/fever.php?api&unread_item_ids
+- https://freshrss.example.net/api/fever.php?api&saved_item_ids
+- https://freshrss.example.net/api/fever.php?api&items&since_id=some_id
+- https://freshrss.example.net/api/fever.php?api&items&max_id=some_id
+- https://freshrss.example.net/api/fever.php?api&mark=item&as=read&id=some_id
+- https://freshrss.example.net/api/fever.php?api&mark=item&as=unread&id=some_id
+
+Replace `some_id` with a real ID from your `freshrss_username_entry` database.
+
+### Debugging
+
+If nothing helps and your clients still misbehaves, add these lines to the start of `fever.api`:
+
+```php
+file_put_contents(__DIR__ . '/fever.log', $_SERVER['HTTP_USER_AGENT'] . ': ' . json_encode($_REQUEST) . PHP_EOL, FILE_APPEND);
+```
+
+Then use your RSS client to query the API and afterwards check the file `fever.log`.
+
+## Credits
+
+This plugin was inspired by the [tinytinyrss-fever-plugin](https://github.com/dasmurphy/tinytinyrss-fever-plugin).

+ 17 - 0
docs/fr/users/06_Fever_API.md

@@ -0,0 +1,17 @@
+# FreshRSS - API compatible Fever
+
+
+## Compatibilité
+
+Testé avec:
+
+- iOS
+  - [Fiery Feeds](https://itunes.apple.com/app/fiery-feeds-rss-reader/id1158763303)
+  - [Unread](https://itunes.apple.com/app/unread-rss-reader/id1252376153)
+
+- MacOS
+  - [Readkit](https://itunes.apple.com/app/readkit/id588726889)
+
+## TODO
+
+Voir [la page en anglais](../../en/users/06_Fever_API.md).

+ 634 - 0
p/api/fever.php

@@ -0,0 +1,634 @@
+<?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());
+}

+ 2 - 0
p/api/greader.php

@@ -745,6 +745,8 @@ if (!FreshRSS_Context::$system_conf->api_enabled) {
 	serviceUnavailable();
 }
 
+ini_set('session.use_cookies', '0');
+register_shutdown_function('session_destroy');
 Minz_Session::init('FreshRSS');
 
 $user = authorizationToUser();

+ 11 - 0
p/api/index.php

@@ -26,5 +26,16 @@ echo Minz_Url::display('/api/greader.php', 'html', true);
 configuration (without <code>%2F</code> support)</a></li>
 </ul>
 
+<h2>Fever compatible API</h2>
+<dl>
+<dt>Your API address:</dt>
+<dd><?php
+echo Minz_Url::display('/api/fever.php', 'html', true);
+?></dd>
+</dl>
+<ul>
+<li><a href="fever.php?api" rel="nofollow">Test</a></li>
+</ul>
+
 </body>
 </html>