Browse Source

Implement Miniflux feed discovery

rpm
J. King 3 years ago
parent
commit
94154d4354
  1. 59
      lib/REST/Miniflux/V1.php
  2. 1
      lib/REST/NextcloudNews/V1_2.php
  3. 5
      locale/en.php
  4. 17
      tests/cases/REST/Miniflux/TestV1.php

59
lib/REST/Miniflux/V1.php

@ -7,21 +7,31 @@ declare(strict_types=1);
namespace JKingWeb\Arsse\REST\Miniflux; namespace JKingWeb\Arsse\REST\Miniflux;
use JKingWeb\Arsse\Arsse; use JKingWeb\Arsse\Arsse;
use JKingWeb\Arsse\Feed;
use JKingWeb\Arsse\Feed\Exception as FeedException;
use JKingWeb\Arsse\AbstractException; use JKingWeb\Arsse\AbstractException;
use JKingWeb\Arsse\Db\ExceptionInput; use JKingWeb\Arsse\Db\ExceptionInput;
use JKingWeb\Arsse\Misc\HTTP; 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\REST\Exception;
use JKingWeb\Arsse\User\ExceptionConflict as UserException; use JKingWeb\Arsse\User\ExceptionConflict as UserException;
use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ResponseInterface;
use Laminas\Diactoros\Response\EmptyResponse; use Laminas\Diactoros\Response\EmptyResponse;
use Laminas\Diactoros\Response\JsonResponse as Response;
class V1 extends \JKingWeb\Arsse\REST\AbstractHandler { 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_OPML = ["application/xml", "text/xml", "text/x-opml"];
protected const ACCEPTED_TYPES_JSON = ["application/json"]; protected const ACCEPTED_TYPES_JSON = ["application/json"];
protected const TOKEN_LENGTH = 32; 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 = [ protected $paths = [
'/categories' => ['GET' => "getCategories", 'POST' => "createCategory"], '/categories' => ['GET' => "getCategories", 'POST' => "createCategory"],
@ -86,6 +96,8 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
if ($func instanceof ResponseInterface) { if ($func instanceof ResponseInterface) {
return $func; return $func;
} }
$data = [];
$query = [];
if ($func === "opmlImport") { if ($func === "opmlImport") {
if (!HTTP::matchType($req, "", ...[self::ACCEPTED_TYPES_OPML])) { if (!HTTP::matchType($req, "", ...[self::ACCEPTED_TYPES_OPML])) {
return new ErrorResponse("", 415, ['Accept' => implode(", ", 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" // if the body could not be parsed as JSON, return "400 Bad Request"
return new ErrorResponse(["invalidBodyJSON", json_last_error_msg()], 400); return new ErrorResponse(["invalidBodyJSON", json_last_error_msg()], 400);
} }
} else { $data = $this->normalizeBody((array) $data);
$data = null; if ($data instanceof ResponseInterface) {
return $data;
}
} elseif ($method === "GET") {
$query = $req->getQueryParams();
} }
try { try {
$path = explode("/", ltrim($target, "/")); $path = explode("/", ltrim($target, "/"));
return $this->$func($path, $req->getQueryParams(), $data); return $this->$func($path, $query, $data);
// @codeCoverageIgnoreStart // @codeCoverageIgnoreStart
} catch (Exception $e) { } catch (Exception $e) {
// if there was a REST exception return 400 // if there was a REST exception return 400
@ -118,7 +134,7 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
$path = explode("/", $url); $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) // 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++) { for ($a = 0; $a < sizeof($path); $a++) {
if (ValueInfo::id($path[$a])) { if (V::id($path[$a])) {
$path[$a] = "1"; $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 { public static function tokenGenerate(string $user, string $label): string {
// Miniflux produces tokens in base64url alphabet // Miniflux produces tokens in base64url alphabet
$t = str_replace(["+", "/"], ["-", "_"], base64_encode(random_bytes(self::TOKEN_LENGTH))); $t = str_replace(["+", "/"], ["-", "_"], base64_encode(random_bytes(self::TOKEN_LENGTH)));

1
lib/REST/NextcloudNews/V1_2.php

@ -22,7 +22,6 @@ use Laminas\Diactoros\Response\EmptyResponse;
class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler { class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler {
public const VERSION = "11.0.5"; public const VERSION = "11.0.5";
protected const REALM = "Nextcloud News API v1-2";
protected const ACCEPTED_TYPE = "application/json"; protected const ACCEPTED_TYPE = "application/json";
protected $dateFormat = "unix"; protected $dateFormat = "unix";

5
locale/en.php

@ -9,6 +9,11 @@ return [
'API.Miniflux.Error.401' => 'Access Unauthorized', 'API.Miniflux.Error.401' => 'Access Unauthorized',
'API.Miniflux.Error.invalidBodyJSON' => 'Invalid JSON payload: {0}', '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.Uncategorized' => 'Uncategorized',
'API.TTRSS.Category.Special' => 'Special', 'API.TTRSS.Category.Special' => 'Special',

17
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"], ["/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"]));
}
} }

Loading…
Cancel
Save