From ea08bbb87be805b66c9fd19cc648b283b90c0551 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Fri, 10 Nov 2017 12:02:59 -0500 Subject: [PATCH] Implement TTRSS feed icons; fixes #121 This introduces a data model function of unusual privilege: it can retrieve favicon URLs for any subscription, regardless of user ID. This is a single-purpose hack and its use should be avoided if at all possible. --- lib/Database.php | 4 ++ lib/REST.php | 13 +++-- lib/REST/TinyTinyRSS/Icon.php | 32 ++++++++++++ tests/REST/TinyTinyRSS/TestTinyTinyIcon.php | 54 +++++++++++++++++++++ tests/lib/Database/SeriesSubscription.php | 23 +++++++-- tests/phpunit.xml | 1 + 6 files changed, 120 insertions(+), 7 deletions(-) create mode 100644 lib/REST/TinyTinyRSS/Icon.php create mode 100644 tests/REST/TinyTinyRSS/TestTinyTinyIcon.php diff --git a/lib/Database.php b/lib/Database.php index 87a7738..59f4a42 100644 --- a/lib/Database.php +++ b/lib/Database.php @@ -629,6 +629,10 @@ class Database { return $out; } + public function subscriptionFavicon(int $id): string { + return (string) $this->db->prepare("SELECT favicon from arsse_feeds join arsse_subscriptions on feed is arsse_feeds.id where arsse_subscriptions.id is ?", "int")->run($id)->getValue(); + } + protected function subscriptionValidateId(string $user, $id, bool $subject = false): array { if (!ValueInfo::id($id)) { throw new Db\ExceptionInput("typeViolation", ["action" => $this->caller(), "field" => "feed", 'type' => "int > 0"]); diff --git a/lib/REST.php b/lib/REST.php index c340e37..ddee9d3 100644 --- a/lib/REST.php +++ b/lib/REST.php @@ -21,14 +21,19 @@ class REST { 'strip' => '/tt-rss/api/', 'class' => REST\TinyTinyRSS\API::class, ], + 'ttrss_icon' => [ // Tiny Tiny RSS feed icons + 'match' => '/tt-rss/feed-icons/', + 'strip' => '/tt-rss/feed-icons/', + 'class' => REST\TinyTinyRSS\Icon::class, + ], // Other candidates: - // NextCloud News v2 https://github.com/nextcloud/news/blob/master/docs/externalapi/External-Api.md - // Feedbin v1 https://github.com/feedbin/feedbin-api/commit/86da10aac5f1a57531a6e17b08744e5f9e7db8a9 - // Feedbin v2 https://github.com/feedbin/feedbin-api - // Fever https://feedafever.com/api // Google Reader http://feedhq.readthedocs.io/en/latest/api/index.html + // Fever https://feedafever.com/api + // Feedbin v2 https://github.com/feedbin/feedbin-api + // Feedbin v1 https://github.com/feedbin/feedbin-api/commit/86da10aac5f1a57531a6e17b08744e5f9e7db8a9 // Miniflux https://github.com/miniflux/miniflux/blob/master/docs/json-rpc-api.markdown // CommaFeed https://www.commafeed.com/api/ + // NextCloud News v2 https://github.com/nextcloud/news/blob/master/docs/externalapi/External-Api.md // Selfoss https://github.com/SSilence/selfoss/wiki/Restful-API-for-Apps-or-any-other-external-access // BirdReader https://github.com/glynnbird/birdreader/blob/master/API.md // Proprietary (centralized) entities: diff --git a/lib/REST/TinyTinyRSS/Icon.php b/lib/REST/TinyTinyRSS/Icon.php new file mode 100644 index 0000000..9c03d58 --- /dev/null +++ b/lib/REST/TinyTinyRSS/Icon.php @@ -0,0 +1,32 @@ +method != "GET") { + // only GET requests are allowed + return new Response(405, "", "", ["Allow: GET"]); + } elseif (!preg_match("<^(\d+)\.ico$>", $req->url, $match) || !((int) $match[1])) { + return new Response(404); + } + $url = Arsse::$db->subscriptionFavicon((int) $match[1]); + if ($url) { + // strip out anything after literal line-end characters; this is to mitigate a potential header (e.g. cookie) injection from the URL + if (($pos = strpos($url, "\r")) !== FALSE || ($pos = strpos($url, "\n")) !== FALSE) { + $url = substr($url, 0, $pos); + } + return new Response(301, "", "", ["Location: $url"]); + } else { + return new Response(404); + } + } +} \ No newline at end of file diff --git a/tests/REST/TinyTinyRSS/TestTinyTinyIcon.php b/tests/REST/TinyTinyRSS/TestTinyTinyIcon.php new file mode 100644 index 0000000..d542740 --- /dev/null +++ b/tests/REST/TinyTinyRSS/TestTinyTinyIcon.php @@ -0,0 +1,54 @@ + */ +class TestTinyTinyIcon extends Test\AbstractTest { + protected $h; + + public function setUp() { + $this->clearData(); + Arsse::$conf = new Conf(); + // create a mock user manager + // create a mock database interface + Arsse::$db = Phake::mock(Database::class); + $this->h = new REST\TinyTinyRSS\Icon(); + } + + public function tearDown() { + $this->clearData(); + } + + public function testRetrieveFavion() { + Phake::when(Arsse::$db)->subscriptionFavicon->thenReturn(""); + Phake::when(Arsse::$db)->subscriptionFavicon(42)->thenReturn("http://example.com/favicon.ico"); + Phake::when(Arsse::$db)->subscriptionFavicon(2112)->thenReturn("http://example.net/logo.png"); + Phake::when(Arsse::$db)->subscriptionFavicon(1337)->thenReturn("http://example.org/icon.gif\r\nLocation: http://bad.example.com/"); + // these requests should succeed + $exp = new Response(301, "", "", ["Location: http://example.com/favicon.ico"]); + $this->assertEquals($exp, $this->h->dispatch(new Request("GET", "42.ico"))); + $exp = new Response(301, "", "", ["Location: http://example.net/logo.png"]); + $this->assertEquals($exp, $this->h->dispatch(new Request("GET", "2112.ico"))); + $exp = new Response(301, "", "", ["Location: http://example.org/icon.gif"]); + $this->assertEquals($exp, $this->h->dispatch(new Request("GET", "1337.ico"))); + // these requests should fail + $exp = new Response(404); + $this->assertEquals($exp, $this->h->dispatch(new Request("GET", "ook.ico"))); + $this->assertEquals($exp, $this->h->dispatch(new Request("GET", "ook"))); + $this->assertEquals($exp, $this->h->dispatch(new Request("GET", "47.ico"))); + $this->assertEquals($exp, $this->h->dispatch(new Request("GET", "2112.png"))); + // only GET is allowed + $exp = new Response(405, "", "", ["Allow: GET"]); + $this->assertEquals($exp, $this->h->dispatch(new Request("PUT", "2112.ico"))); + } +} diff --git a/tests/lib/Database/SeriesSubscription.php b/tests/lib/Database/SeriesSubscription.php index f25def9..f91bee8 100644 --- a/tests/lib/Database/SeriesSubscription.php +++ b/tests/lib/Database/SeriesSubscription.php @@ -44,6 +44,7 @@ trait SeriesSubscription { 'username' => "str", 'password' => "str", 'next_fetch' => "datetime", + 'favicon' => "str", ], 'rows' => [] // filled in the series setup ], @@ -104,9 +105,9 @@ trait SeriesSubscription { public function setUpSeries() { $this->data['arsse_feeds']['rows'] = [ - [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")], + [1,"http://example.com/feed1", "Ook", "", "",strtotime("now"),''], + [2,"http://example.com/feed2", "Eek", "", "",strtotime("now - 1 hour"),'http://example.com/favicon.ico'], + [3,"http://example.com/feed3", "Ack", "", "",strtotime("now + 1 hour"),''], ]; // initialize a partial mock of the Database object to later manipulate the feedUpdate method Arsse::$db = Phake::partialMock(Database::class, $this->drv); @@ -402,4 +403,20 @@ trait SeriesSubscription { $this->assertException("notAuthorized", "User", "ExceptionAuthz"); Arsse::$db->subscriptionPropertiesSet($this->user, 1, ['folder' => null]); } + + public function testRetrieveTheFaviconOfASubscription() { + $exp = "http://example.com/favicon.ico"; + $this->assertSame($exp, Arsse::$db->subscriptionFavicon(1)); + $this->assertSame($exp, Arsse::$db->subscriptionFavicon(2)); + $this->assertSame('', Arsse::$db->subscriptionFavicon(3)); + $this->assertSame('', Arsse::$db->subscriptionFavicon(4)); + // authorization shouldn't have any bearing on this function + Phake::when(Arsse::$user)->authorize->thenReturn(false); + $this->assertSame($exp, Arsse::$db->subscriptionFavicon(1)); + $this->assertSame($exp, Arsse::$db->subscriptionFavicon(2)); + $this->assertSame('', Arsse::$db->subscriptionFavicon(3)); + $this->assertSame('', Arsse::$db->subscriptionFavicon(4)); + // invalid IDs should simply return an empty string + $this->assertSame('', Arsse::$db->subscriptionFavicon(-2112)); + } } diff --git a/tests/phpunit.xml b/tests/phpunit.xml index 0cc5d78..9d65e7b 100644 --- a/tests/phpunit.xml +++ b/tests/phpunit.xml @@ -78,6 +78,7 @@ REST/NextCloudNews/TestNCNVersionDiscovery.php REST/NextCloudNews/TestNCNV1_2.php REST/TinyTinyRSS/TestTinyTinyAPI.php + REST/TinyTinyRSS/TestTinyTinyIcon.php Service/TestService.php