From 94154d43543f4b53414c058a364086ae1c734e69 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Wed, 2 Dec 2020 18:00:27 -0500 Subject: [PATCH] Implement Miniflux feed discovery --- lib/REST/Miniflux/V1.php | 59 +++++++++++++++++++++++++--- lib/REST/NextcloudNews/V1_2.php | 1 - locale/en.php | 5 +++ tests/cases/REST/Miniflux/TestV1.php | 17 ++++++++ 4 files changed, 75 insertions(+), 7 deletions(-) diff --git a/lib/REST/Miniflux/V1.php b/lib/REST/Miniflux/V1.php index c2e84dd7..321716da 100644 --- a/lib/REST/Miniflux/V1.php +++ b/lib/REST/Miniflux/V1.php @@ -7,21 +7,31 @@ declare(strict_types=1); namespace JKingWeb\Arsse\REST\Miniflux; use JKingWeb\Arsse\Arsse; +use JKingWeb\Arsse\Feed; +use JKingWeb\Arsse\Feed\Exception as FeedException; use JKingWeb\Arsse\AbstractException; use JKingWeb\Arsse\Db\ExceptionInput; use JKingWeb\Arsse\Misc\HTTP; -use JKingWeb\Arsse\Misc\ValueInfo; +use JKingWeb\Arsse\Misc\ValueInfo as V; use JKingWeb\Arsse\REST\Exception; use JKingWeb\Arsse\User\ExceptionConflict as UserException; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\ResponseInterface; use Laminas\Diactoros\Response\EmptyResponse; +use Laminas\Diactoros\Response\JsonResponse as Response; class V1 extends \JKingWeb\Arsse\REST\AbstractHandler { + public const VERSION = "2.0.25"; + protected const ACCEPTED_TYPES_OPML = ["application/xml", "text/xml", "text/x-opml"]; protected const ACCEPTED_TYPES_JSON = ["application/json"]; protected const TOKEN_LENGTH = 32; - public const VERSION = "2.0.25"; + protected const VALID_JSON = [ + 'url' => "string", + 'username' => "string", + 'password' => "string", + 'user_agent' => "string", + ]; protected $paths = [ '/categories' => ['GET' => "getCategories", 'POST' => "createCategory"], @@ -86,6 +96,8 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler { if ($func instanceof ResponseInterface) { return $func; } + $data = []; + $query = []; if ($func === "opmlImport") { if (!HTTP::matchType($req, "", ...[self::ACCEPTED_TYPES_OPML])) { return new ErrorResponse("", 415, ['Accept' => implode(", ", self::ACCEPTED_TYPES_OPML)]); @@ -97,12 +109,16 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler { // if the body could not be parsed as JSON, return "400 Bad Request" return new ErrorResponse(["invalidBodyJSON", json_last_error_msg()], 400); } - } else { - $data = null; + $data = $this->normalizeBody((array) $data); + if ($data instanceof ResponseInterface) { + return $data; + } + } elseif ($method === "GET") { + $query = $req->getQueryParams(); } try { $path = explode("/", ltrim($target, "/")); - return $this->$func($path, $req->getQueryParams(), $data); + return $this->$func($path, $query, $data); // @codeCoverageIgnoreStart } catch (Exception $e) { // if there was a REST exception return 400 @@ -118,7 +134,7 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler { $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++) { - if (ValueInfo::id($path[$a])) { + if (V::id($path[$a])) { $path[$a] = "1"; } } @@ -172,6 +188,37 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler { } } + 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; + } + + protected function discoverSubscriptions(array $path, array $query, array $data) { + try { + $list = Feed::discoverAll((string) $data['url'], (string) $data['username'], (string) $data['password']); + } catch (FeedException $e) { + $msg = [ + 10502 => "fetch404", + 10506 => "fetch403", + 10507 => "fetch401", + ][$e->getCode()] ?? "fetchOther"; + 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); + } + public static function tokenGenerate(string $user, string $label): string { // Miniflux produces tokens in base64url alphabet $t = str_replace(["+", "/"], ["-", "_"], base64_encode(random_bytes(self::TOKEN_LENGTH))); diff --git a/lib/REST/NextcloudNews/V1_2.php b/lib/REST/NextcloudNews/V1_2.php index 7cefe13e..c73ea8c7 100644 --- a/lib/REST/NextcloudNews/V1_2.php +++ b/lib/REST/NextcloudNews/V1_2.php @@ -22,7 +22,6 @@ use Laminas\Diactoros\Response\EmptyResponse; class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler { public const VERSION = "11.0.5"; - protected const REALM = "Nextcloud News API v1-2"; protected const ACCEPTED_TYPE = "application/json"; protected $dateFormat = "unix"; diff --git a/locale/en.php b/locale/en.php index c0dea555..f58d7b49 100644 --- a/locale/en.php +++ b/locale/en.php @@ -9,6 +9,11 @@ return [ 'API.Miniflux.Error.401' => 'Access Unauthorized', 'API.Miniflux.Error.invalidBodyJSON' => 'Invalid JSON payload: {0}', + 'API.Miniflux.Error.invalidInputType' => 'Input key "{field}" of type {actual} was expected as {expected}', + 'API.Miniflux.Error.fetch404' => 'Resource not found (404), this feed doesn\'t exists anymore, check the feed URL', + 'API.Miniflux.Error.fetch401' => 'You are not authorized to access this resource (invalid username/password)', + 'API.Miniflux.Error.fetch403' => 'Unable to fetch this resource (Status Code = 403)', + 'API.Miniflux.Error.fetchOther' => 'Unable to fetch this resource', 'API.TTRSS.Category.Uncategorized' => 'Uncategorized', 'API.TTRSS.Category.Special' => 'Special', diff --git a/tests/cases/REST/Miniflux/TestV1.php b/tests/cases/REST/Miniflux/TestV1.php index a66a8900..e94d1450 100644 --- a/tests/cases/REST/Miniflux/TestV1.php +++ b/tests/cases/REST/Miniflux/TestV1.php @@ -122,4 +122,21 @@ class TestV1 extends \JKingWeb\Arsse\Test\AbstractTest { ["/import", "POST", "application/xml, text/xml, text/x-opml"], ]; } + + public function testRejectBadlyTypedData(): void { + $exp = new ErrorResponse(["invalidInputType", 'field' => "url", 'expected' => "string", 'actual' => "integer"], 400); + $this->assertMessage($exp, $this->req("POST", "/discover", ['url' => 2112])); + } + + public function testDiscoverFeeds(): void { + $exp = new Response([ + ['title' => "Feed", 'type' => "rss", 'url' => "http://localhost:8000/Feed/Discovery/Feed"], + ['title' => "Feed", 'type' => "rss", 'url' => "http://localhost:8000/Feed/Discovery/Missing"], + ]); + $this->assertMessage($exp, $this->req("POST", "/discover", ['url' => "http://localhost:8000/Feed/Discovery/Valid"])); + $exp = new Response([]); + $this->assertMessage($exp, $this->req("POST", "/discover", ['url' => "http://localhost:8000/Feed/Discovery/Invalid"])); + $exp = new ErrorResponse("fetch404", 500); + $this->assertMessage($exp, $this->req("POST", "/discover", ['url' => "http://localhost:8000/Feed/Discovery/Missing"])); + } }