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 ;
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 ;
2020-11-01 01:26:11 +00:00
use JKingWeb\Arsse\Misc\HTTP ;
2020-12-08 20:34:31 +00:00
use JKingWeb\Arsse\Misc\Date ;
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 ;
2020-11-23 14:31:50 +00:00
use JKingWeb\Arsse\User\ExceptionConflict 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 ;
2020-11-01 01:26:11 +00:00
class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
2020-12-02 23:00:27 +00:00
public const VERSION = " 2.0.25 " ;
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 " ];
2020-11-30 15:52:32 +00:00
protected const TOKEN_LENGTH = 32 ;
2020-12-02 23:00:27 +00:00
protected const VALID_JSON = [
'url' => " string " ,
'username' => " string " ,
'password' => " string " ,
'user_agent' => " string " ,
2020-12-12 04:47:13 +00:00
'title' => " string " ,
2020-12-02 23:00:27 +00:00
];
2020-12-14 17:41:09 +00:00
protected const CALLS = [ // handler method Admin Path Body Query
'/categories' => [
'GET' => [ " getCategories " , false , false , false , false ],
'POST' => [ " createCategory " , false , false , true , false ],
],
'/categories/1' => [
'PUT' => [ " updateCategory " , false , true , true , false ],
'DELETE' => [ " deleteCategory " , false , true , false , false ],
],
'/categories/1/mark-all-as-read' => [
'PUT' => [ " markCategory " , false , true , false , false ],
],
'/discover' => [
'POST' => [ " discoverSubscriptions " , false , false , true , false ],
],
'/entries' => [
'GET' => [ " getEntries " , false , false , false , true ],
'PUT' => [ " updateEntries " , false , false , true , false ],
],
'/entries/1' => [
'GET' => [ " getEntry " , false , true , false , false ],
],
'/entries/1/bookmark' => [
'PUT' => [ " toggleEntryBookmark " , false , true , false , false ],
],
'/export' => [
'GET' => [ " opmlExport " , false , false , false , false ],
],
'/feeds' => [
'GET' => [ " getFeeds " , false , false , false , false ],
'POST' => [ " createFeed " , false , false , true , false ],
],
'/feeds/1' => [
'GET' => [ " getFeed " , false , true , false , false ],
'PUT' => [ " updateFeed " , false , true , true , false ],
'DELETE' => [ " deleteFeed " , false , true , false , false ],
],
'/feeds/1/entries' => [
'GET' => [ " getFeedEntries " , false , true , false , false ],
],
'/feeds/1/entries/1' => [
'GET' => [ " getFeedEntry " , false , true , false , false ],
],
'/feeds/1/icon' => [
'GET' => [ " getFeedIcon " , false , true , false , false ],
],
'/feeds/1/mark-all-as-read' => [
'PUT' => [ " markFeed " , false , true , false , false ],
],
'/feeds/1/refresh' => [
'PUT' => [ " refreshFeed " , false , true , false , false ],
],
'/feeds/refresh' => [
'PUT' => [ " refreshAllFeeds " , false , false , false , false ],
],
'/import' => [
'POST' => [ " opmlImport " , false , false , true , false ],
],
'/me' => [
'GET' => [ " getCurrentUser " , false , false , false , false ],
],
'/users' => [
'GET' => [ " getUsers " , true , false , false , false ],
'POST' => [ " createUser " , true , false , true , false ],
],
'/users/1' => [
'GET' => [ " getUserByNum " , true , true , false , false ],
'PUT' => [ " updateUserByNum " , true , true , true , false ],
'DELETE' => [ " deleteUserByNum " , true , true , false , false ],
],
'/users/1/mark-all-as-read' => [
'PUT' => [ " markUserByNum " , false , true , false , false ],
],
'/users/*' => [
'GET' => [ " getUserById " , true , true , false , false ],
],
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-12 04:47:13 +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 {
// try to authenticate
if ( ! $this -> authenticate ( $req )) {
return new ErrorResponse ( " 401 " , 401 );
2020-11-01 01:26:11 +00:00
}
// get the request path only; this is assumed to already be normalized
$target = parse_url ( $req -> getRequestTarget ())[ '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 );
}
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 {
[ $func , $reqAdmin , $reqPath , $reqBody , $reqQuery ] = $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 " ) {
if ( ! HTTP :: matchType ( $req , " " , ... [ self :: ACCEPTED_TYPES_OPML ])) {
return new ErrorResponse ( " " , 415 , [ 'Accept' => implode ( " , " , self :: ACCEPTED_TYPES_OPML )]);
}
$args [] = ( string ) $req -> getBody ();
} 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 = [];
}
$data = $this -> normalizeBody (( array ) $data );
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 ) {
$args [] = $req -> getQueryParams ();
2020-11-02 00:09:17 +00:00
}
try {
2020-12-14 17:41:09 +00:00
return $this -> $func ( ... $args );
2020-11-02 00:09:17 +00:00
// @codeCoverageIgnoreStart
} 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-14 17:41:09 +00:00
protected function normalizeBody ( array $body ) {
// 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 ) {
return new ErrorResponse ([ " InvalidInputType " , 'field' => $k , 'expected' => $t , 'actual' => gettype ( $body [ $k ])]);
}
}
return $body ;
}
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 ;
}
}
$out [] = [
'id' => $info [ 'num' ],
'username' => $u ,
'is_admin' => $info [ 'admin' ] ? ? false ,
'theme' => $info [ 'theme' ] ? ? " light_serif " ,
'language' => $info [ 'lang' ] ? ? " en_US " ,
'timezone' => $info [ 'tz' ] ? ? " UTC " ,
'entry_sorting_direction' => ( $info [ 'sort_asc' ] ? ? false ) ? " asc " : " desc " ,
'entries_per_page' => $info [ 'page_size' ] ? ? 100 ,
'keyboard_shortcuts' => $info [ 'shortcuts' ] ? ? true ,
'show_reading_time' => $info [ 'reading_time' ] ? ? true ,
'last_login_at' => $now ,
'entry_swipe' => $info [ 'swipe' ] ? ? true ,
'extra' => [
'custom_css' => $info [ 'stylesheet' ] ? ? " " ,
],
];
}
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 " ,
][ $e -> getCode ()] ? ? " FetchOther " ;
2020-12-02 23:00:27 +00:00
return new ErrorResponse ( $msg , 500 );
}
$out = [];
foreach ( $list as $url ) {
// 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-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-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-14 17:41:09 +00:00
protected function getCategories () : ResponseInterface {
2020-12-11 18:31:35 +00:00
$out = [];
$meta = Arsse :: $user -> propertiesGet ( Arsse :: $user -> id , false );
// add the root folder as a category
$out [] = [ 'id' => 1 , 'title' => $meta [ 'root_folder_name' ] ? ? Arsse :: $lang -> msg ( " API.Miniflux.DefaultCategoryName " ), 'user_id' => $meta [ 'num' ]];
// 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.
$out [] = [ 'id' => $f [ 'id' ] + 1 , 'title' => $f [ 'name' ], 'user_id' => $meta [ 'num' ]];
}
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 ) {
return new ErrorResponse ([ " DuplicateCategory " , 'title' => $data [ 'title' ]], 500 );
} else {
return new ErrorResponse ([ " InvalidCategory " , 'title' => $data [ 'title' ]], 500 );
}
}
$meta = Arsse :: $user -> propertiesGet ( Arsse :: $user -> id , false );
return new Response ([ 'id' => $id + 1 , 'title' => $data [ 'title' ], 'user_id' => $meta [ 'num' ]]);
}
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 ) {
return new ErrorResponse ([ " DuplicateCategory " , 'title' => $title ], 500 );
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 {
return new ErrorResponse ([ " InvalidCategory " , 'title' => $title ], 500 );
}
}
$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
// otherwise we'd be deleting the entire tree
$tr = Arsse :: $db -> begin ();
foreach ( Arsse :: $db -> subscriptionList ( Arsse :: $user -> id , null , false ) as $sub ) {
Arsse :: $db -> subscriptionRemove ( Arsse :: $user -> id , $sub [ 'id' ]);
}
$tr -> commit ();
}
} catch ( ExceptionInput $e ) {
return new ErrorResponse ( " 404 " , 404 );
}
return new EmptyResponse ( 204 );
}
2020-12-14 17:41:09 +00:00
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 = $c -> folderShallow ( $folder );
} else {
$c = $c -> folder ( $folder );
}
try {
Arsse :: $db -> articleMark ( Arsse :: $user -> id , [ 'read' => true ], $c );
} catch ( ExceptionInput $e ) {
return new ErrorResponse ( " 404 " , 404 );
}
return new EmptyResponse ( 204 );
}
2020-11-23 14:31:50 +00:00
public static function tokenGenerate ( string $user , string $label ) : string {
2020-11-30 15:52:32 +00:00
// Miniflux produces tokens in base64url alphabet
$t = str_replace ([ " + " , " / " ], [ " - " , " _ " ], base64_encode ( random_bytes ( self :: TOKEN_LENGTH )));
2020-11-23 14:31:50 +00:00
return Arsse :: $db -> tokenCreate ( $user , " miniflux.login " , $t , null , $label );
}
public static function tokenList ( string $user ) : array {
if ( ! Arsse :: $db -> userExists ( $user )) {
throw new UserException ( " doesNotExist " , [ " action " => __FUNCTION__ , " user " => $user ]);
}
$out = [];
foreach ( Arsse :: $db -> tokenList ( $user , " miniflux.login " ) as $r ) {
$out [] = [ 'label' => $r [ 'data' ], 'id' => $r [ 'id' ]];
}
return $out ;
}
2020-11-01 01:26:11 +00:00
}