2020-11-01 01:26:11 +00:00
< ? php
/** @ license MIT
* Copyright 2017 J . King , Dustin Wilson et al .
* See LICENSE and AUTHORS files for details */
declare ( strict_types = 1 );
namespace JKingWeb\Arsse\REST\Miniflux ;
use JKingWeb\Arsse\Arsse ;
2020-12-02 23:00:27 +00:00
use JKingWeb\Arsse\Feed ;
2021-01-31 02:37:19 +00:00
use JKingWeb\Arsse\ExceptionType ;
2020-12-02 23:00:27 +00:00
use JKingWeb\Arsse\Feed\Exception as FeedException ;
2020-11-01 01:26:11 +00:00
use JKingWeb\Arsse\AbstractException ;
2020-12-14 17:41:09 +00:00
use JKingWeb\Arsse\Context\Context ;
2020-11-30 15:52:32 +00:00
use JKingWeb\Arsse\Db\ExceptionInput ;
2021-02-06 01:29:41 +00:00
use JKingWeb\Arsse\ImportExport\OPML ;
use JKingWeb\Arsse\ImportExport\Exception as ImportException ;
2020-12-08 20:34:31 +00:00
use JKingWeb\Arsse\Misc\Date ;
2021-01-20 04:17:03 +00:00
use JKingWeb\Arsse\Misc\URL ;
2020-12-02 23:00:27 +00:00
use JKingWeb\Arsse\Misc\ValueInfo as V ;
2020-11-01 01:26:11 +00:00
use JKingWeb\Arsse\REST\Exception ;
2021-01-20 04:17:03 +00:00
use JKingWeb\Arsse\Rule\Rule ;
2020-12-28 13:12:30 +00:00
use JKingWeb\Arsse\User\ExceptionConflict ;
use JKingWeb\Arsse\User\Exception as UserException ;
2020-11-01 01:26:11 +00:00
use Psr\Http\Message\ServerRequestInterface ;
use Psr\Http\Message\ResponseInterface ;
use Laminas\Diactoros\Response\EmptyResponse ;
2020-12-02 23:00:27 +00:00
use Laminas\Diactoros\Response\JsonResponse as Response ;
2021-02-06 01:29:41 +00:00
use Laminas\Diactoros\Response\TextResponse as GenericResponse ;
2021-01-17 03:52:07 +00:00
use Laminas\Diactoros\Uri ;
2020-11-01 01:26:11 +00:00
class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
2021-02-09 04:52:13 +00:00
public const VERSION = " 2.0.28 " ;
2020-12-02 23:00:27 +00:00
2020-12-01 17:08:45 +00:00
protected const ACCEPTED_TYPES_OPML = [ " application/xml " , " text/xml " , " text/x-opml " ];
protected const ACCEPTED_TYPES_JSON = [ " application/json " ];
2021-02-04 22:07:22 +00:00
protected const DEFAULT_ENTRY_LIMIT = 100 ;
protected const DEFAULT_ORDER_COL = " modified_date " ;
2021-02-03 21:27:55 +00:00
protected const DATE_FORMAT_SEC = " Y-m-d \T H:i:sP " ;
protected const DATE_FORMAT_MICRO = " Y-m-d \T H:i:s.uP " ;
2021-01-30 18:38:02 +00:00
protected const VALID_QUERY = [
'status' => V :: T_STRING + V :: M_ARRAY ,
'offset' => V :: T_INT ,
'limit' => V :: T_INT ,
'order' => V :: T_STRING ,
'direction' => V :: T_STRING ,
'before' => V :: T_DATE , // Unix timestamp
'after' => V :: T_DATE , // Unix timestamp
'before_entry_id' => V :: T_INT ,
'after_entry_id' => V :: T_INT ,
2021-01-31 15:44:27 +00:00
'starred' => V :: T_MIXED , // the presence of the starred key is the only thing considered by Miniflux
2021-01-30 18:38:02 +00:00
'search' => V :: T_STRING ,
'category_id' => V :: T_INT ,
];
2020-12-02 23:00:27 +00:00
protected const VALID_JSON = [
2021-01-20 04:17:03 +00:00
// user properties which map directly to Arsse user metadata are listed separately;
2021-02-09 00:14:11 +00:00
// not all these properties are used by our implementation, but they are treated
2021-01-20 04:17:03 +00:00
// with the same strictness as in Miniflux to ease cross-compatibility
'url' => " string " ,
'username' => " string " ,
'password' => " string " ,
'user_agent' => " string " ,
'title' => " string " ,
'feed_url' => " string " ,
'category_id' => " integer " ,
'crawler' => " boolean " ,
'user_agent' => " string " ,
'scraper_rules' => " string " ,
'rewrite_rules' => " string " ,
'keeplist_rules' => " string " ,
'blocklist_rules' => " string " ,
'disabled' => " boolean " ,
'ignore_http_cache' => " boolean " ,
'fetch_via_proxy' => " boolean " ,
2021-02-05 13:48:14 +00:00
'entry_ids' => " array " , // this is a special case: it is an array of integers
'status' => " string " ,
2020-12-02 23:00:27 +00:00
];
2020-12-28 13:12:30 +00:00
protected const USER_META_MAP = [
2021-01-22 23:24:33 +00:00
// Miniflux ID // Arsse ID Default value
'is_admin' => [ " admin " , false ],
'theme' => [ " theme " , " light_serif " ],
'language' => [ " lang " , " en_US " ],
'timezone' => [ " tz " , " UTC " ],
'entry_sorting_direction' => [ " sort_asc " , false ],
'entries_per_page' => [ " page_size " , 100 ],
'keyboard_shortcuts' => [ " shortcuts " , true ],
'show_reading_time' => [ " reading_time " , true ],
'entry_swipe' => [ " swipe " , true ],
'stylesheet' => [ " stylesheet " , " " ],
2020-12-28 13:12:30 +00:00
];
2021-01-25 01:28:00 +00:00
/** A map between Miniflux ' s input properties and our input properties when modifiying feeds
2021-02-09 00:14:11 +00:00
*
2021-01-25 01:28:00 +00:00
* Miniflux also allows changing the following properties :
*
* - feed_url
* - username
* - password
* - user_agent
* - scraper_rules
* - rewrite_rules
* - disabled
* - ignore_http_cache
* - fetch_via_proxy
*
* These either do not apply because we have no cache or proxy ,
* or cannot be changed because feeds are deduplicated and changing
* how they are fetched is not practical with our implementation .
* The properties are still checked for type and syntactic validity
2021-02-09 00:14:11 +00:00
* where practical , on the assumption Miniflux would also reject
2021-01-25 01:28:00 +00:00
* invalid values .
*/
protected const FEED_META_MAP = [
'title' => " title " ,
'category_id' => " folder " ,
'crawler' => " scrape " ,
'keeplist_rules' => " keep_rule " ,
'blocklist_rules' => " block_rule " ,
];
2021-02-02 21:05:16 +00:00
protected const ARTICLE_COLUMNS = [
2021-02-09 00:14:11 +00:00
" id " , " url " , " title " , " subscription " ,
2021-02-03 18:06:36 +00:00
" author " , " fingerprint " ,
2021-02-09 00:14:11 +00:00
" published_date " , " modified_date " ,
2021-02-03 18:06:36 +00:00
" starred " , " unread " , " hidden " ,
2021-02-09 00:14:11 +00:00
" content " , " media_url " , " media_type " ,
2021-02-02 21:05:16 +00:00
];
2020-12-31 20:46:47 +00:00
protected const CALLS = [ // handler method Admin Path Body Query Required fields
2020-12-14 17:41:09 +00:00
'/categories' => [
2020-12-31 20:46:47 +00:00
'GET' => [ " getCategories " , false , false , false , false , []],
'POST' => [ " createCategory " , false , false , true , false , [ " title " ]],
2020-12-14 17:41:09 +00:00
],
'/categories/1' => [
2020-12-31 20:46:47 +00:00
'PUT' => [ " updateCategory " , false , true , true , false , [ " title " ]], // title is effectively required since no other field can be changed
'DELETE' => [ " deleteCategory " , false , true , false , false , []],
2020-12-14 17:41:09 +00:00
],
2021-01-21 16:11:25 +00:00
'/categories/1/entries' => [
2021-01-31 02:37:19 +00:00
'GET' => [ " getCategoryEntries " , false , true , false , true , []],
2021-01-21 16:11:25 +00:00
],
'/categories/1/entries/1' => [
2021-01-22 23:24:33 +00:00
'GET' => [ " getCategoryEntry " , false , true , false , false , []],
2021-01-21 16:11:25 +00:00
],
'/categories/1/feeds' => [
2021-01-22 23:24:33 +00:00
'GET' => [ " getCategoryFeeds " , false , true , false , false , []],
2021-01-21 16:11:25 +00:00
],
2020-12-14 17:41:09 +00:00
'/categories/1/mark-all-as-read' => [
2021-02-07 04:55:40 +00:00
'PUT' => [ " markCategory " , false , true , false , false , []],
2020-12-14 17:41:09 +00:00
],
'/discover' => [
2020-12-31 20:46:47 +00:00
'POST' => [ " discoverSubscriptions " , false , false , true , false , [ " url " ]],
2020-12-14 17:41:09 +00:00
],
'/entries' => [
2020-12-31 20:46:47 +00:00
'GET' => [ " getEntries " , false , false , false , true , []],
2021-02-05 13:48:14 +00:00
'PUT' => [ " updateEntries " , false , false , true , false , [ " entry_ids " , " status " ]],
2020-12-14 17:41:09 +00:00
],
'/entries/1' => [
2020-12-31 20:46:47 +00:00
'GET' => [ " getEntry " , false , true , false , false , []],
2020-12-14 17:41:09 +00:00
],
'/entries/1/bookmark' => [
2020-12-31 20:46:47 +00:00
'PUT' => [ " toggleEntryBookmark " , false , true , false , false , []],
2020-12-14 17:41:09 +00:00
],
'/export' => [
2020-12-31 20:46:47 +00:00
'GET' => [ " opmlExport " , false , false , false , false , []],
2020-12-14 17:41:09 +00:00
],
'/feeds' => [
2020-12-31 20:46:47 +00:00
'GET' => [ " getFeeds " , false , false , false , false , []],
2021-01-20 04:17:03 +00:00
'POST' => [ " createFeed " , false , false , true , false , [ " feed_url " , " category_id " ]],
2020-12-14 17:41:09 +00:00
],
'/feeds/1' => [
2020-12-31 20:46:47 +00:00
'GET' => [ " getFeed " , false , true , false , false , []],
'PUT' => [ " updateFeed " , false , true , true , false , []],
'DELETE' => [ " deleteFeed " , false , true , false , false , []],
2020-12-14 17:41:09 +00:00
],
'/feeds/1/entries' => [
2021-01-31 02:37:19 +00:00
'GET' => [ " getFeedEntries " , false , true , false , true , []],
2020-12-14 17:41:09 +00:00
],
'/feeds/1/entries/1' => [
2020-12-31 20:46:47 +00:00
'GET' => [ " getFeedEntry " , false , true , false , false , []],
2020-12-14 17:41:09 +00:00
],
'/feeds/1/icon' => [
2020-12-31 20:46:47 +00:00
'GET' => [ " getFeedIcon " , false , true , false , false , []],
2020-12-14 17:41:09 +00:00
],
'/feeds/1/mark-all-as-read' => [
2020-12-31 20:46:47 +00:00
'PUT' => [ " markFeed " , false , true , false , false , []],
2020-12-14 17:41:09 +00:00
],
'/feeds/1/refresh' => [
2020-12-31 20:46:47 +00:00
'PUT' => [ " refreshFeed " , false , true , false , false , []],
2020-12-14 17:41:09 +00:00
],
'/feeds/refresh' => [
2020-12-31 20:46:47 +00:00
'PUT' => [ " refreshAllFeeds " , false , false , false , false , []],
2020-12-14 17:41:09 +00:00
],
'/import' => [
2020-12-31 20:46:47 +00:00
'POST' => [ " opmlImport " , false , false , true , false , []],
2020-12-14 17:41:09 +00:00
],
'/me' => [
2020-12-31 20:46:47 +00:00
'GET' => [ " getCurrentUser " , false , false , false , false , []],
2020-12-14 17:41:09 +00:00
],
'/users' => [
2020-12-31 20:46:47 +00:00
'GET' => [ " getUsers " , true , false , false , false , []],
'POST' => [ " createUser " , true , false , true , false , [ " username " , " password " ]],
2020-12-14 17:41:09 +00:00
],
'/users/1' => [
2020-12-31 20:46:47 +00:00
'GET' => [ " getUserByNum " , true , true , false , false , []],
'PUT' => [ " updateUserByNum " , false , true , true , false , []], // requires admin for users other than self
'DELETE' => [ " deleteUserByNum " , true , true , false , false , []],
2020-12-14 17:41:09 +00:00
],
'/users/1/mark-all-as-read' => [
2020-12-31 20:46:47 +00:00
'PUT' => [ " markUserByNum " , false , true , false , false , []],
2020-12-14 17:41:09 +00:00
],
'/users/*' => [
2020-12-31 20:46:47 +00:00
'GET' => [ " getUserById " , true , true , false , false , []],
2020-12-14 17:41:09 +00:00
],
2020-11-01 01:26:11 +00:00
];
public function __construct () {
}
2020-11-23 14:31:50 +00:00
protected function authenticate ( ServerRequestInterface $req ) : bool {
// first check any tokens; this is what Miniflux does
2020-11-30 15:52:32 +00:00
if ( $req -> hasHeader ( " X-Auth-Token " )) {
$t = $req -> getHeader ( " X-Auth-Token " )[ 0 ]; // consider only the first token
if ( strlen ( $t )) { // and only if it is not blank
2020-11-23 14:31:50 +00:00
try {
$d = Arsse :: $db -> tokenLookup ( " miniflux.login " , $t );
} catch ( ExceptionInput $e ) {
return false ;
}
2020-11-30 15:52:32 +00:00
Arsse :: $user -> id = $d [ 'user' ];
2020-11-23 14:31:50 +00:00
return true ;
}
}
2020-12-22 21:13:12 +00:00
// next check HTTP auth
2020-11-01 01:26:11 +00:00
if ( $req -> getAttribute ( " authenticated " , false )) {
Arsse :: $user -> id = $req -> getAttribute ( " authenticatedUser " );
2020-11-30 15:52:32 +00:00
return true ;
2020-11-23 14:31:50 +00:00
}
return false ;
}
public function dispatch ( ServerRequestInterface $req ) : ResponseInterface {
2020-11-01 01:26:11 +00:00
// get the request path only; this is assumed to already be normalized
2021-01-31 02:37:19 +00:00
$target = parse_url ( $req -> getRequestTarget (), \PHP_URL_PATH ) ? ? " " ;
2020-11-02 00:09:17 +00:00
$method = $req -> getMethod ();
2020-11-01 01:26:11 +00:00
// handle HTTP OPTIONS requests
2020-11-02 00:09:17 +00:00
if ( $method === " OPTIONS " ) {
2020-11-01 01:26:11 +00:00
return $this -> handleHTTPOptions ( $target );
}
2021-03-03 21:46:57 +00:00
// try to authenticate
if ( ! $this -> authenticate ( $req )) {
return new ErrorResponse ( " 401 " , 401 );
}
2020-11-02 00:09:17 +00:00
$func = $this -> chooseCall ( $target , $method );
2020-12-01 16:06:29 +00:00
if ( $func instanceof ResponseInterface ) {
return $func ;
2020-12-14 17:41:09 +00:00
} else {
2020-12-31 20:46:47 +00:00
[ $func , $reqAdmin , $reqPath , $reqBody , $reqQuery , $reqFields ] = $func ;
2020-12-01 16:06:29 +00:00
}
2020-12-14 17:41:09 +00:00
if ( $reqAdmin && ! $this -> isAdmin ()) {
2020-12-08 20:34:31 +00:00
return new ErrorResponse ( " 403 " , 403 );
}
2020-12-14 17:41:09 +00:00
$args = [];
if ( $reqPath ) {
$args [] = explode ( " / " , ltrim ( $target , " / " ));
}
if ( $reqBody ) {
if ( $func === " opmlImport " ) {
2021-02-07 18:04:44 +00:00
$data = ( string ) $req -> getBody ();
2020-12-14 17:41:09 +00:00
} else {
$data = ( string ) $req -> getBody ();
if ( strlen ( $data )) {
$data = @ json_decode ( $data , true );
if ( json_last_error () !== \JSON_ERROR_NONE ) {
// if the body could not be parsed as JSON, return "400 Bad Request"
return new ErrorResponse ([ " InvalidBodyJSON " , json_last_error_msg ()], 400 );
}
} else {
$data = [];
}
2020-12-31 20:46:47 +00:00
$data = $this -> normalizeBody (( array ) $data , $reqFields );
2020-12-14 17:41:09 +00:00
if ( $data instanceof ResponseInterface ) {
return $data ;
}
2020-12-02 23:00:27 +00:00
}
2020-12-14 17:41:09 +00:00
$args [] = $data ;
}
if ( $reqQuery ) {
2021-01-31 15:44:27 +00:00
$query = $this -> normalizeQuery ( parse_url ( $req -> getRequestTarget (), \PHP_URL_QUERY ) ? ? " " );
if ( $query instanceof ResponseInterface ) {
return $query ;
}
$args [] = $query ;
2020-11-02 00:09:17 +00:00
}
try {
2020-12-14 17:41:09 +00:00
return $this -> $func ( ... $args );
2021-02-09 00:14:11 +00:00
// @codeCoverageIgnoreStart
2020-11-02 00:09:17 +00:00
} catch ( Exception $e ) {
// if there was a REST exception return 400
return new EmptyResponse ( 400 );
} catch ( AbstractException $e ) {
// if there was any other Arsse exception return 500
return new EmptyResponse ( 500 );
}
// @codeCoverageIgnoreEnd
2020-11-01 01:26:11 +00:00
}
2020-12-14 17:41:09 +00:00
protected function chooseCall ( string $url , string $method ) {
// // normalize the URL path: change any IDs to 1 for easier comparison
$url = $this -> normalizePathIds ( $url );
// normalize the HTTP method to uppercase
$method = strtoupper ( $method );
// we now evaluate the supplied URL against every supported path for the selected scope
if ( isset ( self :: CALLS [ $url ])) {
// if the path is supported, make sure the method is allowed
if ( isset ( self :: CALLS [ $url ][ $method ])) {
// if it is allowed, return the object method to run, assuming the method exists
assert ( method_exists ( $this , self :: CALLS [ $url ][ $method ][ 0 ]), new \Exception ( " Method is not implemented " ));
return self :: CALLS [ $url ][ $method ];
} else {
// otherwise return 405
return new EmptyResponse ( 405 , [ 'Allow' => implode ( " , " , array_keys ( self :: CALLS [ $url ]))]);
}
} else {
// if the path is not supported, return 404
return new EmptyResponse ( 404 );
}
}
2020-11-01 01:26:11 +00:00
protected function normalizePathIds ( string $url ) : string {
$path = explode ( " / " , $url );
// any path components which are database IDs (integers greater than zero) should be replaced with "1", for easier comparison (we don't care about the specific ID)
for ( $a = 0 ; $a < sizeof ( $path ); $a ++ ) {
2020-12-02 23:00:27 +00:00
if ( V :: id ( $path [ $a ])) {
2020-11-01 01:26:11 +00:00
$path [ $a ] = " 1 " ;
}
}
2020-11-02 00:09:17 +00:00
// handle special case "Get User By User Name", which can have any non-numeric string, non-empty as the last component
if ( sizeof ( $path ) === 3 && $path [ 0 ] === " " && $path [ 1 ] === " users " && ! preg_match ( " /^(?: \ d+)? $ / " , $path [ 2 ])) {
$path [ 2 ] = " * " ;
}
2020-11-01 01:26:11 +00:00
return implode ( " / " , $path );
}
2020-12-31 20:46:47 +00:00
protected function normalizeBody ( array $body , array $req ) {
2020-12-14 17:41:09 +00:00
// Miniflux does not attempt to coerce values into different types
foreach ( self :: VALID_JSON as $k => $t ) {
if ( ! isset ( $body [ $k ])) {
$body [ $k ] = null ;
} elseif ( gettype ( $body [ $k ]) !== $t ) {
2020-12-28 13:12:30 +00:00
return new ErrorResponse ([ " InvalidInputType " , 'field' => $k , 'expected' => $t , 'actual' => gettype ( $body [ $k ])], 422 );
2021-01-20 23:28:51 +00:00
} elseif (
2021-01-31 02:37:19 +00:00
( in_array ( $k , [ " keeplist_rules " , " blocklist_rules " ]) && ! Rule :: validate ( $body [ $k ]))
2021-02-09 00:14:11 +00:00
|| ( in_array ( $k , [ " url " , " feed_url " ]) && ! URL :: absolute ( $body [ $k ]))
2021-01-31 02:37:19 +00:00
|| ( $k === " category_id " && $body [ $k ] < 1 )
2021-02-05 13:48:14 +00:00
|| ( $k === " status " && ! in_array ( $body [ $k ], [ " read " , " unread " , " removed " ]))
2021-01-20 23:28:51 +00:00
) {
2021-01-20 04:17:03 +00:00
return new ErrorResponse ([ " InvalidInputValue " , 'field' => $k ], 422 );
2021-02-05 13:48:14 +00:00
} elseif ( $k === " entry_ids " ) {
foreach ( $body [ $k ] as $v ) {
if ( gettype ( $v ) !== " integer " ) {
return new ErrorResponse ([ " InvalidInputType " , 'field' => $k , 'expected' => " integer " , 'actual' => gettype ( $v )], 422 );
} elseif ( $v < 1 ) {
return new ErrorResponse ([ " InvalidInputValue " , 'field' => $k ], 422 );
}
}
2020-12-28 13:12:30 +00:00
}
}
2020-12-31 20:46:47 +00:00
//normalize user-specific input
2021-01-08 20:47:19 +00:00
foreach ( self :: USER_META_MAP as $k => [, $d ]) {
2020-12-28 13:12:30 +00:00
$t = gettype ( $d );
if ( ! isset ( $body [ $k ])) {
$body [ $k ] = null ;
2020-12-30 22:01:17 +00:00
} elseif ( $k === " entry_sorting_direction " ) {
if ( ! in_array ( $body [ $k ], [ " asc " , " desc " ])) {
return new ErrorResponse ([ " InvalidInputValue " , 'field' => $k ], 422 );
}
2020-12-28 13:12:30 +00:00
} elseif ( gettype ( $body [ $k ]) !== $t ) {
return new ErrorResponse ([ " InvalidInputType " , 'field' => $k , 'expected' => $t , 'actual' => gettype ( $body [ $k ])], 422 );
2020-12-14 17:41:09 +00:00
}
}
2020-12-31 20:46:47 +00:00
// check for any missing required values
foreach ( $req as $k ) {
2021-02-05 13:48:14 +00:00
if ( ! isset ( $body [ $k ]) || ( is_array ( $body [ $k ]) && ! $body [ $k ])) {
2020-12-31 20:46:47 +00:00
return new ErrorResponse ([ " MissingInputValue " , 'field' => $k ], 422 );
}
}
2020-12-14 17:41:09 +00:00
return $body ;
}
2021-01-31 02:37:19 +00:00
protected function normalizeQuery ( string $query ) {
2021-01-30 18:38:02 +00:00
// fill an array with all valid keys
$out = [];
2021-01-31 15:44:27 +00:00
$seen = [];
2021-01-30 18:38:02 +00:00
foreach ( self :: VALID_QUERY as $k => $t ) {
$out [ $k ] = ( $t >= V :: M_ARRAY ) ? [] : null ;
2021-01-31 15:44:27 +00:00
$seen [ $k ] = false ;
2021-01-30 18:38:02 +00:00
}
// split the query string and normalize the values to their correct types
foreach ( explode ( " & " , $query ) as $parts ) {
$parts = explode ( " = " , $parts , 2 );
$k = rawurldecode ( $parts [ 0 ]);
2021-01-31 15:44:27 +00:00
$v = ( isset ( $parts [ 1 ])) ? rawurldecode ( $parts [ 1 ]) : " " ;
if ( ! isset ( self :: VALID_QUERY [ $k ])) {
// ignore unknown keys
2021-01-30 18:38:02 +00:00
continue ;
}
$t = self :: VALID_QUERY [ $k ] & ~ V :: M_ARRAY ;
$a = self :: VALID_QUERY [ $k ] >= V :: M_ARRAY ;
2021-01-31 02:37:19 +00:00
try {
2021-01-31 15:44:27 +00:00
if ( $seen [ $k ] && ! $a ) {
// if the key has already been seen and it's not an array field, bail
// NOTE: Miniflux itself simply ignores duplicates entirely
return new ErrorResponse ([ " DuplicateInputValue " , 'field' => $k ], 400 );
}
$seen [ $k ] = true ;
if ( $k === " starred " ) {
// the starred key is a special case in that Miniflux only considers the presence of the key
$out [ $k ] = true ;
continue ;
} elseif ( $v === " " ) {
// if the value is empty we can discard the value, but subsequent values for the same non-array key are still considered duplicates
continue ;
} elseif ( $a ) {
2021-01-31 02:37:19 +00:00
$out [ $k ][] = V :: normalize ( $v , $t + V :: M_STRICT , " unix " );
} else {
2021-01-31 15:44:27 +00:00
$out [ $k ] = V :: normalize ( $v , $t + V :: M_STRICT , " unix " );
2021-01-31 02:37:19 +00:00
}
} catch ( ExceptionType $e ) {
return new ErrorResponse ([ " InvalidInputValue " , 'field' => $k ], 400 );
}
2021-01-31 15:44:27 +00:00
// perform additional validation
2021-01-31 02:37:19 +00:00
if (
( in_array ( $k , [ " category_id " , " before_entry_id " , " after_entry_id " ]) && $v < 1 )
|| ( in_array ( $k , [ " limit " , " offset " ]) && $v < 0 )
|| ( $k === " direction " && ! in_array ( $v , [ " asc " , " desc " ]))
|| ( $k === " order " && ! in_array ( $v , [ " id " , " status " , " published_at " , " category_title " , " category_id " ]))
|| ( $k === " status " && ! in_array ( $v , [ " read " , " unread " , " removed " ]))
) {
return new ErrorResponse ([ " InvalidInputValue " , 'field' => $k ], 400 );
2021-01-30 18:38:02 +00:00
}
}
return $out ;
}
2020-11-01 01:26:11 +00:00
protected function handleHTTPOptions ( string $url ) : ResponseInterface {
// normalize the URL path: change any IDs to 1 for easier comparison
$url = $this -> normalizePathIDs ( $url );
2020-12-14 17:41:09 +00:00
if ( isset ( self :: CALLS [ $url ])) {
2020-11-01 01:26:11 +00:00
// if the path is supported, respond with the allowed methods and other metadata
2020-12-14 17:41:09 +00:00
$allowed = array_keys ( self :: CALLS [ $url ]);
2020-11-01 01:26:11 +00:00
// if GET is allowed, so is HEAD
if ( in_array ( " GET " , $allowed )) {
array_unshift ( $allowed , " HEAD " );
}
return new EmptyResponse ( 204 , [
2020-12-01 17:08:45 +00:00
'Allow' => implode ( " , " , $allowed ),
2020-11-02 00:09:17 +00:00
'Accept' => implode ( " , " , $url === " /import " ? self :: ACCEPTED_TYPES_OPML : self :: ACCEPTED_TYPES_JSON ),
2020-11-01 01:26:11 +00:00
]);
} else {
// if the path is not supported, return 404
return new EmptyResponse ( 404 );
}
}
2020-12-08 20:34:31 +00:00
protected function listUsers ( array $users , bool $reportMissing ) : array {
$out = [];
2020-12-10 04:39:29 +00:00
$now = Date :: transform ( $this -> now (), " iso8601m " );
2020-12-08 20:34:31 +00:00
foreach ( $users as $u ) {
try {
$info = Arsse :: $user -> propertiesGet ( $u , true );
} catch ( UserException $e ) {
if ( $reportMissing ) {
throw $e ;
} else {
continue ;
}
}
2020-12-28 13:12:30 +00:00
$entry = [
2020-12-08 20:34:31 +00:00
'id' => $info [ 'num' ],
'username' => $u ,
'last_login_at' => $now ,
2021-01-22 23:24:33 +00:00
'google_id' => " " ,
'openid_connect_id' => " " ,
2020-12-08 20:34:31 +00:00
];
2021-01-22 23:24:33 +00:00
foreach ( self :: USER_META_MAP as $ext => [ $int , $default ]) {
$entry [ $ext ] = $info [ $int ] ? ? $default ;
2020-12-28 13:12:30 +00:00
}
$entry [ 'entry_sorting_direction' ] = ( $entry [ 'entry_sorting_direction' ]) ? " asc " : " desc " ;
$out [] = $entry ;
2020-12-08 20:34:31 +00:00
}
return $out ;
}
2020-12-31 18:57:36 +00:00
protected function editUser ( string $user , array $data ) : array {
// map Miniflux properties to internal metadata properties
$in = [];
2021-02-09 00:14:11 +00:00
foreach ( self :: USER_META_MAP as $i => [ $o ]) {
2020-12-31 18:57:36 +00:00
if ( isset ( $data [ $i ])) {
if ( $i === " entry_sorting_direction " ) {
$in [ $o ] = $data [ $i ] === " asc " ;
} else {
$in [ $o ] = $data [ $i ];
}
}
}
// make any requested changes
$tr = Arsse :: $user -> begin ();
if ( $in ) {
Arsse :: $user -> propertiesSet ( $user , $in );
}
// read out the newly-modified user and commit the changes
$out = $this -> listUsers ([ $user ], true )[ 0 ];
$tr -> commit ();
// add the input password if a password change was requested
if ( isset ( $data [ 'password' ])) {
$out [ 'password' ] = $data [ 'password' ];
}
return $out ;
}
2020-12-14 17:41:09 +00:00
protected function discoverSubscriptions ( array $data ) : ResponseInterface {
2020-12-02 23:00:27 +00:00
try {
$list = Feed :: discoverAll (( string ) $data [ 'url' ], ( string ) $data [ 'username' ], ( string ) $data [ 'password' ]);
} catch ( FeedException $e ) {
$msg = [
2020-12-11 18:31:35 +00:00
10502 => " Fetch404 " ,
10506 => " Fetch403 " ,
10507 => " Fetch401 " ,
2021-01-23 17:00:11 +00:00
10521 => " Fetch404 " ,
2020-12-11 18:31:35 +00:00
][ $e -> getCode ()] ? ? " FetchOther " ;
2021-01-20 04:17:03 +00:00
return new ErrorResponse ( $msg , 502 );
2020-12-02 23:00:27 +00:00
}
$out = [];
2020-12-22 21:13:12 +00:00
foreach ( $list as $url ) {
2020-12-02 23:00:27 +00:00
// TODO: This needs to be refined once PicoFeed is replaced
$out [] = [ 'title' => " Feed " , 'type' => " rss " , 'url' => $url ];
}
return new Response ( $out );
}
2020-12-14 17:41:09 +00:00
protected function getUsers () : ResponseInterface {
2020-12-28 13:12:30 +00:00
$tr = Arsse :: $user -> begin ();
2020-12-08 20:34:31 +00:00
return new Response ( $this -> listUsers ( Arsse :: $user -> list (), false ));
}
2020-12-14 17:41:09 +00:00
protected function getUserById ( array $path ) : ResponseInterface {
2020-12-08 20:34:31 +00:00
try {
2020-12-10 04:39:29 +00:00
return new Response ( $this -> listUsers ([ $path [ 1 ]], true )[ 0 ] ? ? new \stdClass );
2020-12-08 20:34:31 +00:00
} catch ( UserException $e ) {
return new ErrorResponse ( " 404 " , 404 );
}
}
2020-12-14 17:41:09 +00:00
protected function getUserByNum ( array $path ) : ResponseInterface {
2020-12-11 01:08:00 +00:00
try {
$user = Arsse :: $user -> lookup (( int ) $path [ 1 ]);
return new Response ( $this -> listUsers ([ $user ], true )[ 0 ] ? ? new \stdClass );
} catch ( UserException $e ) {
return new ErrorResponse ( " 404 " , 404 );
}
2020-12-08 20:34:31 +00:00
}
2020-12-22 21:13:12 +00:00
2020-12-14 17:41:09 +00:00
protected function getCurrentUser () : ResponseInterface {
2020-12-08 20:34:31 +00:00
return new Response ( $this -> listUsers ([ Arsse :: $user -> id ], false )[ 0 ] ? ? new \stdClass );
}
2020-12-31 18:57:36 +00:00
protected function createUser ( array $data ) : ResponseInterface {
try {
$tr = Arsse :: $user -> begin ();
$data [ 'password' ] = Arsse :: $user -> add ( $data [ 'username' ], $data [ 'password' ]);
$out = $this -> editUser ( $data [ 'username' ], $data );
$tr -> commit ();
} catch ( UserException $e ) {
switch ( $e -> getCode ()) {
case 10403 :
return new ErrorResponse ([ " DuplicateUser " , 'user' => $data [ 'username' ]], 409 );
case 10441 :
return new ErrorResponse ([ " InvalidInputValue " , 'field' => " timezone " ], 422 );
case 10443 :
return new ErrorResponse ([ " InvalidInputValue " , 'field' => " entries_per_page " ], 422 );
case 10444 :
return new ErrorResponse ([ " InvalidInputValue " , 'field' => " username " ], 422 );
}
throw $e ; // @codeCoverageIgnore
}
return new Response ( $out , 201 );
}
2020-12-30 22:01:17 +00:00
protected function updateUserByNum ( array $path , array $data ) : ResponseInterface {
// this function is restricted to admins unless the affected user and calling user are the same
$user = Arsse :: $user -> propertiesGet ( Arsse :: $user -> id , false );
if ((( int ) $path [ 1 ]) === $user [ 'num' ]) {
if ( $data [ 'is_admin' ] && ! $user [ 'admin' ]) {
// non-admins should not be able to set themselves as admin
return new ErrorResponse ( " InvalidElevation " , 403 );
}
$user = Arsse :: $user -> id ;
} elseif ( ! $user [ 'admin' ]) {
return new ErrorResponse ( " 403 " , 403 );
} else {
try {
$user = Arsse :: $user -> lookup (( int ) $path [ 1 ]);
} catch ( ExceptionConflict $e ) {
return new ErrorResponse ( " 404 " , 404 );
2020-12-28 13:12:30 +00:00
}
}
// make any requested changes
try {
$tr = Arsse :: $user -> begin ();
if ( isset ( $data [ 'username' ])) {
Arsse :: $user -> rename ( $user , $data [ 'username' ]);
$user = $data [ 'username' ];
}
if ( isset ( $data [ 'password' ])) {
Arsse :: $user -> passwordSet ( $user , $data [ 'password' ]);
}
2020-12-31 18:57:36 +00:00
$out = $this -> editUser ( $user , $data );
2020-12-28 13:12:30 +00:00
$tr -> commit ();
} catch ( UserException $e ) {
switch ( $e -> getCode ()) {
case 10403 :
return new ErrorResponse ([ " DuplicateUser " , 'user' => $data [ 'username' ]], 409 );
2020-12-30 22:01:17 +00:00
case 10441 :
return new ErrorResponse ([ " InvalidInputValue " , 'field' => " timezone " ], 422 );
2020-12-28 13:12:30 +00:00
case 10443 :
2020-12-30 22:01:17 +00:00
return new ErrorResponse ([ " InvalidInputValue " , 'field' => " entries_per_page " ], 422 );
2020-12-28 13:12:30 +00:00
case 10444 :
2020-12-30 22:01:17 +00:00
return new ErrorResponse ([ " InvalidInputValue " , 'field' => " username " ], 422 );
2020-12-28 13:12:30 +00:00
}
throw $e ; // @codeCoverageIgnore
}
return new Response ( $out );
}
2020-12-31 22:03:08 +00:00
protected function deleteUserByNum ( array $path ) : ResponseInterface {
try {
Arsse :: $user -> remove ( Arsse :: $user -> lookup (( int ) $path [ 1 ]));
} catch ( ExceptionConflict $e ) {
return new ErrorResponse ( " 404 " , 404 );
}
return new EmptyResponse ( 204 );
}
2021-01-28 19:55:18 +00:00
/** Returns a useful subset of user metadata
2021-02-09 00:14:11 +00:00
*
2021-01-28 19:55:18 +00:00
* The following keys are included :
2021-02-09 00:14:11 +00:00
*
2021-01-28 19:55:18 +00:00
* - " num " : The user ' s numeric ID ,
* - " root " : The effective name of the root folder
*/
protected function userMeta ( string $user ) : array {
2020-12-11 18:31:35 +00:00
$meta = Arsse :: $user -> propertiesGet ( Arsse :: $user -> id , false );
2021-01-28 19:55:18 +00:00
return [
'num' => $meta [ 'num' ],
2021-02-02 21:05:16 +00:00
'root' => $meta [ 'root_folder_name' ] ? ? Arsse :: $lang -> msg ( " API.Miniflux.DefaultCategoryName " ),
'tz' => new \DateTimeZone ( $meta [ 'tz' ] ? ? " UTC " ),
2021-01-28 19:55:18 +00:00
];
2021-01-22 23:24:33 +00:00
}
protected function getCategories () : ResponseInterface {
2021-01-28 19:55:18 +00:00
$out = [];
2020-12-11 18:31:35 +00:00
// add the root folder as a category
2021-01-28 19:55:18 +00:00
$meta = $this -> userMeta ( Arsse :: $user -> id );
$out [] = [ 'id' => 1 , 'title' => $meta [ 'root' ], 'user_id' => $meta [ 'num' ]];
2020-12-11 18:31:35 +00:00
// add other top folders as categories
foreach ( Arsse :: $db -> folderList ( Arsse :: $user -> id , null , false ) as $f ) {
// always add 1 to the ID since the root folder will always be 1 instead of 0.
2021-01-28 19:55:18 +00:00
$out [] = [ 'id' => $f [ 'id' ] + 1 , 'title' => $f [ 'name' ], 'user_id' => $meta [ 'num' ]];
2020-12-11 18:31:35 +00:00
}
return new Response ( $out );
}
2020-12-14 17:41:09 +00:00
protected function createCategory ( array $data ) : ResponseInterface {
2020-12-12 04:47:13 +00:00
try {
$id = Arsse :: $db -> folderAdd ( Arsse :: $user -> id , [ 'name' => ( string ) $data [ 'title' ]]);
} catch ( ExceptionInput $e ) {
if ( $e -> getCode () === 10236 ) {
2020-12-31 20:46:47 +00:00
return new ErrorResponse ([ " DuplicateCategory " , 'title' => $data [ 'title' ]], 409 );
2020-12-12 04:47:13 +00:00
} else {
2020-12-31 20:46:47 +00:00
return new ErrorResponse ([ " InvalidCategory " , 'title' => $data [ 'title' ]], 422 );
2020-12-12 04:47:13 +00:00
}
}
$meta = Arsse :: $user -> propertiesGet ( Arsse :: $user -> id , false );
2020-12-28 13:12:30 +00:00
return new Response ([ 'id' => $id + 1 , 'title' => $data [ 'title' ], 'user_id' => $meta [ 'num' ]], 201 );
2020-12-12 04:47:13 +00:00
}
2020-12-14 17:41:09 +00:00
protected function updateCategory ( array $path , array $data ) : ResponseInterface {
2020-12-13 17:56:57 +00:00
// category IDs in Miniflux are always greater than 1; we have folder 0, so we decrement category IDs by 1 to get the folder ID
2020-12-12 04:47:13 +00:00
$folder = $path [ 1 ] - 1 ;
$title = $data [ 'title' ] ? ? " " ;
try {
if ( $folder === 0 ) {
2020-12-13 17:56:57 +00:00
// folder 0 doesn't actually exist in the database, so its name is kept as user metadata
2020-12-12 04:47:13 +00:00
if ( ! strlen ( trim ( $title ))) {
throw new ExceptionInput ( " whitespace " );
}
$title = Arsse :: $user -> propertiesSet ( Arsse :: $user -> id , [ 'root_folder_name' => $title ])[ 'root_folder_name' ];
} else {
Arsse :: $db -> folderPropertiesSet ( Arsse :: $user -> id , $folder , [ 'name' => $title ]);
}
} catch ( ExceptionInput $e ) {
if ( $e -> getCode () === 10236 ) {
2020-12-31 20:46:47 +00:00
return new ErrorResponse ([ " DuplicateCategory " , 'title' => $title ], 409 );
2020-12-14 03:10:34 +00:00
} elseif ( in_array ( $e -> getCode (), [ 10237 , 10239 ])) {
2020-12-12 04:47:13 +00:00
return new ErrorResponse ( " 404 " , 404 );
} else {
2020-12-31 20:46:47 +00:00
return new ErrorResponse ([ " InvalidCategory " , 'title' => $title ], 422 );
2020-12-12 04:47:13 +00:00
}
}
$meta = Arsse :: $user -> propertiesGet ( Arsse :: $user -> id , false );
return new Response ([ 'id' => ( int ) $path [ 1 ], 'title' => $title , 'user_id' => $meta [ 'num' ]]);
}
2020-12-14 17:41:09 +00:00
protected function deleteCategory ( array $path ) : ResponseInterface {
2020-12-14 03:10:34 +00:00
try {
$folder = $path [ 1 ] - 1 ;
if ( $folder !== 0 ) {
Arsse :: $db -> folderRemove ( Arsse :: $user -> id , $folder );
} else {
// if we're deleting from the root folder, delete each child subscription individually
2020-12-22 21:13:12 +00:00
// otherwise we'd be deleting the entire tree
2020-12-14 03:10:34 +00:00
$tr = Arsse :: $db -> begin ();
foreach ( Arsse :: $db -> subscriptionList ( Arsse :: $user -> id , null , false ) as $sub ) {
2021-03-02 16:27:48 +00:00
Arsse :: $db -> subscriptionRemove ( Arsse :: $user -> id , ( int ) $sub [ 'id' ]);
2020-12-14 03:10:34 +00:00
}
$tr -> commit ();
}
} catch ( ExceptionInput $e ) {
return new ErrorResponse ( " 404 " , 404 );
}
return new EmptyResponse ( 204 );
}
2021-02-03 21:27:55 +00:00
protected function transformFeed ( array $sub , int $uid , string $rootName , \DateTimeZone $tz ) : array {
2021-01-17 18:02:31 +00:00
$url = new Uri ( $sub [ 'url' ]);
return [
'id' => ( int ) $sub [ 'id' ],
2021-01-28 19:55:18 +00:00
'user_id' => $uid ,
2021-01-17 18:02:31 +00:00
'feed_url' => ( string ) $url -> withUserInfo ( " " ),
'site_url' => ( string ) $sub [ 'source' ],
'title' => ( string ) $sub [ 'title' ],
2021-02-03 21:27:55 +00:00
'checked_at' => Date :: normalize ( $sub [ 'updated' ], " sql " ) -> setTimezone ( $tz ) -> format ( self :: DATE_FORMAT_MICRO ),
'next_check_at' => $sub [ 'next_fetch' ] ? Date :: normalize ( $sub [ 'next_fetch' ], " sql " ) -> setTimezone ( $tz ) -> format ( self :: DATE_FORMAT_MICRO ) : " 0001-01-01T00:00:00Z " ,
2021-01-17 18:02:31 +00:00
'etag_header' => ( string ) $sub [ 'etag' ],
'last_modified_header' => ( string ) Date :: transform ( $sub [ 'edited' ], " http " , " sql " ),
'parsing_error_message' => ( string ) $sub [ 'err_msg' ],
'parsing_error_count' => ( int ) $sub [ 'err_count' ],
'scraper_rules' => " " ,
'rewrite_rules' => " " ,
'crawler' => ( bool ) $sub [ 'scrape' ],
'blocklist_rules' => ( string ) $sub [ 'block_rule' ],
'keeplist_rules' => ( string ) $sub [ 'keep_rule' ],
'user_agent' => " " ,
'username' => rawurldecode ( explode ( " : " , $url -> getUserInfo (), 2 )[ 0 ] ? ? " " ),
'password' => rawurldecode ( explode ( " : " , $url -> getUserInfo (), 2 )[ 1 ] ? ? " " ),
'disabled' => false ,
'ignore_http_cache' => false ,
'fetch_via_proxy' => false ,
2021-01-28 19:55:18 +00:00
'category' => [
'id' => ( int ) $sub [ 'top_folder' ] + 1 ,
'title' => $sub [ 'top_folder_name' ] ? ? $rootName ,
'user_id' => $uid ,
],
2021-01-17 18:02:31 +00:00
'icon' => $sub [ 'icon_id' ] ? [ 'feed_id' => ( int ) $sub [ 'id' ], 'icon_id' => ( int ) $sub [ 'icon_id' ]] : null ,
];
}
protected function getFeeds () : ResponseInterface {
$out = [];
2021-01-28 19:55:18 +00:00
$tr = Arsse :: $db -> begin ();
$meta = $this -> userMeta ( Arsse :: $user -> id );
2021-01-17 03:52:07 +00:00
foreach ( Arsse :: $db -> subscriptionList ( Arsse :: $user -> id ) as $r ) {
2021-02-03 21:27:55 +00:00
$out [] = $this -> transformFeed ( $r , $meta [ 'num' ], $meta [ 'root' ], $meta [ 'tz' ]);
2021-01-17 03:52:07 +00:00
}
2021-01-17 18:02:31 +00:00
return new Response ( $out );
2021-01-17 03:52:07 +00:00
}
2021-01-22 23:24:33 +00:00
protected function getCategoryFeeds ( array $path ) : ResponseInterface {
// transform the category number into a folder number by subtracting one
$folder = (( int ) $path [ 1 ]) - 1 ;
// unless the folder is root, list recursive
$recursive = $folder > 0 ;
2021-01-28 19:55:18 +00:00
$out = [];
2021-01-22 23:24:33 +00:00
$tr = Arsse :: $db -> begin ();
2021-01-28 19:55:18 +00:00
// get the list of subscriptions, or bail
2021-01-22 23:24:33 +00:00
try {
2021-01-28 19:55:18 +00:00
$meta = $this -> userMeta ( Arsse :: $user -> id );
foreach ( Arsse :: $db -> subscriptionList ( Arsse :: $user -> id , $folder , $recursive ) as $r ) {
2021-02-03 21:27:55 +00:00
$out [] = $this -> transformFeed ( $r , $meta [ 'num' ], $meta [ 'root' ], $meta [ 'tz' ]);
2021-01-28 19:55:18 +00:00
}
2021-01-22 23:24:33 +00:00
} catch ( ExceptionInput $e ) {
// the folder does not exist
2021-01-24 16:33:00 +00:00
return new ErrorResponse ( " 404 " , 404 );
2021-01-22 23:24:33 +00:00
}
return new Response ( $out );
}
2021-01-24 18:54:54 +00:00
protected function getFeed ( array $path ) : ResponseInterface {
$tr = Arsse :: $db -> begin ();
2021-01-28 19:55:18 +00:00
$meta = $this -> userMeta ( Arsse :: $user -> id );
2021-01-24 18:54:54 +00:00
try {
$sub = Arsse :: $db -> subscriptionPropertiesGet ( Arsse :: $user -> id , ( int ) $path [ 1 ]);
2021-02-03 21:27:55 +00:00
return new Response ( $this -> transformFeed ( $sub , $meta [ 'num' ], $meta [ 'root' ], $meta [ 'tz' ]));
2021-01-24 18:54:54 +00:00
} catch ( ExceptionInput $e ) {
return new ErrorResponse ( " 404 " , 404 );
}
}
2021-01-20 04:17:03 +00:00
protected function createFeed ( array $data ) : ResponseInterface {
try {
Arsse :: $db -> feedAdd ( $data [ 'feed_url' ], ( string ) $data [ 'username' ], ( string ) $data [ 'password' ], false , ( bool ) $data [ 'crawler' ]);
$tr = Arsse :: $db -> begin ();
$id = Arsse :: $db -> subscriptionAdd ( Arsse :: $user -> id , $data [ 'feed_url' ], ( string ) $data [ 'username' ], ( string ) $data [ 'password' ], false , ( bool ) $data [ 'crawler' ]);
2021-01-23 23:01:23 +00:00
Arsse :: $db -> subscriptionPropertiesSet ( Arsse :: $user -> id , $id , [ 'folder' => $data [ 'category_id' ] - 1 , 'scrape' => ( bool ) $data [ 'crawler' ]]);
2021-01-20 04:17:03 +00:00
$tr -> commit ();
2021-01-23 23:01:23 +00:00
if ( strlen ( $data [ 'keeplist_rules' ] ? ? " " ) || strlen ( $data [ 'blocklist_rules' ] ? ? " " )) {
// we do rules separately so as not to tie up the database
Arsse :: $db -> subscriptionPropertiesSet ( Arsse :: $user -> id , $id , [ 'keep_rule' => $data [ 'keeplist_rules' ], 'block_rule' => $data [ 'blocklist_rules' ]]);
}
2021-01-20 04:17:03 +00:00
} catch ( FeedException $e ) {
$msg = [
10502 => " Fetch404 " ,
10506 => " Fetch403 " ,
10507 => " Fetch401 " ,
2021-01-23 17:00:11 +00:00
10521 => " Fetch404 " ,
10522 => " FetchFormat " ,
2021-01-20 04:17:03 +00:00
][ $e -> getCode ()] ? ? " FetchOther " ;
return new ErrorResponse ( $msg , 502 );
} catch ( ExceptionInput $e ) {
switch ( $e -> getCode ()) {
case 10235 :
return new ErrorResponse ( " MissingCategory " , 422 );
case 10236 :
return new ErrorResponse ( " DuplicateFeed " , 409 );
}
}
return new Response ([ 'feed_id' => $id ], 201 );
}
2021-01-25 01:28:00 +00:00
protected function updateFeed ( array $path , array $data ) : ResponseInterface {
$in = [];
foreach ( self :: FEED_META_MAP as $from => $to ) {
if ( isset ( $data [ $from ])) {
$in [ $to ] = $data [ $from ];
}
}
if ( isset ( $in [ 'folder' ])) {
$in [ 'folder' ] -= 1 ;
}
try {
Arsse :: $db -> subscriptionPropertiesSet ( Arsse :: $user -> id , ( int ) $path [ 1 ], $in );
} catch ( ExceptionInput $e ) {
switch ( $e -> getCode ()) {
case 10231 :
case 10232 :
return new ErrorResponse ( " InvalidTitle " , 422 );
case 10235 :
return new ErrorResponse ( " MissingCategory " , 422 );
case 10239 :
return new ErrorResponse ( " 404 " , 404 );
}
}
2021-01-25 02:12:32 +00:00
return $this -> getFeed ( $path );
}
protected function deleteFeed ( array $path ) : ResponseInterface {
try {
Arsse :: $db -> subscriptionRemove ( Arsse :: $user -> id , ( int ) $path [ 1 ]);
return new EmptyResponse ( 204 );
} catch ( ExceptionInput $e ) {
return new ErrorResponse ( " 404 " , 404 );
}
2021-01-25 01:28:00 +00:00
}
2021-01-25 02:53:45 +00:00
protected function getFeedIcon ( array $path ) : ResponseInterface {
try {
$icon = Arsse :: $db -> subscriptionIcon ( Arsse :: $user -> id , ( int ) $path [ 1 ]);
} catch ( ExceptionInput $e ) {
return new ErrorResponse ( " 404 " , 404 );
}
2021-01-27 18:41:10 +00:00
if ( ! $icon || ! $icon [ 'type' ] || ! $icon [ 'data' ]) {
2021-01-25 02:53:45 +00:00
return new ErrorResponse ( " 404 " , 404 );
}
return new Response ([
2021-02-09 14:26:12 +00:00
'id' => ( int ) $icon [ 'id' ],
2021-02-09 00:14:11 +00:00
'data' => $icon [ 'type' ] . " ;base64, " . base64_encode ( $icon [ 'data' ]),
2021-01-27 18:41:10 +00:00
'mime_type' => $icon [ 'type' ],
2021-01-25 02:53:45 +00:00
]);
}
2021-02-02 21:05:16 +00:00
protected function computeContext ( array $query , Context $c = null ) : Context {
2021-03-06 16:26:14 +00:00
if ( $query [ 'before' ] && $query [ 'before' ] -> getTimestamp () === 0 ) {
$query [ 'before' ] = null ; // NOTE: This workaround is needed for compatibility with "Microflux for Miniflux", an Android Client
}
2021-02-02 21:05:16 +00:00
$c = ( $c ? ? new Context )
2021-02-04 22:07:22 +00:00
-> limit ( $query [ 'limit' ] ? ? self :: DEFAULT_ENTRY_LIMIT ) // NOTE: This does not honour user preferences
2021-02-02 02:02:46 +00:00
-> offset ( $query [ 'offset' ])
-> starred ( $query [ 'starred' ])
-> modifiedSince ( $query [ 'after' ]) // FIXME: This may not be the correct date field
-> notModifiedSince ( $query [ 'before' ])
-> oldestArticle ( $query [ 'after_entry_id' ] ? $query [ 'after_entry_id' ] + 1 : null ) // FIXME: This might be edition
-> latestArticle ( $query [ 'before_entry_id' ] ? $query [ 'before_entry_id' ] - 1 : null )
-> searchTerms ( strlen ( $query [ 'search' ] ? ? " " ) ? preg_split ( " / \ s+/ " , $query [ 'search' ]) : null ); // NOTE: Miniflux matches only whole words; we match simple substrings
if ( $query [ 'category_id' ]) {
if ( $query [ 'category_id' ] === 1 ) {
$c -> folderShallow ( 0 );
} else {
$c -> folder ( $query [ 'category_id' ] - 1 );
}
}
// FIXME: specifying e.g. ?status=read&status=removed should yield all hidden articles and all read articles, but the best we can do is all read articles which are or are not hidden
2021-02-03 18:06:36 +00:00
$status = array_unique ( $query [ 'status' ]);
sort ( $status );
2021-02-02 02:02:46 +00:00
if ( $status === [ " read " , " removed " ]) {
$c -> unread ( false );
} elseif ( $status === [ " read " , " unread " ]) {
$c -> hidden ( false );
} elseif ( $status === [ " read " ]) {
$c -> hidden ( false ) -> unread ( false );
} elseif ( $status === [ " removed " , " unread " ]) {
$c -> unread ( true );
} elseif ( $status === [ " removed " ]) {
$c -> hidden ( true );
} elseif ( $status === [ " unread " ]) {
$c -> hidden ( false ) -> unread ( true );
}
2021-02-02 21:05:16 +00:00
return $c ;
}
protected function computeOrder ( array $query ) : array {
2021-02-02 03:11:15 +00:00
$desc = $query [ 'direction' ] === " desc " ? " desc " : " " ;
if ( $query [ 'order' ] === " id " ) {
2021-02-02 21:05:16 +00:00
return [ " id " . $desc ];
2021-02-02 03:11:15 +00:00
} elseif ( $query [ 'order' ] === " status " ) {
if ( ! $desc ) {
2021-02-02 21:05:16 +00:00
return [ " hidden " , " unread desc " ];
2021-02-02 03:11:15 +00:00
} else {
2021-02-02 21:05:16 +00:00
return [ " hidden desc " , " unread " ];
2021-02-02 03:11:15 +00:00
}
} elseif ( $query [ 'order' ] === " published_at " ) {
2021-02-02 21:05:16 +00:00
return [ " modified_date " . $desc ];
2021-02-02 03:11:15 +00:00
} elseif ( $query [ 'order' ] === " category_title " ) {
2021-02-02 21:05:16 +00:00
return [ " top_folder_name " . $desc ];
2021-02-04 22:07:22 +00:00
} elseif ( $query [ 'order' ] === " category_id " ) {
2021-02-02 21:05:16 +00:00
return [ " top_folder " . $desc ];
} else {
2021-02-04 22:07:22 +00:00
return [ self :: DEFAULT_ORDER_COL . $desc ];
2021-02-02 21:05:16 +00:00
}
}
protected function transformEntry ( array $entry , int $uid , \DateTimeZone $tz ) : array {
if ( $entry [ 'hidden' ]) {
$status = " removed " ;
} elseif ( $entry [ 'unread' ]) {
$status = " unread " ;
} else {
$status = " read " ;
}
if ( $entry [ 'media_url' ]) {
$enclosures = [
[
2021-02-09 14:26:12 +00:00
'id' => ( int ) $entry [ 'id' ], // NOTE: We don't have IDs for enclosures, but we also only have one enclosure per entry, so we can just re-use the same ID
2021-02-02 21:05:16 +00:00
'user_id' => $uid ,
2021-02-09 14:26:12 +00:00
'entry_id' => ( int ) $entry [ 'id' ],
2021-02-02 21:05:16 +00:00
'url' => $entry [ 'media_url' ],
'mime_type' => $entry [ 'media_type' ] ? : " application/octet-stream " ,
'size' => 0 ,
2021-02-09 00:14:11 +00:00
],
2021-02-02 21:05:16 +00:00
];
} else {
$enclosures = null ;
}
return [
'id' => ( int ) $entry [ 'id' ],
'user_id' => $uid ,
'feed_id' => ( int ) $entry [ 'subscription' ],
'status' => $status ,
'hash' => $entry [ 'fingerprint' ],
'title' => $entry [ 'title' ],
'url' => $entry [ 'url' ],
'comments_url' => " " ,
2021-02-03 21:27:55 +00:00
'published_at' => Date :: normalize ( $entry [ 'published_date' ], " sql " ) -> setTimezone ( $tz ) -> format ( self :: DATE_FORMAT_SEC ),
'created_at' => Date :: normalize ( $entry [ 'modified_date' ], " sql " ) -> setTimezone ( $tz ) -> format ( self :: DATE_FORMAT_MICRO ),
2021-02-02 21:05:16 +00:00
'content' => $entry [ 'content' ],
'author' => ( string ) $entry [ 'author' ],
'share_code' => " " ,
'starred' => ( bool ) $entry [ 'starred' ],
'reading_time' => 0 ,
'enclosures' => $enclosures ,
'feed' => null ,
];
}
2021-02-04 22:52:40 +00:00
protected function listEntries ( array $query , Context $c ) : array {
$c = $this -> computeContext ( $query , $c );
2021-02-02 21:05:16 +00:00
$order = $this -> computeOrder ( $query );
$tr = Arsse :: $db -> begin ();
$meta = $this -> userMeta ( Arsse :: $user -> id );
// compile the list of entries
$out = [];
2021-02-04 22:52:40 +00:00
foreach ( Arsse :: $db -> articleList ( Arsse :: $user -> id , $c , self :: ARTICLE_COLUMNS , $order ) as $entry ) {
2021-02-02 21:05:16 +00:00
$out [] = $this -> transformEntry ( $entry , $meta [ 'num' ], $meta [ 'tz' ]);
}
// next compile a map of feeds to add to the entries
2021-02-03 18:06:36 +00:00
if ( $out ) {
$feeds = [];
foreach ( Arsse :: $db -> subscriptionList ( Arsse :: $user -> id ) as $r ) {
2021-02-03 21:27:55 +00:00
$feeds [( int ) $r [ 'id' ]] = $this -> transformFeed ( $r , $meta [ 'num' ], $meta [ 'root' ], $meta [ 'tz' ]);
2021-02-03 18:06:36 +00:00
}
// add the feed objects to each entry
// NOTE: If ever we implement multiple enclosure, this would be the right place to add them
for ( $a = 0 ; $a < sizeof ( $out ); $a ++ ) {
$out [ $a ][ 'feed' ] = $feeds [ $out [ $a ][ 'feed_id' ]];
}
2021-02-02 21:05:16 +00:00
}
2021-02-04 22:07:22 +00:00
// finally compute the total number of entries match the query, where necessary
$count = sizeof ( $out );
if ( $c -> offset || ( $c -> limit && $count >= $c -> limit )) {
2021-02-02 21:14:04 +00:00
$count = Arsse :: $db -> articleCount ( Arsse :: $user -> id , ( clone $c ) -> limit ( 0 ) -> offset ( 0 ));
2021-02-02 03:11:15 +00:00
}
2021-02-04 22:52:40 +00:00
return [ 'total' => $count , 'entries' => $out ];
}
2021-02-05 01:19:35 +00:00
protected function findEntry ( int $id , Context $c = null ) : array {
$c = ( $c ? ? new Context ) -> article ( $id );
$tr = Arsse :: $db -> begin ();
$meta = $this -> userMeta ( Arsse :: $user -> id );
// find the entry we want
$entry = Arsse :: $db -> articleList ( Arsse :: $user -> id , $c , self :: ARTICLE_COLUMNS ) -> getRow ();
if ( ! $entry ) {
throw new ExceptionInput ( " idMissing " );
}
$out = $this -> transformEntry ( $entry , $meta [ 'num' ], $meta [ 'tz' ]);
// next transform the parent feed of the entry
$out [ 'feed' ] = $this -> transformFeed ( Arsse :: $db -> subscriptionPropertiesGet ( Arsse :: $user -> id , $out [ 'feed_id' ]), $meta [ 'num' ], $meta [ 'root' ], $meta [ 'tz' ]);
return $out ;
}
2021-02-09 00:14:11 +00:00
2021-02-04 22:52:40 +00:00
protected function getEntries ( array $query ) : ResponseInterface {
try {
return new Response ( $this -> listEntries ( $query , new Context ));
} catch ( ExceptionInput $e ) {
return new ErrorResponse ( " MissingCategory " , 400 );
}
}
2021-02-09 00:14:11 +00:00
2021-02-04 22:52:40 +00:00
protected function getFeedEntries ( array $path , array $query ) : ResponseInterface {
$c = ( new Context ) -> subscription (( int ) $path [ 1 ]);
try {
return new Response ( $this -> listEntries ( $query , $c ));
} catch ( ExceptionInput $e ) {
// FIXME: this should differentiate between a missing feed and a missing category, but doesn't
return new ErrorResponse ( " 404 " , 404 );
}
}
2021-02-09 00:14:11 +00:00
2021-02-04 22:52:40 +00:00
protected function getCategoryEntries ( array $path , array $query ) : ResponseInterface {
$query [ 'category_id' ] = ( int ) $path [ 1 ];
try {
return new Response ( $this -> listEntries ( $query , new Context ));
} catch ( ExceptionInput $e ) {
return new ErrorResponse ( " 404 " , 404 );
}
2021-02-02 02:02:46 +00:00
}
2021-02-09 00:14:11 +00:00
2021-02-05 01:19:35 +00:00
protected function getEntry ( array $path ) : ResponseInterface {
try {
return new Response ( $this -> findEntry (( int ) $path [ 1 ]));
} catch ( ExceptionInput $e ) {
return new ErrorResponse ( " 404 " , 404 );
}
}
2021-02-09 00:14:11 +00:00
2021-02-05 01:19:35 +00:00
protected function getFeedEntry ( array $path ) : ResponseInterface {
$c = ( new Context ) -> subscription (( int ) $path [ 1 ]);
try {
return new Response ( $this -> findEntry (( int ) $path [ 3 ], $c ));
} catch ( ExceptionInput $e ) {
return new ErrorResponse ( " 404 " , 404 );
}
}
2021-02-09 00:14:11 +00:00
2021-02-05 01:19:35 +00:00
protected function getCategoryEntry ( array $path ) : ResponseInterface {
$c = new Context ;
if ( $path [ 1 ] === " 1 " ) {
$c -> folderShallow ( 0 );
} else {
$c -> folder (( int ) $path [ 1 ] - 1 );
}
try {
return new Response ( $this -> findEntry (( int ) $path [ 3 ], $c ));
} catch ( ExceptionInput $e ) {
return new ErrorResponse ( " 404 " , 404 );
}
}
2021-02-02 02:02:46 +00:00
2021-02-05 13:48:14 +00:00
protected function updateEntries ( array $data ) : ResponseInterface {
if ( $data [ 'status' ] === " read " ) {
$in = [ 'read' => true , 'hidden' => false ];
} elseif ( $data [ 'status' ] === " unread " ) {
$in = [ 'read' => false , 'hidden' => false ];
} elseif ( $data [ 'status' ] === " removed " ) {
$in = [ 'read' => true , 'hidden' => true ];
}
assert ( isset ( $in ), new \Exception ( " Unknown status specified " ));
Arsse :: $db -> articleMark ( Arsse :: $user -> id , $in , ( new Context ) -> articles ( $data [ 'entry_ids' ]));
return new EmptyResponse ( 204 );
}
protected function massRead ( Context $c ) : void {
Arsse :: $db -> articleMark ( Arsse :: $user -> id , [ 'read' => true ], $c -> hidden ( false ));
}
protected function markUserByNum ( array $path ) : ResponseInterface {
// this function is restricted to the logged-in user
$user = Arsse :: $user -> propertiesGet ( Arsse :: $user -> id , false );
if ((( int ) $path [ 1 ]) !== $user [ 'num' ]) {
return new ErrorResponse ( " 403 " , 403 );
}
$this -> massRead ( new Context );
return new EmptyResponse ( 204 );
}
protected function markFeed ( array $path ) : ResponseInterface {
try {
$this -> massRead (( new Context ) -> subscription (( int ) $path [ 1 ]));
} catch ( ExceptionInput $e ) {
return new ErrorResponse ( " 404 " , 404 );
}
return new EmptyResponse ( 204 );
}
protected function markCategory ( array $path ) : ResponseInterface {
$folder = $path [ 1 ] - 1 ;
$c = new Context ;
if ( $folder === 0 ) {
// if we're marking the root folder don't also mark its child folders, since Miniflux organizes it as a peer of other folders
$c -> folderShallow ( $folder );
} else {
$c -> folder ( $folder );
}
try {
$this -> massRead ( $c );
} catch ( ExceptionInput $e ) {
return new ErrorResponse ( " 404 " , 404 );
}
return new EmptyResponse ( 204 );
}
protected function toggleEntryBookmark ( array $path ) : ResponseInterface {
// NOTE: A toggle is bad design, but we have no choice but to implement what Miniflux does
$id = ( int ) $path [ 1 ];
$c = ( new Context ) -> article ( $id );
try {
$tr = Arsse :: $db -> begin ();
if ( Arsse :: $db -> articleCount ( Arsse :: $user -> id , ( clone $c ) -> starred ( false ))) {
Arsse :: $db -> articleMark ( Arsse :: $user -> id , [ 'starred' => true ], $c );
} else {
Arsse :: $db -> articleMark ( Arsse :: $user -> id , [ 'starred' => false ], $c );
}
$tr -> commit ();
} catch ( ExceptionInput $e ) {
return new ErrorResponse ( " 404 " , 404 );
}
return new EmptyResponse ( 204 );
}
2021-02-05 14:04:00 +00:00
protected function refreshFeed ( array $path ) : ResponseInterface {
// NOTE: This is a no-op; we simply check that the feed exists
try {
Arsse :: $db -> subscriptionPropertiesGet ( Arsse :: $user -> id , ( int ) $path [ 1 ]);
} catch ( ExceptionInput $e ) {
return new ErrorResponse ( " 404 " , 404 );
}
return new EmptyResponse ( 204 );
}
protected function refreshAllFeeds () : ResponseInterface {
// NOTE: This is a no-op
// It could be implemented, but the need is considered low since we use a dynamic schedule always
return new EmptyResponse ( 204 );
}
2021-02-06 01:29:41 +00:00
protected function opmlImport ( string $data ) : ResponseInterface {
try {
2021-02-07 04:51:23 +00:00
Arsse :: $obj -> get ( OPML :: class ) -> import ( Arsse :: $user -> id , $data );
2021-02-06 01:29:41 +00:00
} catch ( ImportException $e ) {
switch ( $e -> getCode ()) {
case 10611 :
return new ErrorResponse ( " InvalidBodyXML " , 400 );
case 10612 :
return new ErrorResponse ( " InvalidBodyOPML " , 422 );
case 10613 :
return new ErrorResponse ( " InvalidImportCategory " , 422 );
case 10614 :
2021-02-07 18:04:44 +00:00
return new ErrorResponse ( " DuplicateImportCategory " , 422 );
2021-02-06 01:29:41 +00:00
case 10615 :
return new ErrorResponse ( " InvalidImportLabel " , 422 );
}
} catch ( FeedException $e ) {
return new ErrorResponse ([ " FailedImportFeed " , 'url' => $e -> getParams ()[ 'url' ], 'code' => $e -> getCode ()], 502 );
}
2021-02-07 18:04:44 +00:00
return new Response ([ 'message' => Arsse :: $lang -> msg ( " API.Miniflux.ImportSuccess " )]);
2021-02-06 01:29:41 +00:00
}
protected function opmlExport () : ResponseInterface {
2021-02-07 04:51:23 +00:00
return new GenericResponse ( Arsse :: $obj -> get ( OPML :: class ) -> export ( Arsse :: $user -> id ), 200 , [ 'Content-Type' => " application/xml " ]);
2021-02-06 01:29:41 +00:00
}
2020-11-01 01:26:11 +00:00
}