diff --git a/lib/Database.php b/lib/Database.php index 47798db5..17e8ad71 100644 --- a/lib/Database.php +++ b/lib/Database.php @@ -26,7 +26,7 @@ class Database { return debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 3)[2]['function']; } - function dateFormatDefault(string $set = null): string { + public function dateFormatDefault(string $set = null): string { if(is_null($set)) return $this->dateFormatDefault; $set = strtolower($set); if(in_array($set, ["sql", "iso8601", "unix", "http"])) { @@ -101,6 +101,10 @@ class Database { } } + public function begin(): Db\Transaction { + return $this->db->begin(); + } + public function settingSet(string $key, $in, string $type = null): bool { if(!$type) { switch(gettype($in)) { @@ -513,6 +517,43 @@ class Database { return $out; } + protected function subscriptionValidateId(string $user, int $id): array { + $out = $this->db->prepare("SELECT feed from arsse_subscriptions where id is ? and owner is ?", "int", "str")->run($id, $user)->getRow(); + if(!$out) throw new Db\ExceptionInput("idMissing", ["action" => $this->caller(), "field" => "subscription", 'id' => $id]); + return $out; + } + + public function articleStarredCount(string $user, array $context = []): int { + if(!Data::$user->authorize($user, __FUNCTION__)) throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); + return $this->db->prepare("SELECT count(*) from arsse_marks where owner is ? and starred is 1", "str")->run($user)->getValue(); + } + + public function editionLatest(string $user, array $context = []): int { + if(!Data::$user->authorize($user, __FUNCTION__)) throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); + if(array_key_exists("subscription", $context)) { + $id = $context['subscription']; + $sub = $this->subscriptionValidateId($user, $id); + return (int) $this->db->prepare( + "SELECT max(arsse_editions.id) + from arsse_editions + left join arsse_articles on article is arsse_articles.id + left join arsse_feeds on arsse_articles.feed is arsse_feeds.id + where arsse_feeds.id is ?", + "int" + )->run($sub['feed'])->getValue(); + } + return (int) $this->db->prepare("SELECT max(id) from arsse_editions")->run()->getValue(); + } + + public function feedListStale(): array { + $feeds = $this->db->prepare("SELECT distinct feed from arsse_subscriptions left join arsse_feeds on arsse_feeds.id = feed where next_fetch <= CURRENT_TIMESTAMP")->run(); + $out = []; + foreach($feeds as $feed) { + $out[] = $feed['feed']; + } + return $out; + } + public function feedUpdate(int $feedID, bool $throwError = false): bool { $tr = $this->db->begin(); // check to make sure the feed exists diff --git a/lib/Db/AbstractStatement.php b/lib/Db/AbstractStatement.php index 54ec1cf9..48633503 100644 --- a/lib/Db/AbstractStatement.php +++ b/lib/Db/AbstractStatement.php @@ -79,11 +79,7 @@ abstract class AbstractStatement implements Statement { } protected function formatDate($date, int $part = self::TS_BOTH) { - // Force UTC. - $timezone = date_default_timezone_get(); - date_default_timezone_set('UTC'); // convert input to a Unix timestamp - // FIXME: there are more kinds of date representations if($date instanceof \DateTimeInterface) { $time = $date->getTimestamp(); } else if(is_numeric($date)) { @@ -99,8 +95,6 @@ abstract class AbstractStatement implements Statement { $time = (int) $date; } // ISO 8601 with space in the middle instead of T. - $date = date($this->dateFormat($part), $time); - date_default_timezone_set($timezone); - return $date; + return date($this->dateFormat($part), $time); } } \ No newline at end of file diff --git a/lib/Db/SQLite3/Statement.php b/lib/Db/SQLite3/Statement.php index ba868b46..44242786 100644 --- a/lib/Db/SQLite3/Statement.php +++ b/lib/Db/SQLite3/Statement.php @@ -39,9 +39,9 @@ class Statement extends \JKingWeb\Arsse\Db\AbstractStatement { public static function dateFormat(int $part = self::TS_BOTH): string { return ([ - self::TS_TIME => 'h:i:s', + self::TS_TIME => 'H:i:s', self::TS_DATE => 'Y-m-d', - self::TS_BOTH => 'Y-m-d h:i:s', + self::TS_BOTH => 'Y-m-d H:i:s', ])[$part]; } diff --git a/lib/REST/AbstractHandler.php b/lib/REST/AbstractHandler.php index 7a935210..8f119981 100644 --- a/lib/REST/AbstractHandler.php +++ b/lib/REST/AbstractHandler.php @@ -5,4 +5,32 @@ namespace JKingWeb\Arsse\REST; abstract class AbstractHandler implements Handler { abstract function __construct(); abstract function dispatch(Request $req): Response; + + protected function mapFieldNames(array $data, array $map, bool $overwrite = false): array { + foreach($map as $from => $to) { + if(array_key_exists($from, $data)) { + if($overwrite || !array_key_exists($to, $data)) $data[$to] = $data[$from]; + unset($data[$from]); + } + } + return $data; + } + + protected function mapFieldTypes(array $data, array $map): array { + foreach($map as $key => $type) { + if(array_key_exists($key, $data)) settype($data[$key], $type); + } + return $data; + } + + protected function validateId($id):bool { + try { + $ch1 = strval(intval($id)); + $ch2 = strval($id); + } catch(\Throwable $e) { + return false; + } + return ($ch1 === $ch2); + } + } \ No newline at end of file diff --git a/lib/REST/NextCloudNews/V1_2.php b/lib/REST/NextCloudNews/V1_2.php index 19d17ff4..8f459f5a 100644 --- a/lib/REST/NextCloudNews/V1_2.php +++ b/lib/REST/NextCloudNews/V1_2.php @@ -2,8 +2,10 @@ declare(strict_types=1); namespace JKingWeb\Arsse\REST\NextCloudNews; use JKingWeb\Arsse\Data; +use JKingWeb\Arsse\User; use JKingWeb\Arsse\AbstractException; use JKingWeb\Arsse\Db\ExceptionInput; +use JKingWeb\Arsse\Feed\Exception as FeedException; use JKingWeb\Arsse\REST\Response; class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler { @@ -38,11 +40,14 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler { unset($url[1]); $url = array_filter($url); $url = array_values($url); + // decode any % sequences in the URL + $url = array_map(function($v){return rawurldecode($v);}, $url); // check to make sure the requested function is implemented $func = $scope.$req->method; if(!method_exists($this, $func)) return new Response(501); // dispatch try { + Data::$db->dateFormatDefault("unix"); return $this->$func($url, $data); } catch(Exception $e) { // if there was a REST exception return 400 @@ -55,7 +60,7 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler { return new Response(404); } } - + // list folders protected function foldersGET(array $url, array $data): Response { // if URL is more than '/folders' this is an error @@ -93,7 +98,7 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler { if(sizeof($url) < 1) return new Response(405, "", "", ['Allow: GET, POST']); if(sizeof($url) > 1) return new Response(404); // folder ID must be integer - if(strval(intval($url[0])) !== $url[0]) return new Response(404); + if(!$this->validateId($url[0])) return new Response(404); // perform the deletion try { Data::$db->folderRemove(Data::$user->id, (int) $url[0]); @@ -110,7 +115,7 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler { if(sizeof($url) < 1) return new Response(405, "", "", ['Allow: GET, POST']); if(sizeof($url) > 1) return new Response(404); // folder ID must be integer - if(strval(intval($url[0])) !== $url[0]) return new Response(404); + if(!$this->validateId($url[0])) return new Response(404); // there must be some change to be made if(!sizeof($data)) return new Response(422); // perform the edit @@ -160,4 +165,178 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler { return new Response(405, "", "", ['Allow: GET']); } + protected function feedTranslate(array $feed, bool $overwrite = false): array { + // cast values + $feed = $this->mapFieldTypes($feed, [ + 'folder' => "int", + 'pinned' => "bool", + ]); + // map fields to proper names + $feed = $this->mapFieldNames($feed, [ + 'source' => "link", + 'favicon' => "faviconLink", + 'folder' => "folderId", + 'unread' => "unreadCount", + 'order_type' => "ordering", + 'err_count' => "updateErrorCount", + 'err_msg' => "lastUpdateError", + ], $overwrite); + return $feed; + } + + // return list of feeds for the logged-in user + // return list of feeds which should be refreshed + // refresh a feed + protected function feedsGET(array $url, array $data): Response { + // URL may be /feeds/[all|update] only + $args = sizeof($url); + if($args==2 && in_array($url[1], ["rename","move","read"])) return new Response(405, "", "", ['Allow: PUT, DELETE']); + if($args > 1) return new Response(404); + if($args==1 && !in_array($url[0], ["all","update"])) return new Response(405, "", "", ['Allow: PUT, DELETE']); + // valid action are listing owned subscriptions or (for admins) listing stale feeds or updating a feed + if($args==1) { + // listing stale feeds for updating and updating itself require admin rights per spec + if(Data::$user->rightsGet(Data::$user->id)==User::RIGHTS_NONE) return new Response(403); + if($url[0]=="all") { + // list stale feeds which should be checked for updates + $feeds = Data::$db->feedListStale(); + $out = []; + foreach($feeds as $feed) { + // since in our implementation feeds don't belong the users, the 'userId' field will always be an empty string + $out[] = ['id' => $feed, 'userId' => ""]; + } + return new Response(200, ['feeds' => $out]); + } elseif($url[0]=="update") { + // perform an update of a single feed + if(!array_key_exists("feedId", $data) || $data['feedId'] < 1) return new Response(422); + try { + Data::$db->feedUpdate($data['feedId']); + } catch(ExceptionInput $e) { + return new Response(404); + } + return new Response(200); + } + } else { + // list subscriptions for the logged-in user + $subs = Data::$db->subscriptionList(Data::$user->id); + $out = []; + foreach($subs as $sub) { + $sub = $this->feedTranslate($sub); + $out[] = $sub; + } + $out = ['feeds' => $out]; + $out['starredCount'] = Data::$db->articleStarredCount(Data::$user->id); + $newest = Data::$db->editionLatest(Data::$user->id, ['subscription' => $id]); + if($newest) $out['newestItemId'] = $newest; + return new Response(200, $out); + } + } + + // add a new feed + protected function feedsPOST(array $url, array $data): Response { + // if URL is more than '/feeds' this is an error + $args = sizeof($url); + if($args==1 && in_array($url[0], ["all","update"])) return new Response(405, "", "", ['Allow: GET']); + if($args==1) return new Response(405, "", "", ['Allow: PUT, DELETE']); + if($args==2 && in_array($url[1], ["rename","move","read"])) return new Response(405, "", "", ['Allow: PUT']); + if($args) return new Response(404); + // normalize the URL + if(!array_key_exists("url", $data)) { + $url = ""; + } else { + $url = $data['url']; + } + // normalize the folder ID, if specified + if(!array_key_exists("folderId", $data)) { + $folder = null; + } else { + $folder = $data['folderId']; + $folder = $folder ? null : $folder; + } + // try to add the feed + $tr = Data::$db->begin(); + try { + $id = Data::$db->subscriptionAdd(Data::$user->id, $url); + } catch(ExceptionInput $e) { + // feed already exists + return new Response(409); + } catch(FeedException $e) { + // feed could not be retrieved + return new Response(422); + } + // if a folder was specified, move the feed to the correct folder; silently ignore errors + if($folder) { + try { + Data::$db->subscriptionPropertiesSet(Data::$user->id, $id, ['folder' => $folder]); + } catch(ExceptionInput $e) {} + } + $tr->commit(); + // fetch the feed's metadata and format it appropriately + $feed = Data::$db->subscriptionPropertiesGet(Data::$user->id, $id); + $feed = $this->feedTranslate($feed); + $out = ['feeds' => [$feed]]; + $newest = Data::$db->editionLatest(Data::$user->id, ['subscription' => $id]); + if($newest) $out['newestItemId'] = $newest; + return new Response(200, $out); + } + + // delete a feed + protected function feedsDELETE(array $url, array $data): Response { + // if URL is more or less than '/feeds/$id' this is an error + if(sizeof($url) < 1) return new Response(405, "", "", ['Allow: GET, POST']); + if(sizeof($url) > 1) return new Response(404); + // folder ID must be integer + if(!$this->validateId($url[0])) return new Response(404); + // perform the deletion + try { + Data::$db->subscriptionRemove(Data::$user->id, (int) $url[0]); + } catch(ExceptionInput $e) { + // feed does not exist + return new Response(404); + } + return new Response(204); + } + + // rename a feed + // move a feed to a folder + // mark items from a feed as read + protected function feedsPUT(array $url, array $data): Response { + // URL may be /feeds//[rename|move|read] + $args = sizeof($url); + if(!$args) return new Response(405, "", "", ['Allow: GET, POST']); + if($args > 2) return new Response(404); + if(in_array($url[0], ["all", "update"])) { + if($args==1) return new Response(405, "", "", ['Allow: GET']); + return new Response(404); + } + if($args==2 && !in_array($url[1], ["rename","move","read"])) return new Response(404); + // if the feed ID is not an integer, this is also an error + if(!$this->validateId($url[0])) return new Response(404); + // normalize input for move and rename + $in = []; + if(array_key_exists("feedTitle", $data)) { + $in['title'] = $data['feedTitle']; + } + if(array_key_exists("folderId", $data)) { + $folder = $data['folderId']; + if(!$this->validateId($folder)) return new Response(422); + if(!$folder) $folder = null; + $in['folder'] = $folder; + } + // perform the move and/or rename + if($in) { + try { + Data::$db->subscriptionPropertiesSet(Data::$user->id, (int) $url[0], $in); + } catch(ExceptionInput $e) { + return new Response(404); + } + } + // mark items as read, if requested + if(array_key_exists("newestItemId", $data)) { + $newest = $data['newestItemId']; + if(!$this->validateId($newest)) return new Response(422); + // FIXME: do the marking as read + } + return new Response(204); + } } \ No newline at end of file diff --git a/lib/User.php b/lib/User.php index 0061086a..26cb7639 100644 --- a/lib/User.php +++ b/lib/User.php @@ -3,6 +3,12 @@ declare(strict_types=1); namespace JKingWeb\Arsse; class User { + const RIGHTS_NONE = 0; // normal user + const RIGHTS_DOMAIN_MANAGER = 25; // able to act for any normal users on same domain; cannot elevate other users + const RIGHTS_DOMAIN_ADMIN = 50; // able to act for any users on same domain not above themselves; may elevate users on same domain to domain manager or domain admin + const RIGHTS_GLOBAL_MANAGER = 75; // able to act for any normal users on any domain; cannot elevate other users + const RIGHTS_GLOBAL_ADMIN = 100; // is completely unrestricted + public $id = null; protected $u; diff --git a/locale/en.php b/locale/en.php index 68aa72f5..97d8c781 100644 --- a/locale/en.php +++ b/locale/en.php @@ -7,9 +7,13 @@ return [ 'HTTP.Status.200' => 'OK', 'HTTP.Status.204' => 'No Content', 'HTTP.Status.401' => 'Unauthorized', + 'HTTP.Status.403' => 'Forbidden', 'HTTP.Status.404' => 'Not Found', 'HTTP.Status.405' => 'Method Not Allowed', + 'HTTP.Status.409' => 'Conflict', 'HTTP.Status.415' => 'Unsupported Media Type', + 'HTTP.Status.422' => 'Unprocessable Entity', + 'HTTP.Status.501' => 'Not Implemented', // this should only be encountered in testing (because tests should cover all exceptions!) 'Exception.JKingWeb/Arsse/Exception.uncoded' => 'The specified exception symbol {0} has no code specified in AbstractException.php', diff --git a/sql/SQLite3/0.sql b/sql/SQLite3/0.sql index f7e156f0..4b2ea65d 100644 --- a/sql/SQLite3/0.sql +++ b/sql/SQLite3/0.sql @@ -98,7 +98,8 @@ create table arsse_marks( -- IDs for specific editions of articles (required for at least NextCloud News) create table arsse_editions( id integer primary key, - article integer not null references arsse_articles(id) on delete cascade + article integer not null references arsse_articles(id) on delete cascade, + modified datetime not null default CURRENT_TIMESTAMP ); -- user labels associated with newsfeed entries diff --git a/tests/Db/SQLite3/TestDbStatementSQLite3.php b/tests/Db/SQLite3/TestDbStatementSQLite3.php index f347c01f..f6accc9d 100644 --- a/tests/Db/SQLite3/TestDbStatementSQLite3.php +++ b/tests/Db/SQLite3/TestDbStatementSQLite3.php @@ -11,7 +11,6 @@ class TestDbStatementSQLite3 extends \PHPUnit\Framework\TestCase { static protected $imp = Db\SQLite3\Statement::class; function setUp() { - date_default_timezone_set("UTC"); $c = new \SQLite3(":memory:"); $c->enableExceptions(true); $this->c = $c; diff --git a/tests/lib/Database/SeriesSubscription.php b/tests/lib/Database/SeriesSubscription.php index 01bc0fa2..0b725474 100644 --- a/tests/lib/Database/SeriesSubscription.php +++ b/tests/lib/Database/SeriesSubscription.php @@ -12,16 +12,17 @@ trait SeriesSubscription { $data = [ 'arsse_feeds' => [ 'columns' => [ - 'id' => "int", - 'url' => "str", - 'title' => "str", - 'username' => "str", - 'password' => "str", + 'id' => "int", + 'url' => "str", + 'title' => "str", + 'username' => "str", + 'password' => "str", + 'next_fetch' => "datetime", ], 'rows' => [ - [1,"http://example.com/feed1", "Ook", "", ""], - [2,"http://example.com/feed2", "Eek", "", ""], - [3,"http://example.com/feed3", "Ack", "", ""], + [1,"http://example.com/feed1", "Ook", "", "",strtotime("now")], + [2,"http://example.com/feed2", "Eek", "", "",strtotime("now - 1 hour")], + [3,"http://example.com/feed3", "Ack", "", "",strtotime("now + 1 hour")], ] ], 'arsse_subscriptions' => [ diff --git a/tests/test.php b/tests/test.php index bf19bd11..bf0018af 100644 --- a/tests/test.php +++ b/tests/test.php @@ -20,11 +20,10 @@ Data::$user->authorizationEnabled(false); Data::$user->rightsSet($user, User\Driver::RIGHTS_GLOBAL_ADMIN); Data::$user->authorizationEnabled(true); Data::$db->folderAdd($user, ['name' => 'ook']); -Data::$db->subscriptionAdd($user, "https://jkingweb.ca/test.atom"); +/*Data::$db->subscriptionAdd($user, "http://linuxfr.org/news.atom"); Data::$db->subscriptionPropertiesSet($user, 1, [ 'title' => "OOOOOOOOK!", - 'folder' => null, - 'order_type' => null, - 'pinned' => null, -]); -var_export(Data::$db->subscriptionList($user)->getAll()); \ No newline at end of file +]);*/ +(new REST())->dispatch(new REST\Request( + "POST", "/index.php/apps/news/api/v1-2/feeds/", json_encode(['url'=> "http://linuxfr.org/news.atom"]) +)); \ No newline at end of file