diff --git a/lib/REST/TinyTinyRSS/API.php b/lib/REST/TinyTinyRSS/API.php index a0136e3..0d6726a 100644 --- a/lib/REST/TinyTinyRSS/API.php +++ b/lib/REST/TinyTinyRSS/API.php @@ -10,6 +10,7 @@ use JKingWeb\Arsse\Misc\Date; use JKingWeb\Arsse\Misc\Context; use JKingWeb\Arsse\Misc\ValueInfo; use JKingWeb\Arsse\AbstractException; +use JKingWeb\Arsse\ExceptionType; use JKingWeb\Arsse\Db\ExceptionInput; use JKingWeb\Arsse\Feed\Exception as FeedException; use JKingWeb\Arsse\REST\Response; @@ -33,46 +34,47 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { const VERSION = "17.4"; const LABEL_OFFSET = 1024; const VALID_INPUT = [ - 'op' => "str", - 'sid' => "str", - 'user' => "str", - 'password' => "str", - 'include_empty' => "bool", - 'unread_only' => "bool", - 'enable_nested' => "bool", - 'caption' => "str", - 'parent_id' => "int", - 'category_id' => "int", - 'feed_url' => "str", - 'login' => "str", - 'feed_id' => "int", - 'article_id' => "int", - 'label_id' => "int", - 'article_ids' => "str", - 'assign' => "bool", - 'is_cat' => "bool", - 'cat_id' => "int", - 'limit' => "int", - 'offset' => "int", - 'include_nested' => "bool", - 'skip' => "int", - 'filter' => "str", - 'show_excerpt' => "bool", - 'show_content' => "bool", - 'view_mode' => "str", - 'include_attachments' => "bool", - 'since_id' => "int", - 'order_by' => "str", - 'sanitize' => "bool", - 'force_update' => "bool", - 'has_sandbox' => "bool", - 'include_header' => "bool", - 'search' => "str", - 'search_mode' => "str", - 'match_on' => "str", - 'mode' => "int", - 'field' => "int", - 'data' => "str", + 'op' => ValueInfo::T_STRING, + 'sid' => ValueInfo::T_STRING, + 'seq' => ValueInfo::T_INT, + 'user' => ValueInfo::T_STRING | ValueInfo::M_STRICT, + 'password' => ValueInfo::T_STRING | ValueInfo::M_STRICT, + 'include_empty' => ValueInfo::T_BOOL | ValueInfo::M_DROP, + 'unread_only' => ValueInfo::T_BOOL | ValueInfo::M_DROP, + 'enable_nested' => ValueInfo::T_BOOL | ValueInfo::M_DROP, + 'caption' => ValueInfo::T_STRING | ValueInfo::M_STRICT, + 'parent_id' => ValueInfo::T_INT, + 'category_id' => ValueInfo::T_INT, + 'feed_url' => ValueInfo::T_STRING | ValueInfo::M_STRICT, + 'login' => ValueInfo::T_STRING | ValueInfo::M_STRICT, + 'feed_id' => ValueInfo::T_INT, + 'article_id' => ValueInfo::T_INT, + 'label_id' => ValueInfo::T_INT, + 'article_ids' => ValueInfo::T_STRING, + 'assign' => ValueInfo::T_BOOL | ValueInfo::M_DROP, + 'is_cat' => ValueInfo::T_BOOL | ValueInfo::M_DROP, + 'cat_id' => ValueInfo::T_INT, + 'limit' => ValueInfo::T_INT, + 'offset' => ValueInfo::T_INT, + 'include_nested' => ValueInfo::T_BOOL | ValueInfo::M_DROP, + 'skip' => ValueInfo::T_INT, + 'filter' => ValueInfo::T_STRING, + 'show_excerpt' => ValueInfo::T_BOOL | ValueInfo::M_DROP, + 'show_content' => ValueInfo::T_BOOL | ValueInfo::M_DROP, + 'view_mode' => ValueInfo::T_STRING, + 'include_attachments' => ValueInfo::T_BOOL | ValueInfo::M_DROP, + 'since_id' => ValueInfo::T_INT, + 'order_by' => ValueInfo::T_STRING, + 'sanitize' => ValueInfo::T_BOOL | ValueInfo::M_DROP, + 'force_update' => ValueInfo::T_BOOL | ValueInfo::M_DROP, + 'has_sandbox' => ValueInfo::T_BOOL | ValueInfo::M_DROP, + 'include_header' => ValueInfo::T_BOOL | ValueInfo::M_DROP, + 'search' => ValueInfo::T_STRING, + 'search_mode' => ValueInfo::T_STRING, + 'match_on' => ValueInfo::T_STRING, + 'mode' => ValueInfo::T_INT, + 'field' => ValueInfo::T_INT, + 'data' => ValueInfo::T_STRING, ]; const FATAL_ERR = [ 'seq' => null, @@ -98,16 +100,17 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { // non-JSON input indicates an error return new Response(400, self::FATAL_ERR); } - // layer input over defaults - $data = array_merge([ - 'seq' => 0, - 'op' => "", - 'sid' => null, - ], $data); try { + // normalize input + try { + $data['seq'] = isset($data['seq']) ? $data['seq'] : 0; + $data = $this->normalizeInput($data, self::VALID_INPUT, "unix"); + } catch(ExceptionType $e) { + throw new Exception("INCORRECT_USAGE"); + } if (strtolower((string) $data['op']) != "login") { // unless logging in, a session identifier is required - $this->resumeSession($data['sid']); + $this->resumeSession((string) $data['sid']); } $method = "op".ucfirst($data['op']); if (!method_exists($this, $method)) { @@ -142,10 +145,10 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { } } - protected function resumeSession($id): bool { + protected function resumeSession(string $id): bool { try { // verify the supplied session is valid - $s = Arsse::$db->sessionResume((string) $id); + $s = Arsse::$db->sessionResume($id); } catch (\JKingWeb\Arsse\User\ExceptionSession $e) { // if not throw an exception throw new Exception("NOT_LOGGED_IN"); @@ -167,7 +170,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { } public function opLogin(array $data): array { - if (isset($data['user']) && isset($data['password']) && Arsse::$user->auth($data['user'], $data['password'])) { + if (Arsse::$user->auth((string) $data['user'], (string) $data['password'])) { $id = Arsse::$db->sessionCreate($data['user']); return [ 'session_id' => $id, @@ -281,9 +284,9 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { public function opGetCategories(array $data): array { // normalize input - $all = isset($data['include_empty']) ? ValueInfo::bool($data['include_empty'], false) : false; - $read = !(isset($data['unread_only']) ? ValueInfo::bool($data['unread_only'], false) : false); - $deep = !(isset($data['enable_nested']) ? ValueInfo::bool($data['enable_nested'], false) : false); + $all = $data['include_empty'] ?? false; + $read = !($data['unread_only'] ?? false); + $deep = !($data['enable_nested'] ?? false); $user = Arsse::$user->id; // for each category, add the ID to a lookup table, set the number of unread to zero, and assign an increasing order index $cats = Arsse::$db->folderList($user, null, $deep)->getAll(); @@ -356,12 +359,9 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { public function opAddCategory(array $data) { $in = [ - 'name' => isset($data['caption']) ? $data['caption'] : "", - 'parent' => isset($data['parent_id']) ? $data['parent_id'] : null, + 'name' => $data['caption'], + 'parent' => $data['parent_id'], ]; - if (!$in['parent']) { - $in['parent'] = null; - } try { return Arsse::$db->folderAdd(Arsse::$user->id, $in); } catch (ExceptionInput $e) { @@ -384,7 +384,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { } public function opRemoveCategory(array $data) { - if (!isset($data['category_id']) || !ValueInfo::id($data['category_id'])) { + if (!ValueInfo::id($data['category_id'])) { // if the folder is invalid, throw an error throw new Exception("INCORRECT_USAGE"); } @@ -398,7 +398,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { } public function opMoveCategory(array $data) { - if (!isset($data['category_id']) || !ValueInfo::id($data['category_id']) || !isset($data['parent_id']) || !ValueInfo::id($data['parent_id'], true)) { + if (!ValueInfo::id($data['category_id']) || !ValueInfo::id($data['parent_id'], true)) { // if the folder or parent is invalid, throw an error throw new Exception("INCORRECT_USAGE"); } @@ -415,21 +415,17 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { } public function opRenameCategory(array $data) { - if (!isset($data['category_id']) || !ValueInfo::id($data['category_id']) || !isset($data['caption'])) { - // if the folder is invalid, throw an error - throw new Exception("INCORRECT_USAGE"); - } $info = ValueInfo::str($data['caption']); - if (!($info & ValueInfo::VALID) || ($info & ValueInfo::EMPTY) || ($info & ValueInfo::WHITE)) { - // if the folder name is invalid, throw an error + if (!ValueInfo::id($data['category_id']) || !($info & ValueInfo::VALID) || ($info & ValueInfo::EMPTY) || ($info & ValueInfo::WHITE)) { + // if the folder or its new name are invalid, throw an error throw new Exception("INCORRECT_USAGE"); } $in = [ - 'name' => (string) $data['caption'], + 'name' => $data['caption'], ]; try { // try to rename the folder - Arsse::$db->folderPropertiesSet(Arsse::$user->id, (int) $data['category_id'], $in); + Arsse::$db->folderPropertiesSet(Arsse::$user->id, $data['category_id'], $in); } catch(ExceptionInput $e) { // ignore all errors } @@ -453,23 +449,14 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { } public function opSubscribeToFeed(array $data): array { - if (!isset($data['feed_url']) || !(ValueInfo::str($data['feed_url']) & ValueInfo::VALID)) { - // if the feed URL is invalid, throw an error + if (!$data['feed_url'] || !ValueInfo::id($data['category_id'], true)) { + // if the feed URL or the category ID is invalid, throw an error throw new Exception("INCORRECT_USAGE"); } - // normalize input data - if ( - (isset($data['category_id']) && !ValueInfo::id($data['category_id'], true)) || - (isset($data['login']) && !(ValueInfo::str($data['login']) & ValueInfo::VALID)) || - (isset($data['password']) && !(ValueInfo::str($data['password']) & ValueInfo::VALID)) - ) { - // if the category is not a valid ID or the feed username or password are not convertible to strings, also throw an error - throw new Exception("INCORRECT_USAGE"); - } $url = (string) $data['feed_url']; - $folder = isset($data['category_id']) ? (int) $data['category_id'] : null; - $fetchUser = isset($data['login']) ? (string) $data['login'] : ""; - $fetchPassword = isset($data['password']) ? (string) $data['password'] : ""; + $folder = (int) $data['category_id']; + $fetchUser = (string) $data['login']; + $fetchPassword = (string) $data['password']; // check to make sure the requested folder exists before doing anything else, if one is specified if ($folder) { try { @@ -519,10 +506,6 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { } public function opUnsubscribeFeed(array $data): array { - if (!isset($data['feed_id']) || !ValueInfo::id($data['feed_id'])) { - // if the feed is invalid, throw an error - throw new Exception("FEED_NOT_FOUND"); - } try { // attempt to remove the feed Arsse::$db->subscriptionRemove(Arsse::$user->id, (int) $data['feed_id']); @@ -533,16 +516,16 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { } public function opMoveFeed(array $data) { - if (!isset($data['feed_id']) || !ValueInfo::id($data['feed_id']) || !isset($data['category_id']) || !ValueInfo::id($data['category_id'], true)) { + if (!ValueInfo::id($data['feed_id']) || !isset($data['category_id']) || !ValueInfo::id($data['category_id'], true)) { // if the feed or folder is invalid, throw an error throw new Exception("INCORRECT_USAGE"); } $in = [ - 'folder' => (int) $data['category_id'], + 'folder' => $data['category_id'], ]; try { // try to move the feed - Arsse::$db->subscriptionPropertiesSet(Arsse::$user->id, (int) $data['feed_id'], $in); + Arsse::$db->subscriptionPropertiesSet(Arsse::$user->id, $data['feed_id'], $in); } catch(ExceptionInput $e) { // ignore all errors } @@ -550,21 +533,17 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { } public function opRenameFeed(array $data) { - if (!isset($data['feed_id']) || !ValueInfo::id($data['feed_id']) || !isset($data['caption'])) { - // if the feed is invalid or there is no caption, throw an error - throw new Exception("INCORRECT_USAGE"); - } $info = ValueInfo::str($data['caption']); - if (!($info & ValueInfo::VALID) || ($info & ValueInfo::EMPTY) || ($info & ValueInfo::WHITE)) { - // if the feed name is invalid, throw an error + if (!ValueInfo::id($data['feed_id']) || !($info & ValueInfo::VALID) || ($info & ValueInfo::EMPTY) || ($info & ValueInfo::WHITE)) { + // if the feed ID or name is invalid, throw an error throw new Exception("INCORRECT_USAGE"); } $in = [ - 'name' => (string) $data['caption'], + 'name' => $data['caption'], ]; try { // try to rename the feed - Arsse::$db->subscriptionPropertiesSet(Arsse::$user->id, (int) $data['feed_id'], $in); + Arsse::$db->subscriptionPropertiesSet(Arsse::$user->id, $data['feed_id'], $in); } catch(ExceptionInput $e) { // ignore all errors } @@ -577,7 +556,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { throw new Exception("INCORRECT_USAGE"); } try { - Arsse::$db->feedUpdate(Arsse::$db->subscriptionPropertiesGet(Arsse::$user->id, (int) $data['feed_id'])['feed']); + Arsse::$db->feedUpdate(Arsse::$db->subscriptionPropertiesGet(Arsse::$user->id, $data['feed_id'])['feed']); } catch(ExceptionInput $e) { throw new Exception("FEED_NOT_FOUND"); } @@ -597,7 +576,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { public function opGetLabels(array $data): array { // this function doesn't complain about invalid article IDs - $article = (isset($data['article_id']) && ValueInfo::id($data['article_id'])) ? (int) $data['article_id'] : 0; + $article = ValueInfo::id($data['article_id']) ? $data['article_id'] : 0; try { $list = $article ? Arsse::$db->articleLabelsGet(Arsse::$user->id, $article) : []; } catch (ExceptionInput $e) { @@ -618,7 +597,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { public function opAddLabel(array $data) { $in = [ - 'name' => isset($data['caption']) ? $data['caption'] : "", + 'name' => (string) $data['caption'], ]; try { return $this->labelOut(Arsse::$db->labelAdd(Arsse::$user->id, $in)); @@ -635,7 +614,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { public function opRemoveLabel(array $data) { // normalize the label ID; missing or invalid IDs are rejected - $id = $this->labelIn(isset($data['label_id']) ? $data['label_id'] : 0); + $id = $this->labelIn($data['label_id']); try { // attempt to remove the label Arsse::$db->labelRemove(Arsse::$user->id, $id); @@ -647,8 +626,8 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { public function opRenameLabel(array $data) { // normalize input; missing or invalid IDs are rejected - $id = $this->labelIn(isset($data['label_id']) ? $data['label_id'] : 0); - $name = isset($data['caption']) ? $data['caption'] : ""; + $id = $this->labelIn($data['label_id']); + $name = (string) $data['caption']; try { // try to rename the folder Arsse::$db->labelPropertiesSet(Arsse::$user->id, $id, ['name' => $name]); @@ -662,12 +641,11 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { } public function opSetArticleLabel(array $data): array { - if (!isset($data['article_ids']) || !isset($data['label_id'])) { + if (!$data['article_ids'] || !$data['label_id']) { throw new Exception("INCORRECT_USAGE"); } $label = $this->labelIn($data['label_id']); - $articles = explode(",", (string) $data['article_ids']); - $assign = ValueInfo::bool(isset($data['assign']) ? $data['assign'] : null, false); - + $articles = explode(",", $data['article_ids']); + $assign = $data['assign'] ?? false; } } diff --git a/tests/REST/TinyTinyRSS/TestTinyTinyAPI.php b/tests/REST/TinyTinyRSS/TestTinyTinyAPI.php index 81e6df4..8b83713 100644 --- a/tests/REST/TinyTinyRSS/TestTinyTinyAPI.php +++ b/tests/REST/TinyTinyRSS/TestTinyTinyAPI.php @@ -257,11 +257,21 @@ class TestTinyTinyAPI extends Test\AbstractTest { [Arsse::$user->id, 2112, ['parent' => 2]], [Arsse::$user->id, 42, ['parent' => 0]], [Arsse::$user->id, 42, ['parent' => 47]], + [Arsse::$user->id, -1, ['parent' => 1]], + [Arsse::$user->id, 42, ['parent' => -1]], + [Arsse::$user->id, 42, ['parent' => 0]], + [Arsse::$user->id, 0, ['parent' => -1]], + [Arsse::$user->id, 0, ['parent' => 0]], ]; Phake::when(Arsse::$db)->folderPropertiesSet(...$db[0])->thenReturn(true); Phake::when(Arsse::$db)->folderPropertiesSet(...$db[1])->thenThrow(new ExceptionInput("subjectMissing")); Phake::when(Arsse::$db)->folderPropertiesSet(...$db[2])->thenThrow(new ExceptionInput("constraintViolation")); Phake::when(Arsse::$db)->folderPropertiesSet(...$db[3])->thenThrow(new ExceptionInput("idMissing")); + Phake::when(Arsse::$db)->folderPropertiesSet(...$db[4])->thenThrow(new ExceptionInput("typeViolation")); + Phake::when(Arsse::$db)->folderPropertiesSet(...$db[5])->thenThrow(new ExceptionInput("typeViolation")); + Phake::when(Arsse::$db)->folderPropertiesSet(...$db[6])->thenThrow(new ExceptionInput("constraintViolation")); + Phake::when(Arsse::$db)->folderPropertiesSet(...$db[7])->thenThrow(new ExceptionInput("typeViolation")); + Phake::when(Arsse::$db)->folderPropertiesSet(...$db[8])->thenThrow(new ExceptionInput("typeViolation")); // succefully move a folder $exp = $this->respGood(); $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[0])))); @@ -272,14 +282,14 @@ class TestTinyTinyAPI extends Test\AbstractTest { $exp = $this->respGood(); $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[2])))); $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[3])))); + $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[6])))); // all the rest should cause errors $exp = $this->respErr("INCORRECT_USAGE"); $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[4])))); $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[5])))); - $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[6])))); $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[7])))); $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[8])))); - Phake::verify(Arsse::$db, Phake::times(4))->folderPropertiesSet(Arsse::$user->id, $this->anything(), $this->anything()); + Phake::verify(Arsse::$db, Phake::times(5))->folderPropertiesSet(Arsse::$user->id, $this->anything(), $this->anything()); } public function testRenameACategory() { @@ -404,7 +414,8 @@ class TestTinyTinyAPI extends Test\AbstractTest { ['op' => "unsubscribeFeed", 'sid' => "PriestsOfSyrinx", 'feed_id' => -1], ['op' => "unsubscribeFeed", 'sid' => "PriestsOfSyrinx"], ]; - Phake::when(Arsse::$db)->subscriptionRemove(Arsse::$user->id, $this->anything())->thenThrow(new ExceptionInput("subjectMissing")); + Phake::when(Arsse::$db)->subscriptionRemove(Arsse::$user->id, $this->anything())->thenThrow(new ExceptionInput("typeViolation")); + Phake::when(Arsse::$db)->subscriptionRemove(Arsse::$user->id, 2112)->thenThrow(new ExceptionInput("subjectMissing")); Phake::when(Arsse::$db)->subscriptionRemove(Arsse::$user->id, 42)->thenReturn(true)->thenThrow(new ExceptionInput("subjectMissing")); // succefully delete a folder $exp = $this->respGood(['status' => "OK"]); @@ -415,7 +426,7 @@ class TestTinyTinyAPI extends Test\AbstractTest { $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[1])))); $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[2])))); $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[3])))); - Phake::verify(Arsse::$db, Phake::times(3))->subscriptionRemove(Arsse::$user->id, $this->anything()); + Phake::verify(Arsse::$db, Phake::times(5))->subscriptionRemove(Arsse::$user->id, $this->anything()); } public function testMoveASubscription() {