Browse Source

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.
microsub
J. King 7 years ago
parent
commit
ea08bbb87b
  1. 4
      lib/Database.php
  2. 13
      lib/REST.php
  3. 32
      lib/REST/TinyTinyRSS/Icon.php
  4. 54
      tests/REST/TinyTinyRSS/TestTinyTinyIcon.php
  5. 23
      tests/lib/Database/SeriesSubscription.php
  6. 1
      tests/phpunit.xml

4
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"]);

13
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:

32
lib/REST/TinyTinyRSS/Icon.php

@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace JKingWeb\Arsse\REST\TinyTinyRSS;
use JKingWeb\Arsse\Arsse;
use JKingWeb\Arsse\REST\Response;
class Icon extends \JKingWeb\Arsse\REST\AbstractHandler {
public function __construct() {
}
public function dispatch(\JKingWeb\Arsse\REST\Request $req): Response {
if ($req->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);
}
}
}

54
tests/REST/TinyTinyRSS/TestTinyTinyIcon.php

@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace JKingWeb\Arsse;
use JKingWeb\Arsse\REST\Request;
use JKingWeb\Arsse\REST\Response;
use JKingWeb\Arsse\Test\Result;
use JKingWeb\Arsse\Misc\Date;
use JKingWeb\Arsse\Misc\Context;
use JKingWeb\Arsse\Db\ExceptionInput;
use JKingWeb\Arsse\Db\Transaction;
use JKingWeb\Arsse\REST\TinyTinyRSS\API;
use Phake;
/** @covers \JKingWeb\Arsse\REST\TinyTinyRSS\Icon<extended> */
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")));
}
}

23
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));
}
}

1
tests/phpunit.xml

@ -78,6 +78,7 @@
<file>REST/NextCloudNews/TestNCNVersionDiscovery.php</file>
<file>REST/NextCloudNews/TestNCNV1_2.php</file>
<file>REST/TinyTinyRSS/TestTinyTinyAPI.php</file>
<file>REST/TinyTinyRSS/TestTinyTinyIcon.php</file>
</testsuite>
<testsuite name="Refresh service">
<file>Service/TestService.php</file>

Loading…
Cancel
Save