diff --git a/lib/REST/Miniflux/V1.php b/lib/REST/Miniflux/V1.php index bf3da2e..4822adf 100644 --- a/lib/REST/Miniflux/V1.php +++ b/lib/REST/Miniflux/V1.php @@ -16,7 +16,8 @@ use JKingWeb\Arsse\Misc\HTTP; use JKingWeb\Arsse\Misc\Date; use JKingWeb\Arsse\Misc\ValueInfo as V; use JKingWeb\Arsse\REST\Exception; -use JKingWeb\Arsse\User\ExceptionConflict as UserException; +use JKingWeb\Arsse\User\ExceptionConflict; +use JKingWeb\Arsse\User\Exception as UserException; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\ResponseInterface; use Laminas\Diactoros\Response\EmptyResponse; @@ -29,12 +30,26 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler { protected const ACCEPTED_TYPES_JSON = ["application/json"]; protected const TOKEN_LENGTH = 32; protected const VALID_JSON = [ + // user properties which map directly to Arsse user metadata are listed separately 'url' => "string", 'username' => "string", 'password' => "string", 'user_agent' => "string", 'title' => "string", ]; + protected const USER_META_MAP = [ + // Miniflux ID // Arsse ID Default value Extra + 'is_admin' => ["admin", false, false], + 'theme' => ["theme", "light_serif", false], + 'language' => ["lang", "en_US", false], + 'timezone' => ["tz", "UTC", false], + 'entry_sorting_direction' => ["sort_asc", false, false], + 'entries_per_page' => ["page_size", 100, false], + 'keyboard_shortcuts' => ["shortcuts", true, false], + 'show_reading_time' => ["reading_time", true, false], + 'entry_swipe' => ["swipe", true, false], + 'custom_css' => ["stylesheet", "", true], + ]; protected const CALLS = [ // handler method Admin Path Body Query '/categories' => [ 'GET' => ["getCategories", false, false, false, false], @@ -102,7 +117,7 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler { ], '/users/1' => [ 'GET' => ["getUserByNum", true, true, false, false], - 'PUT' => ["updateUserByNum", true, true, true, false], + 'PUT' => ["updateUserByNum", false, true, true, false], // requires admin for users other than self 'DELETE' => ["deleteUserByNum", true, true, false, false], ], '/users/1/mark-all-as-read' => [ @@ -246,7 +261,17 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler { 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 new ErrorResponse(["InvalidInputType", 'field' => $k, 'expected' => $t, 'actual' => gettype($body[$k])], 422); + } + } + foreach (self::USER_META_MAP as $k => [,$d,]) { + $t = gettype($d); + if (!isset($body[$k])) { + $body[$k] = null; + } elseif (gettype($body[$k]) !== $t) { + return new ErrorResponse(["InvalidInputType", 'field' => $k, 'expected' => $t, 'actual' => gettype($body[$k])], 422); + } elseif ($k === "entry_sorting_direction" && !in_array($body[$k], ["asc", "desc"])) { + return new ErrorResponse(["InvalidInputValue", 'field' => $k], 422); } } return $body; @@ -285,23 +310,23 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler { continue; } } - $out[] = [ + $entry = [ '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'] ?? "", - ], ]; + foreach (self::USER_META_MAP as $ext => [$int, $default, $extra]) { + if (!$extra) { + $entry[$ext] = $info[$int] ?? $default; + } else { + if (!isset($entry['extra'])) { + $entry['extra'] = []; + } + $entry['extra'][$ext] = $info[$int] ?? $default; + } + } + $entry['entry_sorting_direction'] = ($entry['entry_sorting_direction']) ? "asc" : "desc"; + $out[] = $entry; } return $out; } @@ -326,6 +351,7 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler { } protected function getUsers(): ResponseInterface { + $tr = Arsse::$user->begin(); return new Response($this->listUsers(Arsse::$user->list(), false)); } @@ -350,6 +376,70 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler { return new Response($this->listUsers([Arsse::$user->id], false)[0] ?? new \stdClass); } + protected function updateUserByNum(array $data, array $path): ResponseInterface { + try { + if (!$this->isAdmin()) { + // this function is restricted to admins unless the affected user and calling user are the same + if (Arsse::$db->userLookup((int) $path[1]) !== Arsse::$user->id) { + return new ErrorResponse("403", 403); + } elseif ($data['is_admin']) { + // non-admins should not be able to set themselves as admin + return new ErrorResponse("InvalidElevation"); + } + $user = Arsse::$user->id; + } else { + $user = Arsse::$db->userLookup((int) $path[1]); + } + } catch (ExceptionConflict $e) { + return new ErrorResponse("404", 404); + } + // map Miniflux properties to internal metadata properties + $in = []; + foreach (self::USER_META_MAP as $i => [$o,,]) { + if (isset($data[$i])) { + if ($i === "entry_sorting_direction") { + $in[$o] = $data[$i] === "asc"; + } else { + $in[$o] = $data[$i]; + } + } + } + // 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']); + } + 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(); + } catch (UserException $e) { + switch ($e->getCode()) { + case 10403: + return new ErrorResponse(["DuplicateUser", 'user' => $data['username']], 409); + case 20441: + return new ErrorResponse(["InvalidTimeone", 'tz' => $data['timezone']], 422); + case 10443: + return new ErrorResponse("InvalidPageSize", 422); + case 10444: + return new ErrorResponse(["InvalidUsername", $e->getMessage()], 422); + } + throw $e; // @codeCoverageIgnore + } + // add the input password if a password change was requested + if (isset($data['password'])) { + $out['password'] = $data['password']; + } + return new Response($out); + } + protected function getCategories(): ResponseInterface { $out = []; $meta = Arsse::$user->propertiesGet(Arsse::$user->id, false); @@ -374,7 +464,7 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler { } } $meta = Arsse::$user->propertiesGet(Arsse::$user->id, false); - return new Response(['id' => $id + 1, 'title' => $data['title'], 'user_id' => $meta['num']]); + return new Response(['id' => $id + 1, 'title' => $data['title'], 'user_id' => $meta['num']], 201); } protected function updateCategory(array $path, array $data): ResponseInterface { @@ -449,7 +539,7 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler { public static function tokenList(string $user): array { if (!Arsse::$db->userExists($user)) { - throw new UserException("doesNotExist", ["action" => __FUNCTION__, "user" => $user]); + throw new ExceptionConflict("doesNotExist", ["action" => __FUNCTION__, "user" => $user]); } $out = []; foreach (Arsse::$db->tokenList($user, "miniflux.login") as $r) { diff --git a/locale/en.php b/locale/en.php index acdaa60..b0cfe82 100644 --- a/locale/en.php +++ b/locale/en.php @@ -13,12 +13,18 @@ return [ 'API.Miniflux.Error.404' => 'Resource Not Found', '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.InvalidInputValue' => 'Supplied value is not valid for input key "{field}"', '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.Miniflux.Error.DuplicateCategory' => 'Category "{title}" already exists', 'API.Miniflux.Error.InvalidCategory' => 'Invalid category title "{title}"', + 'API.Miniflux.Error.InvalidElevation' => 'Only administrators can change permissions of standard users', + 'API.Miniflux.Error.DuplicateUser' => 'The user name "{user}" already exists', + 'API.Miniflux.Error.InvalidUser' => '{0}', + 'API.Miniflux.Error.InvalidTimezone' => 'Specified time zone "{tz}" is invalid', + 'API.Miniflux.Error.InvalidPageSize' => 'Page size must be greater than zero', '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 918a3da..0b9f68d 100644 --- a/tests/cases/REST/Miniflux/TestV1.php +++ b/tests/cases/REST/Miniflux/TestV1.php @@ -16,6 +16,7 @@ use JKingWeb\Arsse\Misc\Date; use JKingWeb\Arsse\REST\Miniflux\V1; use JKingWeb\Arsse\REST\Miniflux\ErrorResponse; use JKingWeb\Arsse\User\ExceptionConflict; +use JKingWeb\Arsse\User\Exception; use Psr\Http\Message\ResponseInterface; use Laminas\Diactoros\Response\JsonResponse as Response; use Laminas\Diactoros\Response\EmptyResponse; @@ -32,6 +33,7 @@ class TestV1 extends \JKingWeb\Arsse\Test\AbstractTest { [ 'id' => 1, 'username' => "john.doe@example.com", + 'last_login_at' => self::NOW, 'is_admin' => true, 'theme' => "custom", 'language' => "fr_CA", @@ -40,7 +42,6 @@ class TestV1 extends \JKingWeb\Arsse\Test\AbstractTest { 'entries_per_page' => 200, 'keyboard_shortcuts' => false, 'show_reading_time' => false, - 'last_login_at' => self::NOW, 'entry_swipe' => false, 'extra' => [ 'custom_css' => "p {}", @@ -49,6 +50,7 @@ class TestV1 extends \JKingWeb\Arsse\Test\AbstractTest { [ 'id' => 2, 'username' => "jane.doe@example.com", + 'last_login_at' => self::NOW, 'is_admin' => false, 'theme' => "light_serif", 'language' => "en_US", @@ -57,7 +59,6 @@ class TestV1 extends \JKingWeb\Arsse\Test\AbstractTest { 'entries_per_page' => 100, 'keyboard_shortcuts' => true, 'show_reading_time' => true, - 'last_login_at' => self::NOW, 'entry_swipe' => true, 'extra' => [ 'custom_css' => "", @@ -166,7 +167,7 @@ class TestV1 extends \JKingWeb\Arsse\Test\AbstractTest { } public function testRejectBadlyTypedData(): void { - $exp = new ErrorResponse(["InvalidInputType", 'field' => "url", 'expected' => "string", 'actual' => "integer"], 400); + $exp = new ErrorResponse(["InvalidInputType", 'field' => "url", 'expected' => "string", 'actual' => "integer"], 422); $this->assertMessage($exp, $this->req("POST", "/discover", ['url' => 2112])); } @@ -277,11 +278,11 @@ class TestV1 extends \JKingWeb\Arsse\Test\AbstractTest { public function provideCategoryAdditions(): iterable { return [ - ["New", new Response(['id' => 2112, 'title' => "New", 'user_id' => 42])], + ["New", new Response(['id' => 2112, 'title' => "New", 'user_id' => 42], 201)], ["Duplicate", new ErrorResponse(["DuplicateCategory", 'title' => "Duplicate"], 500)], ["", new ErrorResponse(["InvalidCategory", 'title' => ""], 500)], [" ", new ErrorResponse(["InvalidCategory", 'title' => " "], 500)], - [false, new ErrorResponse(["InvalidInputType", 'field' => "title", 'actual' => "boolean", 'expected' => "string"], 400)], + [false, new ErrorResponse(["InvalidInputType", 'field' => "title", 'actual' => "boolean", 'expected' => "string"],422)], ]; } @@ -307,12 +308,12 @@ class TestV1 extends \JKingWeb\Arsse\Test\AbstractTest { [2, "Duplicate", "constraintViolation", new ErrorResponse(["DuplicateCategory", 'title' => "Duplicate"], 500)], [2, "", "missing", new ErrorResponse(["InvalidCategory", 'title' => ""], 500)], [2, " ", "whitespace", new ErrorResponse(["InvalidCategory", 'title' => " "], 500)], - [2, false, "subjectMissing", new ErrorResponse(["InvalidInputType", 'field' => "title", 'actual' => "boolean", 'expected' => "string"], 400)], + [2, false, "subjectMissing", new ErrorResponse(["InvalidInputType", 'field' => "title", 'actual' => "boolean", 'expected' => "string"],422)], [1, "New", true, new Response(['id' => 1, 'title' => "New", 'user_id' => 42])], [1, "Duplicate", "constraintViolation", new Response(['id' => 1, 'title' => "Duplicate", 'user_id' => 42])], // This is allowed because the name of the root folder is only a duplicate in circumstances where it is used [1, "", "missing", new ErrorResponse(["InvalidCategory", 'title' => ""], 500)], [1, " ", "whitespace", new ErrorResponse(["InvalidCategory", 'title' => " "], 500)], - [1, false, false, new ErrorResponse(["InvalidInputType", 'field' => "title", 'actual' => "boolean", 'expected' => "string"], 400)], + [1, false, false, new ErrorResponse(["InvalidInputType", 'field' => "title", 'actual' => "boolean", 'expected' => "string"], 422)], ]; }