diff --git a/lib/Database.php b/lib/Database.php
index ace75a83..760a0de4 100644
--- a/lib/Database.php
+++ b/lib/Database.php
@@ -400,7 +400,7 @@ class Database {
* @param \DateTimeInterface|null $expires An optional expiry date and time for the token
* @param string $data Application-specific data associated with a token
*/
- public function tokenCreate(string $user, string $class, string $id = null, \DateTimeInterface $expires = null, string $data = null): string {
+ public function tokenCreate(string $user, string $class, string $id = null, ?\DateTimeInterface $expires = null, string $data = null): string {
if (!$this->userExists($user)) {
throw new User\ExceptionConflict("doesNotExist", ["action" => __FUNCTION__, "user" => $user]);
}
@@ -418,7 +418,7 @@ class Database {
* @param string $class The class of the token e.g. the protocol name
* @param string|null $id The ID of a specific token, or null for all tokens in the class
*/
- public function tokenRevoke(string $user, string $class, string $id = null): bool {
+ public function tokenRevoke(string $user, string $class, ?string $id = null): bool {
if (is_null($id)) {
$out = $this->db->prepare("DELETE FROM arsse_tokens where \"user\" = ? and class = ?", "str", "str")->run($user, $class)->changes();
} else {
@@ -436,6 +436,11 @@ class Database {
return $out;
}
+ /** List tokens associated with a user */
+ public function tokenList(string $user, string $class): Db\Result {
+ return $this->db->prepare("SELECT id,created,expires,data from arsse_tokens where class = ? and user = ? and (expires is null or expires > CURRENT_TIMESTAMP)", "str", "str")->run($class, $user);
+ }
+
/** Deletes expires tokens from the database, returning the number of deleted tokens */
public function tokenCleanup(): int {
return $this->db->query("DELETE FROM arsse_tokens where expires < CURRENT_TIMESTAMP")->changes();
diff --git a/lib/REST.php b/lib/REST.php
index 011d27df..4f1f4bdd 100644
--- a/lib/REST.php
+++ b/lib/REST.php
@@ -42,9 +42,19 @@ class REST {
],
'miniflux' => [ // Miniflux https://miniflux.app/docs/api.html
'match' => '/v1/',
- 'strip' => '/v1',
+ 'strip' => '',
'class' => REST\Miniflux\V1::class,
],
+ 'miniflux-version' => [ // Miniflux version report
+ 'match' => '/version',
+ 'strip' => '',
+ 'class' => REST\Miniflux\Status::class,
+ ],
+ 'miniflux-healthcheck' => [ // Miniflux health check
+ 'match' => '/healthcheck',
+ 'strip' => '',
+ 'class' => REST\Miniflux\Status::class,
+ ],
// Other candidates:
// Microsub https://indieweb.org/Microsub
// Google Reader http://feedhq.readthedocs.io/en/latest/api/index.html
diff --git a/lib/REST/Miniflux/ErrorResponse.php b/lib/REST/Miniflux/ErrorResponse.php
new file mode 100644
index 00000000..1cf467ee
--- /dev/null
+++ b/lib/REST/Miniflux/ErrorResponse.php
@@ -0,0 +1,19 @@
+ Arsse::$lang->msg("API.Miniflux.Error.".$msg, $data)];
+ parent::__construct($data, $status, $headers, $encodingOptions);
+ }
+}
diff --git a/lib/REST/Miniflux/Status.php b/lib/REST/Miniflux/Status.php
new file mode 100644
index 00000000..367a7a65
--- /dev/null
+++ b/lib/REST/Miniflux/Status.php
@@ -0,0 +1,37 @@
+getRequestTarget())['path'] ?? "";
+ if (!in_array($target, ["/version", "/healthcheck"])) {
+ return new EmptyResponse(404);
+ }
+ $method = $req->getMethod();
+ if ($method === "OPTIONS") {
+ return new EmptyResponse(204, ['Allow' => "HEAD, GET"]);
+ } elseif ($method !== "GET") {
+ return new EmptyResponse(405, ['Allow' => "HEAD, GET"]);
+ }
+ $out = "";
+ if ($target === "/version") {
+ $out = V1::VERSION;
+ } elseif ($target === "/healthcheck") {
+ $out = "OK";
+ }
+ return new TextResponse($out);
+ }
+}
diff --git a/lib/REST/Miniflux/V1.php b/lib/REST/Miniflux/V1.php
index 74873afd..45d61914 100644
--- a/lib/REST/Miniflux/V1.php
+++ b/lib/REST/Miniflux/V1.php
@@ -13,6 +13,7 @@ use JKingWeb\Arsse\Misc\HTTP;
use JKingWeb\Arsse\REST\Exception;
use JKingWeb\Arsse\REST\Exception404;
use JKingWeb\Arsse\REST\Exception405;
+use JKingWeb\Arsse\User\ExceptionConflict as UserException;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\ResponseInterface;
use Laminas\Diactoros\Response\EmptyResponse;
@@ -20,6 +21,8 @@ use Laminas\Diactoros\Response\EmptyResponse;
class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
protected const ACCEPTED_TYPES_OPML = ["text/xml", "application/xml", "text/x-opml"];
protected const ACCEPTED_TYPES_JSON = ["application/json", "text/json"];
+ public const VERSION = "2.0.25";
+
protected $paths = [
'/categories' => ['GET' => "getCategories", 'POST' => "createCategory"],
'/categories/1' => ['PUT' => "updateCategory", 'DELETE' => "deleteCategory"],
@@ -35,25 +38,41 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
'/feeds/1/icon' => ['GET' => "getFeedIcon"],
'/feeds/1/refresh' => ['PUT' => "refreshFeed"],
'/feeds/refresh' => ['PUT' => "refreshAllFeeds"],
- '/healthcheck' => ['GET' => "healthCheck"],
'/import' => ['POST' => "opmlImport"],
'/me' => ['GET' => "getCurrentUser"],
'/users' => ['GET' => "getUsers", 'POST' => "createUser"],
'/users/1' => ['GET' => "getUser", 'PUT' => "updateUser", 'DELETE' => "deleteUser"],
'/users/*' => ['GET' => "getUser"],
- '/version' => ['GET' => "getVersion"],
];
public function __construct() {
}
- public function dispatch(ServerRequestInterface $req): ResponseInterface {
- // try to authenticate
+ protected function authenticate(ServerRequestInterface $req): bool {
+ // first check any tokens; this is what Miniflux does
+ foreach ($req->getHeader("X-Auth-Token") as $t) {
+ if (strlen($t)) {
+ // a non-empty header is authoritative, so we'll stop here one way or the other
+ try {
+ $d = Arsse::$db->tokenLookup("miniflux.login", $t);
+ } catch (ExceptionInput $e) {
+ return false;
+ }
+ Arsse::$user->id = $d->user;
+ return true;
+ }
+ }
+ // next check HTTP auth
if ($req->getAttribute("authenticated", false)) {
Arsse::$user->id = $req->getAttribute("authenticatedUser");
- } else {
- // TODO: Handle X-Auth-Token authentication
- return new EmptyResponse(401);
+ }
+ return false;
+ }
+
+ public function dispatch(ServerRequestInterface $req): ResponseInterface {
+ // try to authenticate
+ if (!$this->authenticate($req)) {
+ return new ErrorResponse("401", 401);
}
// get the request path only; this is assumed to already be normalized
$target = parse_url($req->getRequestTarget())['path'] ?? "";
@@ -65,17 +84,14 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
$func = $this->chooseCall($target, $method);
if ($func === "opmlImport") {
if (!HTTP::matchType($req, "", ...[self::ACCEPTED_TYPES_OPML])) {
- return new EmptyResponse(415, ['Accept' => implode(", ", self::ACCEPTED_TYPES_OPML)]);
+ return new ErrorResponse(415, ['Accept' => implode(", ", self::ACCEPTED_TYPES_OPML)]);
}
$data = (string) $req->getBody();
} elseif ($method === "POST" || $method === "PUT") {
- if (!HTTP::matchType($req, "", ...[self::ACCEPTED_TYPES_JSON])) {
- return new EmptyResponse(415, ['Accept' => implode(", ", self::ACCEPTED_TYPES_JSON)]);
- }
$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 EmptyResponse(400);
+ return new ErrorResponse(["invalidBodyJSON", json_last_error_msg()], 400);
}
} else {
$data = null;
@@ -154,4 +170,20 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
throw new Exception404();
}
}
+
+ public static function tokenGenerate(string $user, string $label): string {
+ $t = base64_encode(random_bytes(24));
+ 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;
+ }
}
diff --git a/lib/User.php b/lib/User.php
index 0a70e5d2..e8359bc0 100644
--- a/lib/User.php
+++ b/lib/User.php
@@ -69,7 +69,6 @@ class User {
}
return $out;
}
-
public function remove(string $user): bool {
try {
@@ -143,7 +142,7 @@ class User {
if (array_key_exists("tz", $data)) {
if (!is_string($data['tz'])) {
throw new User\ExceptionInput("invalidTimezone", ['field' => "tz", 'value' => ""]);
- } elseif(!@timezone_open($data['tz'])) {
+ } elseif (!@timezone_open($data['tz'])) {
throw new User\ExceptionInput("invalidTimezone", ['field' => "tz", 'value' => $data['tz']]);
}
$in['tz'] = $data['tz'];
diff --git a/locale/en.php b/locale/en.php
index 66a03eee..c0dea555 100644
--- a/locale/en.php
+++ b/locale/en.php
@@ -7,6 +7,9 @@ return [
'CLI.Auth.Success' => 'Authentication successful',
'CLI.Auth.Failure' => 'Authentication failed',
+ 'API.Miniflux.Error.401' => 'Access Unauthorized',
+ 'API.Miniflux.Error.invalidBodyJSON' => 'Invalid JSON payload: {0}',
+
'API.TTRSS.Category.Uncategorized' => 'Uncategorized',
'API.TTRSS.Category.Special' => 'Special',
'API.TTRSS.Category.Labels' => 'Labels',
diff --git a/tests/cases/Database/SeriesToken.php b/tests/cases/Database/SeriesToken.php
index 3f766aa3..7a14ed0d 100644
--- a/tests/cases/Database/SeriesToken.php
+++ b/tests/cases/Database/SeriesToken.php
@@ -33,12 +33,16 @@ trait SeriesToken {
'class' => "str",
'user' => "str",
'expires' => "datetime",
+ 'data' => "str",
],
'rows' => [
- ["80fa94c1a11f11e78667001e673b2560", "fever.login", "jane.doe@example.com", $faroff],
- ["27c6de8da13311e78667001e673b2560", "fever.login", "jane.doe@example.com", $past], // expired
- ["ab3b3eb8a13311e78667001e673b2560", "class.class", "jane.doe@example.com", null],
- ["da772f8fa13c11e78667001e673b2560", "class.class", "john.doe@example.com", $future],
+ ["80fa94c1a11f11e78667001e673b2560", "fever.login", "jane.doe@example.com", $faroff, null],
+ ["27c6de8da13311e78667001e673b2560", "fever.login", "jane.doe@example.com", $past, null], // expired
+ ["ab3b3eb8a13311e78667001e673b2560", "class.class", "jane.doe@example.com", null, null],
+ ["da772f8fa13c11e78667001e673b2560", "class.class", "john.doe@example.com", $future, null],
+ ["A", "miniflux.login", "jane.doe@example.com", null, "Label 1"],
+ ["B", "miniflux.login", "jane.doe@example.com", null, "Label 2"],
+ ["C", "miniflux.login", "john.doe@example.com", null, "Label 1"],
],
],
];
@@ -127,4 +131,13 @@ trait SeriesToken {
// revoking tokens which do not exist is not an error
$this->assertFalse(Arsse::$db->tokenRevoke($user, "unknown.class"));
}
+
+ public function testListTokens(): void {
+ $user = "jane.doe@example.com";
+ $exp = [
+ ['id' => "A", 'data' => "Label 1"],
+ ['id' => "B", 'data' => "Label 2"],
+ ];
+ $this->assertResult($exp, Arsse::$db->tokenList($user, "miniflux.login"));
+ }
}
diff --git a/tests/cases/REST/Miniflux/TestErrorResponse.php b/tests/cases/REST/Miniflux/TestErrorResponse.php
new file mode 100644
index 00000000..23d6e286
--- /dev/null
+++ b/tests/cases/REST/Miniflux/TestErrorResponse.php
@@ -0,0 +1,22 @@
+assertSame('{"error_message":"Access Unauthorized"}', (string) $act->getBody());
+ }
+
+ public function testCreateVariableResponse(): void {
+ $act = new ErrorResponse(["invalidBodyJSON", "Doh!"], 401);
+ $this->assertSame('{"error_message":"Invalid JSON payload: Doh!"}', (string) $act->getBody());
+ }
+}
diff --git a/tests/cases/REST/Miniflux/TestStatus.php b/tests/cases/REST/Miniflux/TestStatus.php
new file mode 100644
index 00000000..bcf81d18
--- /dev/null
+++ b/tests/cases/REST/Miniflux/TestStatus.php
@@ -0,0 +1,34 @@
+dispatch($this->serverRequest($method, $url, ""));
+ $this->assertMessage($exp, $act);
+ }
+
+ public function provideRequests(): iterable {
+ return [
+ ["/version", "GET", new TextResponse(V1::VERSION)],
+ ["/version", "POST", new EmptyResponse(405, ['Allow' => "HEAD, GET"])],
+ ["/version", "OPTIONS", new EmptyResponse(204, ['Allow' => "HEAD, GET"])],
+ ["/healthcheck", "GET", new TextResponse("OK")],
+ ["/healthcheck", "POST", new EmptyResponse(405, ['Allow' => "HEAD, GET"])],
+ ["/healthcheck", "OPTIONS", new EmptyResponse(204, ['Allow' => "HEAD, GET"])],
+ ["/version/", "GET", new EmptyResponse(404)],
+ ];
+ }
+}
diff --git a/tests/cases/REST/Miniflux/TestV1.php b/tests/cases/REST/Miniflux/TestV1.php
new file mode 100644
index 00000000..7d20a0a8
--- /dev/null
+++ b/tests/cases/REST/Miniflux/TestV1.php
@@ -0,0 +1,118 @@
+ */
+class TestV1 extends \JKingWeb\Arsse\Test\AbstractTest {
+ protected $h;
+ protected $transaction;
+
+ protected function req(string $method, string $target, $data = "", array $headers = [], bool $authenticated = true, bool $body = true): ResponseInterface {
+ $prefix = "/v1";
+ $url = $prefix.$target;
+ if ($body) {
+ $params = [];
+ } else {
+ $params = $data;
+ $data = [];
+ }
+ $req = $this->serverRequest($method, $url, $prefix, $headers, [], $data, "application/json", $params, $authenticated ? "john.doe@example.com" : "");
+ return $this->h->dispatch($req);
+ }
+
+ public function setUp(): void {
+ self::clearData();
+ self::setConf();
+ // create a mock user manager
+ Arsse::$user = \Phake::mock(User::class);
+ Arsse::$user->id = "john.doe@example.com";
+ // create a mock database interface
+ Arsse::$db = \Phake::mock(Database::class);
+ $this->transaction = \Phake::mock(Transaction::class);
+ \Phake::when(Arsse::$db)->begin->thenReturn($this->transaction);
+ //initialize a handler
+ $this->h = new V1();
+ }
+
+ public function tearDown(): void {
+ self::clearData();
+ }
+
+ protected function v($value) {
+ return $value;
+ }
+
+ public function testSendAuthenticationChallenge(): void {
+ $exp = new EmptyResponse(401);
+ $this->assertMessage($exp, $this->req("GET", "/", "", [], false));
+ }
+
+ /** @dataProvider provideInvalidPaths */
+ public function testRespondToInvalidPaths($path, $method, $code, $allow = null): void {
+ $exp = new EmptyResponse($code, $allow ? ['Allow' => $allow] : []);
+ $this->assertMessage($exp, $this->req($method, $path));
+ }
+
+ public function provideInvalidPaths(): array {
+ return [
+ ["/", "GET", 404],
+ ["/", "POST", 404],
+ ["/", "PUT", 404],
+ ["/", "DELETE", 404],
+ ["/", "OPTIONS", 404],
+ ["/version/invalid", "GET", 404],
+ ["/version/invalid", "POST", 404],
+ ["/version/invalid", "PUT", 404],
+ ["/version/invalid", "DELETE", 404],
+ ["/version/invalid", "OPTIONS", 404],
+ ["/folders/1/invalid", "GET", 404],
+ ["/folders/1/invalid", "POST", 404],
+ ["/folders/1/invalid", "PUT", 404],
+ ["/folders/1/invalid", "DELETE", 404],
+ ["/folders/1/invalid", "OPTIONS", 404],
+ ["/version", "POST", 405, "GET"],
+ ["/version", "PUT", 405, "GET"],
+ ["/version", "DELETE", 405, "GET"],
+ ["/folders", "PUT", 405, "GET, POST"],
+ ["/folders", "DELETE", 405, "GET, POST"],
+ ["/folders/1", "GET", 405, "PUT, DELETE"],
+ ["/folders/1", "POST", 405, "PUT, DELETE"],
+ ];
+ }
+
+ public function testRespondToInvalidInputTypes(): void {
+ $exp = new EmptyResponse(415, ['Accept' => "application/json"]);
+ $this->assertMessage($exp, $this->req("PUT", "/folders/1", '', ['Content-Type' => "application/xml"]));
+ $exp = new EmptyResponse(400);
+ $this->assertMessage($exp, $this->req("PUT", "/folders/1", ''));
+ $this->assertMessage($exp, $this->req("PUT", "/folders/1", '', ['Content-Type' => null]));
+ }
+
+ /** @dataProvider provideOptionsRequests */
+ public function testRespondToOptionsRequests(string $url, string $allow, string $accept): void {
+ $exp = new EmptyResponse(204, [
+ 'Allow' => $allow,
+ 'Accept' => $accept,
+ ]);
+ $this->assertMessage($exp, $this->req("OPTIONS", $url));
+ }
+
+ public function provideOptionsRequests(): array {
+ return [
+ ["/feeds", "HEAD,GET,POST", "application/json"],
+ ["/feeds/2112", "DELETE", "application/json"],
+ ["/user", "HEAD,GET", "application/json"],
+ ];
+ }
+}
diff --git a/tests/cases/User/TestUser.php b/tests/cases/User/TestUser.php
index 310a0a33..8863d5fc 100644
--- a/tests/cases/User/TestUser.php
+++ b/tests/cases/User/TestUser.php
@@ -9,7 +9,6 @@ namespace JKingWeb\Arsse\TestCase\User;
use JKingWeb\Arsse\Arsse;
use JKingWeb\Arsse\Database;
use JKingWeb\Arsse\User;
-use JKingWeb\Arsse\AbstractException as Exception;
use JKingWeb\Arsse\User\ExceptionConflict;
use JKingWeb\Arsse\User\ExceptionInput;
use JKingWeb\Arsse\User\Driver;
@@ -25,7 +24,7 @@ class TestUser extends \JKingWeb\Arsse\Test\AbstractTest {
// create a mock user driver
$this->drv = \Phake::mock(Driver::class);
}
-
+
public function tearDown(): void {
\Phake::verifyNoOtherInteractions($this->drv);
\Phake::verifyNoOtherInteractions(Arsse::$db);
@@ -43,7 +42,7 @@ class TestUser extends \JKingWeb\Arsse\Test\AbstractTest {
$u->id = null;
$this->assertSame("", (string) $u);
}
-
+
public function testGeneratePasswords(): void {
$u = new User($this->drv);
$pass1 = $u->generatePassword();
@@ -166,7 +165,7 @@ class TestUser extends \JKingWeb\Arsse\Test\AbstractTest {
\Phake::verify($this->drv)->userAdd($user, $pass);
\Phake::verify(Arsse::$db)->userExists($user);
}
-
+
public function testRemoveAUser(): void {
$user = "john.doe@example.com";
$pass = "secret";
@@ -178,7 +177,7 @@ class TestUser extends \JKingWeb\Arsse\Test\AbstractTest {
\Phake::verify(Arsse::$db)->userRemove($user);
\Phake::verify($this->drv)->userRemove($user);
}
-
+
public function testRemoveAUserWeDoNotKnow(): void {
$user = "john.doe@example.com";
$pass = "secret";
@@ -189,7 +188,7 @@ class TestUser extends \JKingWeb\Arsse\Test\AbstractTest {
\Phake::verify(Arsse::$db)->userExists($user);
\Phake::verify($this->drv)->userRemove($user);
}
-
+
public function testRemoveAMissingUser(): void {
$user = "john.doe@example.com";
$pass = "secret";
@@ -205,7 +204,7 @@ class TestUser extends \JKingWeb\Arsse\Test\AbstractTest {
\Phake::verify($this->drv)->userRemove($user);
}
}
-
+
public function testRemoveAMissingUserWeDoNotKnow(): void {
$user = "john.doe@example.com";
$pass = "secret";
@@ -381,7 +380,7 @@ class TestUser extends \JKingWeb\Arsse\Test\AbstractTest {
\Phake::verify($this->drv)->userPropertiesGet($user);
}
}
-
+
/** @dataProvider providePropertyChanges */
public function testSetThePropertiesOfAUser(array $in, $out): void {
$user = "john.doe@example.com";
@@ -399,7 +398,7 @@ class TestUser extends \JKingWeb\Arsse\Test\AbstractTest {
\Phake::verify(Arsse::$db)->userExists($user);
}
}
-
+
/** @dataProvider providePropertyChanges */
public function testSetThePropertiesOfAUserWeDoNotKnow(array $in, $out): void {
$user = "john.doe@example.com";
@@ -418,7 +417,7 @@ class TestUser extends \JKingWeb\Arsse\Test\AbstractTest {
\Phake::verify(Arsse::$db)->userAdd($user, null);
}
}
-
+
public function providePropertyChanges(): iterable {
return [
[['admin' => true], ['admin' => true]],
@@ -431,7 +430,7 @@ class TestUser extends \JKingWeb\Arsse\Test\AbstractTest {
[['lang' => null], ['lang' => null]],
];
}
-
+
public function testSetThePropertiesOfAMissingUser(): void {
$user = "john.doe@example.com";
$in = ['admin' => true];
diff --git a/tests/phpunit.dist.xml b/tests/phpunit.dist.xml
index 0c6f8a76..a46fe77d 100644
--- a/tests/phpunit.dist.xml
+++ b/tests/phpunit.dist.xml
@@ -112,10 +112,14 @@
cases/REST/TestREST.php
+
+ cases/REST/Miniflux/TestErrorResponse.php
+ cases/REST/Miniflux/TestStatus.php
+
cases/REST/NextcloudNews/TestVersions.php
cases/REST/NextcloudNews/TestV1_2.php
- cases/REST/NextcloudNews/PDO/TestV1_2.php
+ cases/REST/NextcloudNews/PDO/TestV1_2.php
cases/REST/TinyTinyRSS/TestSearch.php