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