From b58a32646122450360ec3943b0e6e7e55983973d Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Thu, 29 Oct 2020 11:58:45 -0400
Subject: [PATCH 001/366] Prepare for schema changes
---
lib/Database.php | 2 +-
sql/MySQL/6.sql | 6 ++++++
sql/PostgreSQL/6.sql | 7 +++++++
sql/SQLite3/6.sql | 8 ++++++++
4 files changed, 22 insertions(+), 1 deletion(-)
create mode 100644 sql/MySQL/6.sql
create mode 100644 sql/PostgreSQL/6.sql
create mode 100644 sql/SQLite3/6.sql
diff --git a/lib/Database.php b/lib/Database.php
index 6efda1b4..52d315c7 100644
--- a/lib/Database.php
+++ b/lib/Database.php
@@ -39,7 +39,7 @@ use JKingWeb\Arsse\Misc\URL;
*/
class Database {
/** The version number of the latest schema the interface is aware of */
- public const SCHEMA_VERSION = 6;
+ public const SCHEMA_VERSION = 7;
/** Makes tag/label association change operations remove members */
public const ASSOC_REMOVE = 0;
/** Makes tag/label association change operations add members */
diff --git a/sql/MySQL/6.sql b/sql/MySQL/6.sql
new file mode 100644
index 00000000..fff57671
--- /dev/null
+++ b/sql/MySQL/6.sql
@@ -0,0 +1,6 @@
+-- SPDX-License-Identifier: MIT
+-- Copyright 2017 J. King, Dustin Wilson et al.
+-- See LICENSE and AUTHORS files for details
+
+
+update arsse_meta set value = '7' where "key" = 'schema_version';
diff --git a/sql/PostgreSQL/6.sql b/sql/PostgreSQL/6.sql
new file mode 100644
index 00000000..4d86e98c
--- /dev/null
+++ b/sql/PostgreSQL/6.sql
@@ -0,0 +1,7 @@
+-- SPDX-License-Identifier: MIT
+-- Copyright 2017 J. King, Dustin Wilson et al.
+-- See LICENSE and AUTHORS files for details
+
+
+
+update arsse_meta set value = '7' where "key" = 'schema_version';
diff --git a/sql/SQLite3/6.sql b/sql/SQLite3/6.sql
new file mode 100644
index 00000000..7f74ee29
--- /dev/null
+++ b/sql/SQLite3/6.sql
@@ -0,0 +1,8 @@
+-- SPDX-License-Identifier: MIT
+-- Copyright 2017 J. King, Dustin Wilson et al.
+-- See LICENSE and AUTHORS files for details
+
+
+-- set version marker
+pragma user_version = 7;
+update arsse_meta set value = '7' where "key" = 'schema_version';
From 3ac010d5b691e217750d35d9b73db1a3508b443e Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Fri, 30 Oct 2020 12:16:03 -0400
Subject: [PATCH 002/366] Fix tests in absence of database extensions
---
tests/cases/Db/SQLite3/TestDriver.php | 6 ++++--
tests/cases/Db/SQLite3/TestResult.php | 6 ++++--
tests/cases/Db/SQLite3/TestStatement.php | 6 ++++--
tests/cases/Db/SQLite3/TestUpdate.php | 6 ++++--
tests/lib/DatabaseDrivers/MySQL.php | 5 ++++-
tests/lib/DatabaseDrivers/PostgreSQL.php | 2 +-
6 files changed, 21 insertions(+), 10 deletions(-)
diff --git a/tests/cases/Db/SQLite3/TestDriver.php b/tests/cases/Db/SQLite3/TestDriver.php
index 4c80cbad..b3eb3593 100644
--- a/tests/cases/Db/SQLite3/TestDriver.php
+++ b/tests/cases/Db/SQLite3/TestDriver.php
@@ -26,8 +26,10 @@ class TestDriver extends \JKingWeb\Arsse\TestCase\Db\BaseDriver {
}
public static function tearDownAfterClass(): void {
- static::$interface->close();
- static::$interface = null;
+ if (static::$interface) {
+ static::$interface->close();
+ static::$interface = null;
+ }
parent::tearDownAfterClass();
@unlink(static::$file);
static::$file = null;
diff --git a/tests/cases/Db/SQLite3/TestResult.php b/tests/cases/Db/SQLite3/TestResult.php
index d7f8c091..5a8d0cd6 100644
--- a/tests/cases/Db/SQLite3/TestResult.php
+++ b/tests/cases/Db/SQLite3/TestResult.php
@@ -16,8 +16,10 @@ class TestResult extends \JKingWeb\Arsse\TestCase\Db\BaseResult {
protected static $createTest = "CREATE TABLE arsse_test(id integer primary key)";
public static function tearDownAfterClass(): void {
- static::$interface->close();
- static::$interface = null;
+ if (static::$interface) {
+ static::$interface->close();
+ static::$interface = null;
+ }
parent::tearDownAfterClass();
}
diff --git a/tests/cases/Db/SQLite3/TestStatement.php b/tests/cases/Db/SQLite3/TestStatement.php
index 1af5be4c..f7b970f2 100644
--- a/tests/cases/Db/SQLite3/TestStatement.php
+++ b/tests/cases/Db/SQLite3/TestStatement.php
@@ -13,8 +13,10 @@ class TestStatement extends \JKingWeb\Arsse\TestCase\Db\BaseStatement {
use \JKingWeb\Arsse\Test\DatabaseDrivers\SQLite3;
public static function tearDownAfterClass(): void {
- static::$interface->close();
- static::$interface = null;
+ if (static::$interface) {
+ static::$interface->close();
+ static::$interface = null;
+ }
parent::tearDownAfterClass();
}
diff --git a/tests/cases/Db/SQLite3/TestUpdate.php b/tests/cases/Db/SQLite3/TestUpdate.php
index 94842e25..409f1091 100644
--- a/tests/cases/Db/SQLite3/TestUpdate.php
+++ b/tests/cases/Db/SQLite3/TestUpdate.php
@@ -16,8 +16,10 @@ class TestUpdate extends \JKingWeb\Arsse\TestCase\Db\BaseUpdate {
protected static $minimal2 = "pragma user_version=2";
public static function tearDownAfterClass(): void {
- static::$interface->close();
- static::$interface = null;
+ if (static::$interface) {
+ static::$interface->close();
+ static::$interface = null;
+ }
parent::tearDownAfterClass();
}
}
diff --git a/tests/lib/DatabaseDrivers/MySQL.php b/tests/lib/DatabaseDrivers/MySQL.php
index f1571a2b..01501f15 100644
--- a/tests/lib/DatabaseDrivers/MySQL.php
+++ b/tests/lib/DatabaseDrivers/MySQL.php
@@ -18,9 +18,12 @@ trait MySQL {
protected static $stringOutput = true;
public static function dbInterface() {
+ if (!class_exists("mysqli")) {
+ return null;
+ }
$d = @new \mysqli(Arsse::$conf->dbMySQLHost, Arsse::$conf->dbMySQLUser, Arsse::$conf->dbMySQLPass, Arsse::$conf->dbMySQLDb, Arsse::$conf->dbMySQLPort);
if ($d->connect_errno) {
- return;
+ return null;
}
$d->set_charset("utf8mb4");
foreach (\JKingWeb\Arsse\Db\MySQL\PDODriver::makeSetupQueries() as $q) {
diff --git a/tests/lib/DatabaseDrivers/PostgreSQL.php b/tests/lib/DatabaseDrivers/PostgreSQL.php
index fb0038cb..edc75493 100644
--- a/tests/lib/DatabaseDrivers/PostgreSQL.php
+++ b/tests/lib/DatabaseDrivers/PostgreSQL.php
@@ -19,7 +19,7 @@ trait PostgreSQL {
public static function dbInterface() {
$connString = \JKingWeb\Arsse\Db\PostgreSQL\Driver::makeConnectionString(false, Arsse::$conf->dbPostgreSQLUser, Arsse::$conf->dbPostgreSQLPass, Arsse::$conf->dbPostgreSQLDb, Arsse::$conf->dbPostgreSQLHost, Arsse::$conf->dbPostgreSQLPort, "");
- if ($d = @pg_connect($connString, \PGSQL_CONNECT_FORCE_NEW)) {
+ if (function_exists("pg_connect") && $d = @pg_connect($connString, \PGSQL_CONNECT_FORCE_NEW)) {
foreach (\JKingWeb\Arsse\Db\PostgreSQL\Driver::makeSetupQueries(Arsse::$conf->dbPostgreSQLSchema) as $q) {
pg_query($d, $q);
}
From 4db1b95cf43abaeb8c80643cf08bd99c9a81ab73 Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Fri, 30 Oct 2020 15:25:22 -0400
Subject: [PATCH 003/366] Add numeric IDs and other Miniflux data to SQLite
schema
---
lib/Database.php | 4 ++--
sql/SQLite3/6.sql | 24 +++++++++++++++++++
tests/cases/Database/SeriesArticle.php | 9 +++----
tests/cases/Database/SeriesCleanup.php | 5 ++--
tests/cases/Database/SeriesFeed.php | 5 ++--
tests/cases/Database/SeriesFolder.php | 5 ++--
tests/cases/Database/SeriesLabel.php | 9 +++----
tests/cases/Database/SeriesSession.php | 5 ++--
tests/cases/Database/SeriesSubscription.php | 5 ++--
tests/cases/Database/SeriesTag.php | 9 +++----
tests/cases/Database/SeriesToken.php | 5 ++--
tests/cases/Database/SeriesUser.php | 7 +++---
tests/cases/Db/BaseUpdate.php | 18 ++++++++++++++
tests/cases/ImportExport/TestImportExport.php | 5 ++--
14 files changed, 84 insertions(+), 31 deletions(-)
diff --git a/lib/Database.php b/lib/Database.php
index 52d315c7..6c038ab5 100644
--- a/lib/Database.php
+++ b/lib/Database.php
@@ -35,7 +35,7 @@ use JKingWeb\Arsse\Misc\URL;
* deletes a user from the database, and labelArticlesSet() changes a label's
* associations with articles. There has been an effort to keep public method
* names consistent throughout, but protected methods, having different
- * concerns, will typicsally follow different conventions.
+ * concerns, will typically follow different conventions.
*/
class Database {
/** The version number of the latest schema the interface is aware of */
@@ -256,7 +256,7 @@ class Database {
throw new User\Exception("alreadyExists", ["action" => __FUNCTION__, "user" => $user]);
}
$hash = (strlen($password) > 0) ? password_hash($password, \PASSWORD_DEFAULT) : "";
- $this->db->prepare("INSERT INTO arsse_users(id,password) values(?,?)", "str", "str")->runArray([$user,$hash]);
+ $this->db->prepare("INSERT INTO arsse_users(id,password,num) values(?, ?, coalesce((select max(num) from arsse_users), 0) + 1)", "str", "str")->runArray([$user,$hash]);
return true;
}
diff --git a/sql/SQLite3/6.sql b/sql/SQLite3/6.sql
index 7f74ee29..142fada0 100644
--- a/sql/SQLite3/6.sql
+++ b/sql/SQLite3/6.sql
@@ -2,6 +2,30 @@
-- Copyright 2017 J. King, Dustin Wilson et al.
-- See LICENSE and AUTHORS files for details
+-- Add multiple columns to the users table
+-- In particular this adds a numeric identifier for each user, which Miniflux requires
+create table arsse_users_new(
+-- users
+ id text primary key not null collate nocase, -- user id
+ password text, -- password, salted and hashed; if using external authentication this would be blank
+ num integer unique not null, -- numeric identfier used by Miniflux
+ admin boolean not null default 0, -- Whether the user is an administrator
+ lang text, -- The user's chosen language code e.g. 'en', 'fr-ca'; null uses the system default
+ tz text not null default 'Etc/UTC', -- The user's chosen time zone, in zoneinfo format
+ sort_asc boolean not null default 0 -- Whether the user prefers to sort articles in ascending order
+) without rowid;
+create temp table arsse_users_existing(
+ id text not null,
+ num integer primary key
+);
+insert into arsse_users_existing(id) select id from arsse_users;
+insert into arsse_users_new(id, password, num)
+ select id, password, num
+ from arsse_users
+ join arsse_users_existing using(id);
+drop table arsse_users;
+drop table arsse_users_existing;
+alter table arsse_users_new rename to arsse_users;
-- set version marker
pragma user_version = 7;
diff --git a/tests/cases/Database/SeriesArticle.php b/tests/cases/Database/SeriesArticle.php
index 2f78e9c1..a9354c71 100644
--- a/tests/cases/Database/SeriesArticle.php
+++ b/tests/cases/Database/SeriesArticle.php
@@ -19,12 +19,13 @@ trait SeriesArticle {
'columns' => [
'id' => 'str',
'password' => 'str',
+ 'num' => 'int',
],
'rows' => [
- ["jane.doe@example.com", ""],
- ["john.doe@example.com", ""],
- ["john.doe@example.org", ""],
- ["john.doe@example.net", ""],
+ ["jane.doe@example.com", "",1],
+ ["john.doe@example.com", "",2],
+ ["john.doe@example.org", "",3],
+ ["john.doe@example.net", "",4],
],
],
'arsse_feeds' => [
diff --git a/tests/cases/Database/SeriesCleanup.php b/tests/cases/Database/SeriesCleanup.php
index ad40dcb3..b31f87c7 100644
--- a/tests/cases/Database/SeriesCleanup.php
+++ b/tests/cases/Database/SeriesCleanup.php
@@ -30,10 +30,11 @@ trait SeriesCleanup {
'columns' => [
'id' => 'str',
'password' => 'str',
+ 'num' => 'int',
],
'rows' => [
- ["jane.doe@example.com", ""],
- ["john.doe@example.com", ""],
+ ["jane.doe@example.com", "",1],
+ ["john.doe@example.com", "",2],
],
],
'arsse_sessions' => [
diff --git a/tests/cases/Database/SeriesFeed.php b/tests/cases/Database/SeriesFeed.php
index d4a75213..1eb23bb0 100644
--- a/tests/cases/Database/SeriesFeed.php
+++ b/tests/cases/Database/SeriesFeed.php
@@ -19,10 +19,11 @@ trait SeriesFeed {
'columns' => [
'id' => 'str',
'password' => 'str',
+ 'num' => 'int',
],
'rows' => [
- ["jane.doe@example.com", ""],
- ["john.doe@example.com", ""],
+ ["jane.doe@example.com", "",1],
+ ["john.doe@example.com", "",2],
],
],
'arsse_feeds' => [
diff --git a/tests/cases/Database/SeriesFolder.php b/tests/cases/Database/SeriesFolder.php
index 6d69f64a..98d12d7b 100644
--- a/tests/cases/Database/SeriesFolder.php
+++ b/tests/cases/Database/SeriesFolder.php
@@ -15,10 +15,11 @@ trait SeriesFolder {
'columns' => [
'id' => 'str',
'password' => 'str',
+ 'num' => 'int',
],
'rows' => [
- ["jane.doe@example.com", ""],
- ["john.doe@example.com", ""],
+ ["jane.doe@example.com", "",1],
+ ["john.doe@example.com", "",2],
],
],
'arsse_folders' => [
diff --git a/tests/cases/Database/SeriesLabel.php b/tests/cases/Database/SeriesLabel.php
index db9c4989..d66dcdb0 100644
--- a/tests/cases/Database/SeriesLabel.php
+++ b/tests/cases/Database/SeriesLabel.php
@@ -17,12 +17,13 @@ trait SeriesLabel {
'columns' => [
'id' => 'str',
'password' => 'str',
+ 'num' => 'int',
],
'rows' => [
- ["jane.doe@example.com", ""],
- ["john.doe@example.com", ""],
- ["john.doe@example.org", ""],
- ["john.doe@example.net", ""],
+ ["jane.doe@example.com", "",1],
+ ["john.doe@example.com", "",2],
+ ["john.doe@example.org", "",3],
+ ["john.doe@example.net", "",4],
],
],
'arsse_folders' => [
diff --git a/tests/cases/Database/SeriesSession.php b/tests/cases/Database/SeriesSession.php
index 9a354f66..163d8bf8 100644
--- a/tests/cases/Database/SeriesSession.php
+++ b/tests/cases/Database/SeriesSession.php
@@ -26,10 +26,11 @@ trait SeriesSession {
'columns' => [
'id' => 'str',
'password' => 'str',
+ 'num' => 'int',
],
'rows' => [
- ["jane.doe@example.com", ""],
- ["john.doe@example.com", ""],
+ ["jane.doe@example.com", "",1],
+ ["john.doe@example.com", "",2],
],
],
'arsse_sessions' => [
diff --git a/tests/cases/Database/SeriesSubscription.php b/tests/cases/Database/SeriesSubscription.php
index d8614e24..c0a88f4a 100644
--- a/tests/cases/Database/SeriesSubscription.php
+++ b/tests/cases/Database/SeriesSubscription.php
@@ -18,10 +18,11 @@ trait SeriesSubscription {
'columns' => [
'id' => 'str',
'password' => 'str',
+ 'num' => 'int',
],
'rows' => [
- ["jane.doe@example.com", ""],
- ["john.doe@example.com", ""],
+ ["jane.doe@example.com", "",1],
+ ["john.doe@example.com", "",2],
],
],
'arsse_folders' => [
diff --git a/tests/cases/Database/SeriesTag.php b/tests/cases/Database/SeriesTag.php
index f6a3f4ea..3c4b4ac8 100644
--- a/tests/cases/Database/SeriesTag.php
+++ b/tests/cases/Database/SeriesTag.php
@@ -16,12 +16,13 @@ trait SeriesTag {
'columns' => [
'id' => 'str',
'password' => 'str',
+ 'num' => 'int',
],
'rows' => [
- ["jane.doe@example.com", ""],
- ["john.doe@example.com", ""],
- ["john.doe@example.org", ""],
- ["john.doe@example.net", ""],
+ ["jane.doe@example.com", "",1],
+ ["john.doe@example.com", "",2],
+ ["john.doe@example.org", "",3],
+ ["john.doe@example.net", "",4],
],
],
'arsse_feeds' => [
diff --git a/tests/cases/Database/SeriesToken.php b/tests/cases/Database/SeriesToken.php
index aad4a875..267be38e 100644
--- a/tests/cases/Database/SeriesToken.php
+++ b/tests/cases/Database/SeriesToken.php
@@ -20,10 +20,11 @@ trait SeriesToken {
'columns' => [
'id' => 'str',
'password' => 'str',
+ 'num' => 'int',
],
'rows' => [
- ["jane.doe@example.com", ""],
- ["john.doe@example.com", ""],
+ ["jane.doe@example.com", "",1],
+ ["john.doe@example.com", "",2],
],
],
'arsse_tokens' => [
diff --git a/tests/cases/Database/SeriesUser.php b/tests/cases/Database/SeriesUser.php
index 54376600..9b97fd80 100644
--- a/tests/cases/Database/SeriesUser.php
+++ b/tests/cases/Database/SeriesUser.php
@@ -15,11 +15,12 @@ trait SeriesUser {
'columns' => [
'id' => 'str',
'password' => 'str',
+ 'num' => 'int',
],
'rows' => [
- ["admin@example.net", '$2y$10$PbcG2ZR3Z8TuPzM7aHTF8.v61dtCjzjK78gdZJcp4UePE8T9jEgBW'], // password is hash of "secret"
- ["jane.doe@example.com", ""],
- ["john.doe@example.com", ""],
+ ["admin@example.net", '$2y$10$PbcG2ZR3Z8TuPzM7aHTF8.v61dtCjzjK78gdZJcp4UePE8T9jEgBW',1], // password is hash of "secret"
+ ["jane.doe@example.com", "",2],
+ ["john.doe@example.com", "",3],
],
],
];
diff --git a/tests/cases/Db/BaseUpdate.php b/tests/cases/Db/BaseUpdate.php
index d5415134..b25e4723 100644
--- a/tests/cases/Db/BaseUpdate.php
+++ b/tests/cases/Db/BaseUpdate.php
@@ -134,4 +134,22 @@ class BaseUpdate extends \JKingWeb\Arsse\Test\AbstractTest {
$this->drv->schemaUpdate(Database::SCHEMA_VERSION);
$this->assertTrue($this->drv->maintenance());
}
+
+ public function testUpdateTo7(): void {
+ $this->drv->schemaUpdate(6);
+ $this->drv->exec(<<drv->schemaUpdate(7);
+ $exp = [
+ ['id' => "a", 'password' => "xyz", 'num' => 1],
+ ['id' => "b", 'password' => "abc", 'num' => 2],
+ ];
+ $this->assertEquals($exp, $this->drv->query("SELECT id, password, num from arsse_users")->getAll());
+ $this->assertSame(2, (int) $this->drv->query("SELECT count(*) from arsse_folders")->getValue());
+ }
}
diff --git a/tests/cases/ImportExport/TestImportExport.php b/tests/cases/ImportExport/TestImportExport.php
index 4d3fef30..af0b0fe0 100644
--- a/tests/cases/ImportExport/TestImportExport.php
+++ b/tests/cases/ImportExport/TestImportExport.php
@@ -46,10 +46,11 @@ class TestImportExport extends \JKingWeb\Arsse\Test\AbstractTest {
'columns' => [
'id' => 'str',
'password' => 'str',
+ 'num' => 'int',
],
'rows' => [
- ["john.doe@example.com", ""],
- ["jane.doe@example.com", ""],
+ ["john.doe@example.com", "", 1],
+ ["jane.doe@example.com", "", 2],
],
],
'arsse_folders' => [
From 16d2e016687f1d882630048f9e699d5717cf464c Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Fri, 30 Oct 2020 19:00:11 -0400
Subject: [PATCH 004/366] New schema for PostgreSQL and MySQL
---
lib/Database.php | 3 ++-
sql/MySQL/6.sql | 15 +++++++++++++++
sql/PostgreSQL/6.sql | 17 ++++++++++++++++-
3 files changed, 33 insertions(+), 2 deletions(-)
diff --git a/lib/Database.php b/lib/Database.php
index 6c038ab5..b7b44881 100644
--- a/lib/Database.php
+++ b/lib/Database.php
@@ -256,7 +256,8 @@ class Database {
throw new User\Exception("alreadyExists", ["action" => __FUNCTION__, "user" => $user]);
}
$hash = (strlen($password) > 0) ? password_hash($password, \PASSWORD_DEFAULT) : "";
- $this->db->prepare("INSERT INTO arsse_users(id,password,num) values(?, ?, coalesce((select max(num) from arsse_users), 0) + 1)", "str", "str")->runArray([$user,$hash]);
+ // NOTE: This roundabout construction (with 'select' rather than 'values') is required by MySQL, because MySQL is riddled with pitfalls and exceptions
+ $this->db->prepare("INSERT INTO arsse_users(id,password,num) select ?, ?, ((select max(num) from arsse_users) + 1)", "str", "str")->runArray([$user,$hash]);
return true;
}
diff --git a/sql/MySQL/6.sql b/sql/MySQL/6.sql
index fff57671..e16375cc 100644
--- a/sql/MySQL/6.sql
+++ b/sql/MySQL/6.sql
@@ -2,5 +2,20 @@
-- Copyright 2017 J. King, Dustin Wilson et al.
-- See LICENSE and AUTHORS files for details
+alter table arsse_users add column num bigint unsigned unique;
+alter table arsse_users add column admin boolean not null default 0;
+alter table arsse_users add column lang longtext;
+alter table arsse_users add column tz varchar(44) not null default 'Etc/UTC';
+alter table arsse_users add column soort_asc boolean not null default 0;
+create temporary table arsse_users_existing(
+ id text not null,
+ num serial primary key
+) character set utf8mb4 collate utf8mb4_unicode_ci;
+insert into arsse_users_existing(id) select id from arsse_users;
+update arsse_users as u, arsse_users_existing as n
+ set u.num = n.num
+where u.id = n.id;
+drop table arsse_users_existing;
+alter table arsse_users modify num bigint unsigned not null;
update arsse_meta set value = '7' where "key" = 'schema_version';
diff --git a/sql/PostgreSQL/6.sql b/sql/PostgreSQL/6.sql
index 4d86e98c..6099e8d5 100644
--- a/sql/PostgreSQL/6.sql
+++ b/sql/PostgreSQL/6.sql
@@ -2,6 +2,21 @@
-- Copyright 2017 J. King, Dustin Wilson et al.
-- See LICENSE and AUTHORS files for details
-
+alter table arsse_users add column num bigint unique;
+alter table arsse_users add column admin smallint not null default 0;
+alter table arsse_users add column lang text;
+alter table arsse_users add column tz text not null default 'Etc/UTC';
+alter table arsse_users add column soort_asc smallint not null default 0;
+create temp table arsse_users_existing(
+ id text not null,
+ num bigserial
+);
+insert into arsse_users_existing(id) select id from arsse_users;
+update arsse_users as u
+ set num = e.num
+from arsse_users_existing as e
+where u.id = e.id;
+drop table arsse_users_existing;
+alter table arsse_users alter column num set not null;
update arsse_meta set value = '7' where "key" = 'schema_version';
From 8ad7fc81a89fc343344a3ee3f9bc69d27f2e005c Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Sat, 31 Oct 2020 21:26:11 -0400
Subject: [PATCH 005/366] Initially mapping out of Miniflux API
---
lib/REST.php | 6 +-
lib/REST/Miniflux/V1.php | 120 +++++++++++++++++++++++++++++++++++++++
2 files changed, 125 insertions(+), 1 deletion(-)
create mode 100644 lib/REST/Miniflux/V1.php
diff --git a/lib/REST.php b/lib/REST.php
index 41fdaa1f..0d04be93 100644
--- a/lib/REST.php
+++ b/lib/REST.php
@@ -40,6 +40,11 @@ class REST {
'strip' => '/fever/',
'class' => REST\Fever\API::class,
],
+ 'miniflux' => [ // Miniflux https://miniflux.app/docs/api.html
+ 'match' => '/v1/',
+ 'strip' => '/v1',
+ 'class' => REST\Miniflux\API::class,
+ ],
// Other candidates:
// Microsub https://indieweb.org/Microsub
// Google Reader http://feedhq.readthedocs.io/en/latest/api/index.html
@@ -48,7 +53,6 @@ class REST {
// Selfoss https://github.com/SSilence/selfoss/wiki/Restful-API-for-Apps-or-any-other-external-access
// NewsBlur http://www.newsblur.com/api
// Unclear if clients exist:
- // Miniflux https://docs.miniflux.app/en/latest/api.html#api-reference
// Nextcloud News v2 https://github.com/nextcloud/news/blob/master/docs/externalapi/External-Api.md
// BirdReader https://github.com/glynnbird/birdreader/blob/master/API.md
// Feedbin v1 https://github.com/feedbin/feedbin-api/commit/86da10aac5f1a57531a6e17b08744e5f9e7db8a9
diff --git a/lib/REST/Miniflux/V1.php b/lib/REST/Miniflux/V1.php
new file mode 100644
index 00000000..8fd1dc47
--- /dev/null
+++ b/lib/REST/Miniflux/V1.php
@@ -0,0 +1,120 @@
+ ['GET' => "getCategories", 'POST' => "createCategory"],
+ '/categories/1' => ['PUT' => "updateCategory", 'DELETE' => "deleteCategory"],
+ '/discover' => ['POST' => "discoverSubscriptions"],
+ '/entries' => ['GET' => "getEntries", 'PUT' => "updateEntries"],
+ '/entries/1' => ['GET' => "getEntry"],
+ '/entries/1/bookmark' => ['PUT' => "toggleEntryBookmark"],
+ '/export' => ['GET' => "opmlExport"],
+ '/feeds' => ['GET' => "getFeeds", 'POST' => "createFeed"],
+ '/feeds/1' => ['GET' => "getFeed", 'PUT' => "updateFeed", 'DELETE' => "removeFeed"],
+ '/feeds/1/entries/1' => ['GET' => "getFeedEntry"],
+ '/feeds/1/entries' => ['GET' => "getFeedEntries"],
+ '/feeds/1/icon' => ['GET' => "getFeedIcon"],
+ '/feeds/1/refresh' => ['PUT' => "refreshFeed"],
+ '/feeds/refresh' => ['PUT' => "refreshAllFeeds"],
+ '/healthcheck' => ['GET' => "healthCheck"],
+ '/import' => ['POST' => "opmlImport"],
+ '/me' => ['GET' => "getCurrentUser"],
+ '/users' => ['GET' => "getUsers", 'POST' => "createUser"],
+ '/users/1' => ['GET' => "getUser", 'PUT' => "updateUser", 'DELETE' => "deleteUser"],
+ '/users/*' => ['GET' => "getUser"],
+ '/version' => ['GET' => "getVersion"],
+ ];
+
+ public function __construct() {
+ }
+
+ public function dispatch(ServerRequestInterface $req): ResponseInterface {
+ // try to authenticate
+ if ($req->getAttribute("authenticated", false)) {
+ Arsse::$user->id = $req->getAttribute("authenticatedUser");
+ } else {
+ return new EmptyResponse(401);
+ }
+ // get the request path only; this is assumed to already be normalized
+ $target = parse_url($req->getRequestTarget())['path'] ?? "";
+ // handle HTTP OPTIONS requests
+ if ($req->getMethod() === "OPTIONS") {
+ return $this->handleHTTPOptions($target);
+ }
+ }
+
+ protected function normalizePathIds(string $url): string {
+ $path = explode("/", $url);
+ // any path components which are database IDs (integers greater than zero) should be replaced with "1", for easier comparison (we don't care about the specific ID)
+ for ($a = 0; $a < sizeof($path); $a++) {
+ if (ValueInfo::id($path[$a])) {
+ $path[$a] = "1";
+ }
+ }
+ return implode("/", $path);
+ }
+
+ protected function handleHTTPOptions(string $url): ResponseInterface {
+ // normalize the URL path: change any IDs to 1 for easier comparison
+ $url = $this->normalizePathIDs($url);
+ if (isset($this->paths[$url])) {
+ // if the path is supported, respond with the allowed methods and other metadata
+ $allowed = array_keys($this->paths[$url]);
+ // if GET is allowed, so is HEAD
+ if (in_array("GET", $allowed)) {
+ array_unshift($allowed, "HEAD");
+ }
+ return new EmptyResponse(204, [
+ 'Allow' => implode(",", $allowed),
+ 'Accept' => self::ACCEPTED_TYPE,
+ ]);
+ } else {
+ // if the path is not supported, return 404
+ return new EmptyResponse(404);
+ }
+ }
+
+ protected function chooseCall(string $url, string $method): string {
+ // // normalize the URL path: change any IDs to 1 for easier comparison
+ $url = $this->normalizePathIds($url);
+ // normalize the HTTP method to uppercase
+ $method = strtoupper($method);
+ // we now evaluate the supplied URL against every supported path for the selected scope
+ // the URL is evaluated as an array so as to avoid decoded escapes turning invalid URLs into valid ones
+ if (isset($this->paths[$url])) {
+ // if the path is supported, make sure the method is allowed
+ if (isset($this->paths[$url][$method])) {
+ // if it is allowed, return the object method to run
+ return $this->paths[$url][$method];
+ } else {
+ // otherwise return 405
+ throw new Exception405(implode(", ", array_keys($this->paths[$url])));
+ }
+ } else {
+ // if the path is not supported, return 404
+ throw new Exception404();
+ }
+ }
+}
From 905f8938e221d2cdd8af6135a2247c77770b51b2 Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Sun, 1 Nov 2020 09:37:59 -0500
Subject: [PATCH 006/366] Typo
---
sql/MySQL/6.sql | 2 +-
sql/PostgreSQL/6.sql | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/sql/MySQL/6.sql b/sql/MySQL/6.sql
index e16375cc..248a0141 100644
--- a/sql/MySQL/6.sql
+++ b/sql/MySQL/6.sql
@@ -6,7 +6,7 @@ alter table arsse_users add column num bigint unsigned unique;
alter table arsse_users add column admin boolean not null default 0;
alter table arsse_users add column lang longtext;
alter table arsse_users add column tz varchar(44) not null default 'Etc/UTC';
-alter table arsse_users add column soort_asc boolean not null default 0;
+alter table arsse_users add column sort_asc boolean not null default 0;
create temporary table arsse_users_existing(
id text not null,
num serial primary key
diff --git a/sql/PostgreSQL/6.sql b/sql/PostgreSQL/6.sql
index 6099e8d5..bc365704 100644
--- a/sql/PostgreSQL/6.sql
+++ b/sql/PostgreSQL/6.sql
@@ -6,7 +6,7 @@ alter table arsse_users add column num bigint unique;
alter table arsse_users add column admin smallint not null default 0;
alter table arsse_users add column lang text;
alter table arsse_users add column tz text not null default 'Etc/UTC';
-alter table arsse_users add column soort_asc smallint not null default 0;
+alter table arsse_users add column sort_asc smallint not null default 0;
create temp table arsse_users_existing(
id text not null,
num bigserial
From c92bb12a116ccf65b85a420bdd26640b7aa9fc68 Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Sun, 1 Nov 2020 19:09:17 -0500
Subject: [PATCH 007/366] Prototype Miniflux dispatcher
---
lib/REST.php | 2 +-
lib/REST/Miniflux/V1.php | 50 ++++++++++++++++++++++++++++++---
lib/REST/NextcloudNews/V1_2.php | 20 ++++++-------
3 files changed, 57 insertions(+), 15 deletions(-)
diff --git a/lib/REST.php b/lib/REST.php
index 0d04be93..011d27df 100644
--- a/lib/REST.php
+++ b/lib/REST.php
@@ -43,7 +43,7 @@ class REST {
'miniflux' => [ // Miniflux https://miniflux.app/docs/api.html
'match' => '/v1/',
'strip' => '/v1',
- 'class' => REST\Miniflux\API::class,
+ 'class' => REST\Miniflux\V1::class,
],
// Other candidates:
// Microsub https://indieweb.org/Microsub
diff --git a/lib/REST/Miniflux/V1.php b/lib/REST/Miniflux/V1.php
index 8fd1dc47..9edff158 100644
--- a/lib/REST/Miniflux/V1.php
+++ b/lib/REST/Miniflux/V1.php
@@ -23,6 +23,8 @@ use Laminas\Diactoros\Response\JsonResponse as Response;
use Laminas\Diactoros\Response\EmptyResponse;
class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
+ protected const ACCEPTED_TYPES_OPML = ["text/xml", "application/xml", "text/x-opml"];
+ protected const ACCEPTED_TYPES_JSON = ["application/json", "text/json"];
protected $paths = [
'/categories' => ['GET' => "getCategories", 'POST' => "createCategory"],
'/categories/1' => ['PUT' => "updateCategory", 'DELETE' => "deleteCategory"],
@@ -55,14 +57,46 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
if ($req->getAttribute("authenticated", false)) {
Arsse::$user->id = $req->getAttribute("authenticatedUser");
} else {
+ // TODO: Handle X-Auth-Token authentication
return new EmptyResponse(401);
}
// get the request path only; this is assumed to already be normalized
$target = parse_url($req->getRequestTarget())['path'] ?? "";
+ $method = $req->getMethod();
// handle HTTP OPTIONS requests
- if ($req->getMethod() === "OPTIONS") {
+ if ($method === "OPTIONS") {
return $this->handleHTTPOptions($target);
}
+ $func = $this->chooseCall($target, $method);
+ if ($func === "opmlImport") {
+ if (!HTTP::matchType($req, "", ...[self::ACCEPTED_TYPES_OPML])) {
+ return new EmptyResponse(415, ['Accept' => implode(", ", self::ACCEPTED_TYPES_OPML)]);
+ }
+ $data = (string) $req->getBody();
+ } elseif ($method === "POST" || $method === "PUT") {
+ if (!HTTP::matchType($req, "", ...[self::ACCEPTED_TYPES_JSON])) {
+ return new EmptyResponse(415, ['Accept' => implode(", ", self::ACCEPTED_TYPES_JSON)]);
+ }
+ $data = @json_decode($data, true);
+ if (json_last_error() !== \JSON_ERROR_NONE) {
+ // if the body could not be parsed as JSON, return "400 Bad Request"
+ return new EmptyResponse(400);
+ }
+ } else {
+ $data = null;
+ }
+ try {
+ $path = explode("/", ltrim($target, "/"));
+ return $this->$func($path, $req->getQueryParams(), $data);
+ // @codeCoverageIgnoreStart
+ } catch (Exception $e) {
+ // if there was a REST exception return 400
+ return new EmptyResponse(400);
+ } catch (AbstractException $e) {
+ // if there was any other Arsse exception return 500
+ return new EmptyResponse(500);
+ }
+ // @codeCoverageIgnoreEnd
}
protected function normalizePathIds(string $url): string {
@@ -73,6 +107,10 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
$path[$a] = "1";
}
}
+ // handle special case "Get User By User Name", which can have any non-numeric string, non-empty as the last component
+ if (sizeof($path) === 3 && $path[0] === "" && $path[1] === "users" && !preg_match("/^(?:\d+)?$/", $path[2])) {
+ $path[2] = "*";
+ }
return implode("/", $path);
}
@@ -88,7 +126,7 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
}
return new EmptyResponse(204, [
'Allow' => implode(",", $allowed),
- 'Accept' => self::ACCEPTED_TYPE,
+ 'Accept' => implode(", ", $url === "/import" ? self::ACCEPTED_TYPES_OPML : self::ACCEPTED_TYPES_JSON),
]);
} else {
// if the path is not supported, return 404
@@ -106,8 +144,12 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
if (isset($this->paths[$url])) {
// if the path is supported, make sure the method is allowed
if (isset($this->paths[$url][$method])) {
- // if it is allowed, return the object method to run
- return $this->paths[$url][$method];
+ // if it is allowed, return the object method to run, assuming the method exists
+ if (method_exists($this, $this->paths[$url][$method])) {
+ return $this->paths[$url][$method];
+ } else {
+ throw new Exception501(); // @codeCoverageIgnore
+ }
} else {
// otherwise return 405
throw new Exception405(implode(", ", array_keys($this->paths[$url])));
diff --git a/lib/REST/NextcloudNews/V1_2.php b/lib/REST/NextcloudNews/V1_2.php
index c7389df8..4741e839 100644
--- a/lib/REST/NextcloudNews/V1_2.php
+++ b/lib/REST/NextcloudNews/V1_2.php
@@ -17,6 +17,7 @@ use JKingWeb\Arsse\Misc\HTTP;
use JKingWeb\Arsse\REST\Exception;
use JKingWeb\Arsse\REST\Exception404;
use JKingWeb\Arsse\REST\Exception405;
+use JKingWeb\Arsse\REST\Exception501;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\ResponseInterface;
use Laminas\Diactoros\Response\JsonResponse as Response;
@@ -109,20 +110,15 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler {
// merge GET and POST data, and normalize it. POST parameters are preferred over GET parameters
$data = $this->normalizeInput(array_merge($req->getQueryParams(), $data), $this->validInput, "unix");
// check to make sure the requested function is implemented
+ // dispatch
try {
$func = $this->chooseCall($target, $req->getMethod());
+ $path = explode("/", ltrim($target, "/"));
+ return $this->$func($path, $data);
} catch (Exception404 $e) {
return new EmptyResponse(404);
} catch (Exception405 $e) {
return new EmptyResponse(405, ['Allow' => $e->getMessage()]);
- }
- if (!method_exists($this, $func)) {
- return new EmptyResponse(501); // @codeCoverageIgnore
- }
- // dispatch
- try {
- $path = explode("/", ltrim($target, "/"));
- return $this->$func($path, $data);
// @codeCoverageIgnoreStart
} catch (Exception $e) {
// if there was a REST exception return 400
@@ -155,8 +151,12 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler {
if (isset($this->paths[$url])) {
// if the path is supported, make sure the method is allowed
if (isset($this->paths[$url][$method])) {
- // if it is allowed, return the object method to run
- return $this->paths[$url][$method];
+ // if it is allowed, return the object method to run, assuming the method exists
+ if (method_exists($this, $this->paths[$url][$method])) {
+ return $this->paths[$url][$method];
+ } else {
+ throw new Exception501(); // @codeCoverageIgnore
+ }
} else {
// otherwise return 405
throw new Exception405(implode(", ", array_keys($this->paths[$url])));
From c21ae3eca990269d41f1d5e6117e7a32dcc32cf7 Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Mon, 2 Nov 2020 15:21:04 -0500
Subject: [PATCH 008/366] Correctly send binary data to PostgreSQL
This finally brings PostgreSQL to parity with SQLite and MySQL.
Two tests casting binary data to text were removed since behaviour here
should in fact be undefined
Accountinf for any encoding when retrieving data will be addressed by
a later commit
---
lib/Db/PostgreSQL/Statement.php | 3 +++
tests/cases/Db/BaseStatement.php | 7 -------
tests/cases/Db/PostgreSQL/TestStatement.php | 5 +++++
tests/cases/Db/PostgreSQLPDO/TestStatement.php | 5 +++++
4 files changed, 13 insertions(+), 7 deletions(-)
diff --git a/lib/Db/PostgreSQL/Statement.php b/lib/Db/PostgreSQL/Statement.php
index 8c89053d..4472e8e5 100644
--- a/lib/Db/PostgreSQL/Statement.php
+++ b/lib/Db/PostgreSQL/Statement.php
@@ -44,6 +44,9 @@ class Statement extends \JKingWeb\Arsse\Db\AbstractStatement {
}
protected function bindValue($value, int $type, int $position): bool {
+ if ($value !== null && ($this->types[$position - 1] % self::T_NOT_NULL) === self::T_BINARY) {
+ $value = "\\x".bin2hex($value);
+ }
$this->in[] = $value;
return true;
}
diff --git a/tests/cases/Db/BaseStatement.php b/tests/cases/Db/BaseStatement.php
index 206aed79..ba862693 100644
--- a/tests/cases/Db/BaseStatement.php
+++ b/tests/cases/Db/BaseStatement.php
@@ -57,7 +57,6 @@ abstract class BaseStatement extends \JKingWeb\Arsse\Test\AbstractTest {
} else {
$query = "SELECT ($exp = ?) as pass";
}
- $typeStr = "'".str_replace("'", "''", $type)."'";
$s = new $this->statementClass(...$this->makeStatement($query));
$s->retype(...[$type]);
$act = $s->run(...[$value])->getValue();
@@ -66,15 +65,11 @@ abstract class BaseStatement extends \JKingWeb\Arsse\Test\AbstractTest {
/** @dataProvider provideBinaryBindings */
public function testHandleBinaryData($value, string $type, string $exp): void {
- if (in_array(static::$implementation, ["PostgreSQL", "PDO PostgreSQL"])) {
- $this->markTestIncomplete("Correct handling of binary data with PostgreSQL is not currently implemented");
- }
if ($exp === "null") {
$query = "SELECT (? is null) as pass";
} else {
$query = "SELECT ($exp = ?) as pass";
}
- $typeStr = "'".str_replace("'", "''", $type)."'";
$s = new $this->statementClass(...$this->makeStatement($query));
$s->retype(...[$type]);
$act = $s->run(...[$value])->getValue();
@@ -297,13 +292,11 @@ abstract class BaseStatement extends \JKingWeb\Arsse\Test\AbstractTest {
'UTF-8 string as strict binary' => ["\u{e9}", "strict binary", "x'c3a9'"],
'Binary string as integer' => [chr(233).chr(233), "integer", "0"],
'Binary string as float' => [chr(233).chr(233), "float", "0.0"],
- 'Binary string as string' => [chr(233).chr(233), "string", "'".chr(233).chr(233)."'"],
'Binary string as binary' => [chr(233).chr(233), "binary", "x'e9e9'"],
'Binary string as datetime' => [chr(233).chr(233), "datetime", "null"],
'Binary string as boolean' => [chr(233).chr(233), "boolean", "1"],
'Binary string as strict integer' => [chr(233).chr(233), "strict integer", "0"],
'Binary string as strict float' => [chr(233).chr(233), "strict float", "0.0"],
- 'Binary string as strict string' => [chr(233).chr(233), "strict string", "'".chr(233).chr(233)."'"],
'Binary string as strict binary' => [chr(233).chr(233), "strict binary", "x'e9e9'"],
'Binary string as strict datetime' => [chr(233).chr(233), "strict datetime", "'0001-01-01 00:00:00'"],
'Binary string as strict boolean' => [chr(233).chr(233), "strict boolean", "1"],
diff --git a/tests/cases/Db/PostgreSQL/TestStatement.php b/tests/cases/Db/PostgreSQL/TestStatement.php
index 7b44ec1c..a7f776af 100644
--- a/tests/cases/Db/PostgreSQL/TestStatement.php
+++ b/tests/cases/Db/PostgreSQL/TestStatement.php
@@ -27,6 +27,11 @@ class TestStatement extends \JKingWeb\Arsse\TestCase\Db\BaseStatement {
return "U&'\\+".str_pad(dechex((int) $match[1]), 6, "0", \STR_PAD_LEFT)."'";
}
return $value;
+ case "binary":
+ if ($value[0] === "x") {
+ return "'\\x".substr($value, 2)."::bytea";
+ }
+ // no break;
default:
return $value;
}
diff --git a/tests/cases/Db/PostgreSQLPDO/TestStatement.php b/tests/cases/Db/PostgreSQLPDO/TestStatement.php
index 926df768..8878d421 100644
--- a/tests/cases/Db/PostgreSQLPDO/TestStatement.php
+++ b/tests/cases/Db/PostgreSQLPDO/TestStatement.php
@@ -27,6 +27,11 @@ class TestStatement extends \JKingWeb\Arsse\TestCase\Db\BaseStatement {
return "U&'\\+".str_pad(dechex((int) $match[1]), 6, "0", \STR_PAD_LEFT)."'";
}
return $value;
+ case "binary":
+ if ($value[0] === "x") {
+ return "'\\x".substr($value, 2)."::bytea";
+ }
+ // no break;
default:
return $value;
}
From 41bcffd6fb530c1f104658be12d0cdd6603f2e8f Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Tue, 3 Nov 2020 17:52:20 -0500
Subject: [PATCH 009/366] Correctly query PostgreSQL byte arrays
This required different workarouynd for the native and PDO interfaces
---
lib/Db/PostgreSQL/PDOResult.php | 26 +++++++++++++++++++++
lib/Db/PostgreSQL/PDOStatement.php | 14 +++++++++++
lib/Db/PostgreSQL/Result.php | 14 ++++++++++-
tests/cases/Db/BaseResult.php | 13 +++++++++++
tests/cases/Db/PostgreSQL/TestResult.php | 13 +++++++++++
tests/cases/Db/PostgreSQLPDO/TestResult.php | 15 +++++++++++-
tests/lib/DatabaseDrivers/PostgreSQLPDO.php | 2 +-
7 files changed, 94 insertions(+), 3 deletions(-)
create mode 100644 lib/Db/PostgreSQL/PDOResult.php
diff --git a/lib/Db/PostgreSQL/PDOResult.php b/lib/Db/PostgreSQL/PDOResult.php
new file mode 100644
index 00000000..91fe4c09
--- /dev/null
+++ b/lib/Db/PostgreSQL/PDOResult.php
@@ -0,0 +1,26 @@
+cur = $this->set->fetch(\PDO::FETCH_ASSOC);
+ if ($this->cur !== false) {
+ foreach($this->cur as $k => $v) {
+ if (is_resource($v)) {
+ $this->cur[$k] = stream_get_contents($v);
+ fclose($v);
+ }
+ }
+ return true;
+ }
+ return false;
+ }
+}
diff --git a/lib/Db/PostgreSQL/PDOStatement.php b/lib/Db/PostgreSQL/PDOStatement.php
index c9b7b826..9929579a 100644
--- a/lib/Db/PostgreSQL/PDOStatement.php
+++ b/lib/Db/PostgreSQL/PDOStatement.php
@@ -6,6 +6,8 @@
declare(strict_types=1);
namespace JKingWeb\Arsse\Db\PostgreSQL;
+use JKingWeb\Arsse\Db\Result;
+
class PDOStatement extends \JKingWeb\Arsse\Db\PDOStatement {
public static function mungeQuery(string $query, array $types, ...$extraData): string {
return Statement::mungeQuery($query, $types, false);
@@ -16,4 +18,16 @@ class PDOStatement extends \JKingWeb\Arsse\Db\PDOStatement {
// PostgreSQL uses SQLSTATE exclusively, so this is not used
return [];
}
+
+ public function runArray(array $values = []): Result {
+ $this->st->closeCursor();
+ $this->bindValues($values);
+ try {
+ $this->st->execute();
+ } catch (\PDOException $e) {
+ [$excClass, $excMsg, $excData] = $this->buildPDOException(true);
+ throw new $excClass($excMsg, $excData);
+ }
+ return new PDOResult($this->db, $this->st);
+ }
}
diff --git a/lib/Db/PostgreSQL/Result.php b/lib/Db/PostgreSQL/Result.php
index 03dba17f..67a7352d 100644
--- a/lib/Db/PostgreSQL/Result.php
+++ b/lib/Db/PostgreSQL/Result.php
@@ -10,6 +10,7 @@ class Result extends \JKingWeb\Arsse\Db\AbstractResult {
protected $db;
protected $r;
protected $cur;
+ protected $blobs = [];
// actual public methods
@@ -30,6 +31,11 @@ class Result extends \JKingWeb\Arsse\Db\AbstractResult {
public function __construct($db, $result) {
$this->db = $db;
$this->r = $result;
+ for ($a = 0, $stop = pg_num_fields($result); $a < $stop; $a++) {
+ if (pg_field_type($result, $a) === "bytea") {
+ $this->blobs[$a] = pg_field_name($result, $a);
+ }
+ }
}
public function __destruct() {
@@ -41,6 +47,12 @@ class Result extends \JKingWeb\Arsse\Db\AbstractResult {
public function valid() {
$this->cur = pg_fetch_row($this->r, null, \PGSQL_ASSOC);
- return $this->cur !== false;
+ if ($this->cur !== false) {
+ foreach($this->blobs as $f) {
+ $this->cur[$f] = hex2bin(substr($this->cur[$f], 2));
+ }
+ return true;
+ }
+ return false;
}
}
diff --git a/tests/cases/Db/BaseResult.php b/tests/cases/Db/BaseResult.php
index 4d3d2c49..7d63af2e 100644
--- a/tests/cases/Db/BaseResult.php
+++ b/tests/cases/Db/BaseResult.php
@@ -10,6 +10,7 @@ use JKingWeb\Arsse\Db\Result;
abstract class BaseResult extends \JKingWeb\Arsse\Test\AbstractTest {
protected static $insertDefault = "INSERT INTO arsse_test default values";
+ protected static $selectBlob = "SELECT x'DEADBEEF' as \"blob\"";
protected static $interface;
protected $resultClass;
@@ -129,4 +130,16 @@ abstract class BaseResult extends \JKingWeb\Arsse\Test\AbstractTest {
$test = new $this->resultClass(...$this->makeResult("SELECT '2112' as album, '2112' as track union select 'Clockwork Angels' as album, 'The Wreckers' as track"));
$this->assertEquals($exp, $test->getAll());
}
+
+ public function testGetBlobRow(): void {
+ $exp = ['blob' => hex2bin("DEADBEEF")];
+ $test = new $this->resultClass(...$this->makeResult(self::$selectBlob));
+ $this->assertEquals($exp, $test->getRow());
+ }
+
+ public function testGetBlobValue(): void {
+ $exp = hex2bin("DEADBEEF");
+ $test = new $this->resultClass(...$this->makeResult(self::$selectBlob));
+ $this->assertEquals($exp, $test->getValue());
+ }
}
diff --git a/tests/cases/Db/PostgreSQL/TestResult.php b/tests/cases/Db/PostgreSQL/TestResult.php
index 0992962b..73dd8fb6 100644
--- a/tests/cases/Db/PostgreSQL/TestResult.php
+++ b/tests/cases/Db/PostgreSQL/TestResult.php
@@ -15,6 +15,7 @@ class TestResult extends \JKingWeb\Arsse\TestCase\Db\BaseResult {
protected static $createMeta = "CREATE TABLE arsse_meta(key text primary key not null, value text)";
protected static $createTest = "CREATE TABLE arsse_test(id bigserial primary key)";
+ protected static $selectBlob = "SELECT '\\xDEADBEEF'::bytea as blob";
protected function makeResult(string $q): array {
$set = pg_query(static::$interface, $q);
@@ -29,4 +30,16 @@ class TestResult extends \JKingWeb\Arsse\TestCase\Db\BaseResult {
}
parent::tearDownAfterClass();
}
+
+ public function testGetBlobRow(): void {
+ $exp = ['blob' => hex2bin("DEADBEEF")];
+ $test = new $this->resultClass(...$this->makeResult(self::$selectBlob));
+ $this->assertEquals($exp, $test->getRow());
+ }
+
+ public function testGetBlobValue(): void {
+ $exp = hex2bin("DEADBEEF");
+ $test = new $this->resultClass(...$this->makeResult(self::$selectBlob));
+ $this->assertEquals($exp, $test->getValue());
+ }
}
diff --git a/tests/cases/Db/PostgreSQLPDO/TestResult.php b/tests/cases/Db/PostgreSQLPDO/TestResult.php
index aaf6bca2..c810b715 100644
--- a/tests/cases/Db/PostgreSQLPDO/TestResult.php
+++ b/tests/cases/Db/PostgreSQLPDO/TestResult.php
@@ -8,16 +8,29 @@ namespace JKingWeb\Arsse\TestCase\Db\PostgreSQLPDO;
/**
* @group slow
- * @covers \JKingWeb\Arsse\Db\PDOResult
+ * @covers \JKingWeb\Arsse\Db\PostgreSQL\PDOResult
*/
class TestResult extends \JKingWeb\Arsse\TestCase\Db\BaseResult {
use \JKingWeb\Arsse\Test\DatabaseDrivers\PostgreSQLPDO;
protected static $createMeta = "CREATE TABLE arsse_meta(key text primary key not null, value text)";
protected static $createTest = "CREATE TABLE arsse_test(id bigserial primary key)";
+ protected static $selectBlob = "SELECT '\\xDEADBEEF'::bytea as blob";
protected function makeResult(string $q): array {
$set = static::$interface->query($q);
return [static::$interface, $set];
}
+
+ public function testGetBlobRow(): void {
+ $exp = ['blob' => hex2bin("DEADBEEF")];
+ $test = new $this->resultClass(...$this->makeResult(self::$selectBlob));
+ $this->assertEquals($exp, $test->getRow());
+ }
+
+ public function testGetBlobValue(): void {
+ $exp = hex2bin("DEADBEEF");
+ $test = new $this->resultClass(...$this->makeResult(self::$selectBlob));
+ $this->assertSame($exp, $test->getValue());
+ }
}
diff --git a/tests/lib/DatabaseDrivers/PostgreSQLPDO.php b/tests/lib/DatabaseDrivers/PostgreSQLPDO.php
index 58001b6f..116c3b23 100644
--- a/tests/lib/DatabaseDrivers/PostgreSQLPDO.php
+++ b/tests/lib/DatabaseDrivers/PostgreSQLPDO.php
@@ -11,7 +11,7 @@ use JKingWeb\Arsse\Arsse;
trait PostgreSQLPDO {
protected static $implementation = "PDO PostgreSQL";
protected static $backend = "PostgreSQL";
- protected static $dbResultClass = \JKingWeb\Arsse\Db\PDOResult::class;
+ protected static $dbResultClass = \JKingWeb\Arsse\Db\PostgreSQL\PDOResult::class;
protected static $dbStatementClass = \JKingWeb\Arsse\Db\PostgreSQL\PDOStatement::class;
protected static $dbDriverClass = \JKingWeb\Arsse\Db\PostgreSQL\PDODriver::class;
protected static $stringOutput = false;
From b5f959aabfc426cfbadf40d8fafedccc42ae9ef7 Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Tue, 3 Nov 2020 18:57:26 -0500
Subject: [PATCH 010/366] Fix blob tests
---
tests/cases/Db/BaseResult.php | 4 ++--
tests/cases/Db/PostgreSQL/TestResult.php | 12 ------------
tests/cases/Db/PostgreSQLPDO/TestResult.php | 12 ------------
3 files changed, 2 insertions(+), 26 deletions(-)
diff --git a/tests/cases/Db/BaseResult.php b/tests/cases/Db/BaseResult.php
index 7d63af2e..a43956da 100644
--- a/tests/cases/Db/BaseResult.php
+++ b/tests/cases/Db/BaseResult.php
@@ -133,13 +133,13 @@ abstract class BaseResult extends \JKingWeb\Arsse\Test\AbstractTest {
public function testGetBlobRow(): void {
$exp = ['blob' => hex2bin("DEADBEEF")];
- $test = new $this->resultClass(...$this->makeResult(self::$selectBlob));
+ $test = new $this->resultClass(...$this->makeResult(static::$selectBlob));
$this->assertEquals($exp, $test->getRow());
}
public function testGetBlobValue(): void {
$exp = hex2bin("DEADBEEF");
- $test = new $this->resultClass(...$this->makeResult(self::$selectBlob));
+ $test = new $this->resultClass(...$this->makeResult(static::$selectBlob));
$this->assertEquals($exp, $test->getValue());
}
}
diff --git a/tests/cases/Db/PostgreSQL/TestResult.php b/tests/cases/Db/PostgreSQL/TestResult.php
index 73dd8fb6..658228e0 100644
--- a/tests/cases/Db/PostgreSQL/TestResult.php
+++ b/tests/cases/Db/PostgreSQL/TestResult.php
@@ -30,16 +30,4 @@ class TestResult extends \JKingWeb\Arsse\TestCase\Db\BaseResult {
}
parent::tearDownAfterClass();
}
-
- public function testGetBlobRow(): void {
- $exp = ['blob' => hex2bin("DEADBEEF")];
- $test = new $this->resultClass(...$this->makeResult(self::$selectBlob));
- $this->assertEquals($exp, $test->getRow());
- }
-
- public function testGetBlobValue(): void {
- $exp = hex2bin("DEADBEEF");
- $test = new $this->resultClass(...$this->makeResult(self::$selectBlob));
- $this->assertEquals($exp, $test->getValue());
- }
}
diff --git a/tests/cases/Db/PostgreSQLPDO/TestResult.php b/tests/cases/Db/PostgreSQLPDO/TestResult.php
index c810b715..caddba71 100644
--- a/tests/cases/Db/PostgreSQLPDO/TestResult.php
+++ b/tests/cases/Db/PostgreSQLPDO/TestResult.php
@@ -21,16 +21,4 @@ class TestResult extends \JKingWeb\Arsse\TestCase\Db\BaseResult {
$set = static::$interface->query($q);
return [static::$interface, $set];
}
-
- public function testGetBlobRow(): void {
- $exp = ['blob' => hex2bin("DEADBEEF")];
- $test = new $this->resultClass(...$this->makeResult(self::$selectBlob));
- $this->assertEquals($exp, $test->getRow());
- }
-
- public function testGetBlobValue(): void {
- $exp = hex2bin("DEADBEEF");
- $test = new $this->resultClass(...$this->makeResult(self::$selectBlob));
- $this->assertSame($exp, $test->getValue());
- }
}
From 2438f35f3dc34608e09ebedd2ffe31e72972f0b7 Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Wed, 4 Nov 2020 18:34:22 -0500
Subject: [PATCH 011/366] Add icon cache to database
Feed updating has not yet been adapted to store
icon data (nor their URLs anymore)
---
lib/Database.php | 44 ++++++++--------
sql/MySQL/6.sql | 18 +++++++
sql/PostgreSQL/6.sql | 17 ++++++
sql/SQLite3/6.sql | 58 +++++++++++++++++++++
tests/cases/Database/SeriesSubscription.php | 17 ++++--
tests/cases/Db/BaseUpdate.php | 26 +++++++--
6 files changed, 151 insertions(+), 29 deletions(-)
diff --git a/lib/Database.php b/lib/Database.php
index b7b44881..0df46df7 100644
--- a/lib/Database.php
+++ b/lib/Database.php
@@ -731,30 +731,32 @@ class Database {
// create a complex query
$q = new Query(
"SELECT
- arsse_subscriptions.id as id,
- arsse_subscriptions.feed as feed,
- url,favicon,source,folder,pinned,err_count,err_msg,order_type,added,
- arsse_feeds.updated as updated,
- arsse_feeds.modified as edited,
- arsse_subscriptions.modified as modified,
- topmost.top as top_folder,
- coalesce(arsse_subscriptions.title, arsse_feeds.title) as title,
+ s.id as id,
+ s.feed as feed,
+ f.url,source,folder,pinned,err_count,err_msg,order_type,added,
+ f.updated as updated,
+ f.modified as edited,
+ s.modified as modified,
+ i.url as favicon,
+ t.top as top_folder,
+ coalesce(s.title, f.title) as title,
(articles - marked) as unread
- FROM arsse_subscriptions
- left join topmost on topmost.f_id = arsse_subscriptions.folder
- join arsse_feeds on arsse_feeds.id = arsse_subscriptions.feed
- left join (select feed, count(*) as articles from arsse_articles group by feed) as article_stats on article_stats.feed = arsse_subscriptions.feed
- left join (select subscription, sum(\"read\") as marked from arsse_marks group by subscription) as mark_stats on mark_stats.subscription = arsse_subscriptions.id"
+ FROM arsse_subscriptions as s
+ left join topmost as t on t.f_id = s.folder
+ join arsse_feeds as f on f.id = s.feed
+ left join arsse_icons as i on i.id = f.icon
+ left join (select feed, count(*) as articles from arsse_articles group by feed) as article_stats on article_stats.feed = s.feed
+ left join (select subscription, sum(\"read\") as marked from arsse_marks group by subscription) as mark_stats on mark_stats.subscription = s.id"
);
- $q->setWhere("arsse_subscriptions.owner = ?", ["str"], [$user]);
+ $q->setWhere("s.owner = ?", ["str"], [$user]);
$nocase = $this->db->sqlToken("nocase");
- $q->setOrder("pinned desc, coalesce(arsse_subscriptions.title, arsse_feeds.title) collate $nocase");
+ $q->setOrder("pinned desc, coalesce(s.title, f.title) collate $nocase");
// topmost folders belonging to the user
$q->setCTE("topmost(f_id,top)", "SELECT id,id from arsse_folders where owner = ? and parent is null union all select id,top from arsse_folders join topmost on parent=f_id", ["str"], [$user]);
if ($id) {
// this condition facilitates the implementation of subscriptionPropertiesGet, which would otherwise have to duplicate the complex query; it takes precedence over a specified folder
// if an ID is specified, add a suitable WHERE condition and bindings
- $q->setWhere("arsse_subscriptions.id = ?", "int", $id);
+ $q->setWhere("s.id = ?", "int", $id);
} elseif ($folder && $recursive) {
// if a folder is specified and we're listing recursively, add a common table expression to list it and its children so that we select from the entire subtree
$q->setCTE("folders(folder)", "SELECT ? union all select id from arsse_folders join folders on parent = folder", "int", $folder);
@@ -921,13 +923,13 @@ class Database {
* @param string|null $user The user who owns the subscription being queried
*/
public function subscriptionFavicon(int $id, string $user = null): string {
- $q = new Query("SELECT favicon from arsse_feeds join arsse_subscriptions on feed = arsse_feeds.id");
- $q->setWhere("arsse_subscriptions.id = ?", "int", $id);
+ $q = new Query("SELECT i.url as favicon from arsse_feeds as f left join arsse_icons as i on i.id = f.icon join arsse_subscriptions as s on s.feed = f.id");
+ $q->setWhere("s.id = ?", "int", $id);
if (isset($user)) {
if (!Arsse::$user->authorize($user, __FUNCTION__)) {
throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
}
- $q->setWhere("arsse_subscriptions.owner = ?", "str", $user);
+ $q->setWhere("s.owner = ?", "str", $user);
}
return (string) $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues())->getValue();
}
@@ -1140,8 +1142,7 @@ class Database {
}
// lastly update the feed database itself with updated information.
$this->db->prepare(
- "UPDATE arsse_feeds SET title = ?, favicon = ?, source = ?, updated = CURRENT_TIMESTAMP, modified = ?, etag = ?, err_count = 0, err_msg = '', next_fetch = ?, size = ? WHERE id = ?",
- 'str',
+ "UPDATE arsse_feeds SET title = ?, source = ?, updated = CURRENT_TIMESTAMP, modified = ?, etag = ?, err_count = 0, err_msg = '', next_fetch = ?, size = ? WHERE id = ?",
'str',
'str',
'datetime',
@@ -1151,7 +1152,6 @@ class Database {
'int'
)->run(
$feed->data->title,
- $feed->favicon,
$feed->data->siteUrl,
$feed->lastModified,
$feed->resource->getEtag(),
diff --git a/sql/MySQL/6.sql b/sql/MySQL/6.sql
index 248a0141..281467ec 100644
--- a/sql/MySQL/6.sql
+++ b/sql/MySQL/6.sql
@@ -2,6 +2,8 @@
-- Copyright 2017 J. King, Dustin Wilson et al.
-- See LICENSE and AUTHORS files for details
+-- Please consult the SQLite 3 schemata for commented version
+
alter table arsse_users add column num bigint unsigned unique;
alter table arsse_users add column admin boolean not null default 0;
alter table arsse_users add column lang longtext;
@@ -18,4 +20,20 @@ where u.id = n.id;
drop table arsse_users_existing;
alter table arsse_users modify num bigint unsigned not null;
+create table arsse_icons(
+ id serial primary key,
+ url varchar(767) unique not null,
+ modified datetime(0),
+ etag varchar(255) not null default '',
+ next_fetch datetime(0),
+ orphaned datetime(0),
+ type text,
+ data longblob
+) character set utf8mb4 collate utf8mb4_unicode_ci;
+insert into arsse_icons(url) select distinct favicon from arsse_feeds where favicon is not null;
+alter table arsse_feeds add column icon bigint unsigned;
+alter table arsse_feeds add constraint foreign key (icon) references arsse_icons(id) on delete set null;
+update arsse_feeds as f, arsse_icons as i set f.icon = i.id where f.favicon = i.url;
+alter table arsse_feeds drop column favicon;
+
update arsse_meta set value = '7' where "key" = 'schema_version';
diff --git a/sql/PostgreSQL/6.sql b/sql/PostgreSQL/6.sql
index bc365704..6c128a03 100644
--- a/sql/PostgreSQL/6.sql
+++ b/sql/PostgreSQL/6.sql
@@ -2,6 +2,8 @@
-- Copyright 2017 J. King, Dustin Wilson et al.
-- See LICENSE and AUTHORS files for details
+-- Please consult the SQLite 3 schemata for commented version
+
alter table arsse_users add column num bigint unique;
alter table arsse_users add column admin smallint not null default 0;
alter table arsse_users add column lang text;
@@ -19,4 +21,19 @@ where u.id = e.id;
drop table arsse_users_existing;
alter table arsse_users alter column num set not null;
+create table arsse_icons(
+ id bigserial primary key,
+ url text unique not null,
+ modified timestamp(0) without time zone,
+ etag text not null default '',
+ next_fetch timestamp(0) without time zone,
+ orphaned timestamp(0) without time zone,
+ type text,
+ data bytea
+);
+insert into arsse_icons(url) select distinct favicon from arsse_feeds where favicon is not null;
+alter table arsse_feeds add column icon bigint references arsse_icons(id) on delete set null;
+update arsse_feeds as f set icon = i.id from arsse_icons as i where f.favicon = i.url;
+alter table arsse_feeds drop column favicon;
+
update arsse_meta set value = '7' where "key" = 'schema_version';
diff --git a/sql/SQLite3/6.sql b/sql/SQLite3/6.sql
index 142fada0..ab82afcf 100644
--- a/sql/SQLite3/6.sql
+++ b/sql/SQLite3/6.sql
@@ -27,6 +27,64 @@ drop table arsse_users;
drop table arsse_users_existing;
alter table arsse_users_new rename to arsse_users;
+-- Add a separate table for feed icons and replace their URLs in the feeds table with their IDs
+create table arsse_icons(
+ -- Icons associated with feeds
+ -- At a minimum the URL of the icon must be known, but its content may be missing
+ id integer primary key, -- the identifier for the icon
+ url text unique not null, -- the URL of the icon
+ modified text, -- Last-Modified date, for caching
+ etag text not null default '', -- ETag, for caching
+ next_fetch text, -- The date at which cached data should be considered stale
+ orphaned text, -- time at which the icon last had no feeds associated with it
+ type text, -- the Content-Type of the icon, if known
+ data blob -- the binary data of the icon itself
+);
+insert into arsse_icons(url) select distinct favicon from arsse_feeds where favicon is not null;
+create table arsse_feeds_new(
+-- newsfeeds, deduplicated
+-- users have subscriptions to these feeds in another table
+ id integer primary key, -- sequence number
+ url text not null, -- URL of feed
+ title text collate nocase, -- default title of feed (users can set the title of their subscription to the feed)
+ source text, -- URL of site to which the feed belongs
+ updated text, -- time at which the feed was last fetched
+ modified text, -- time at which the feed last actually changed
+ next_fetch text, -- time at which the feed should next be fetched
+ orphaned text, -- time at which the feed last had no subscriptions
+ etag text not null default '', -- HTTP ETag hash used for cache validation, changes each time the content changes
+ err_count integer not null default 0, -- count of successive times update resulted in error since last successful update
+ err_msg text, -- last error message
+ username text not null default '', -- HTTP authentication username
+ password text not null default '', -- HTTP authentication password (this is stored in plain text)
+ size integer not null default 0, -- number of articles in the feed at last fetch
+ scrape boolean not null default 0, -- whether to use picoFeed's content scraper with this feed
+ icon integer references arsse_icons(id) on delete set null, -- numeric identifier of any associated icon
+ unique(url,username,password) -- a URL with particular credentials should only appear once
+);
+insert into arsse_feeds_new
+ select f.id, f.url, title, source, updated, f.modified, f.next_fetch, f.orphaned, f.etag, err_count, err_msg, username, password, size, scrape, i.id
+ from arsse_feeds as f left join arsse_icons as i on f.favicon = i.url;
+drop table arsse_feeds;
+alter table arsse_feeds_new rename to arsse_feeds;
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-- set version marker
pragma user_version = 7;
update arsse_meta set value = '7' where "key" = 'schema_version';
diff --git a/tests/cases/Database/SeriesSubscription.php b/tests/cases/Database/SeriesSubscription.php
index c0a88f4a..427a9843 100644
--- a/tests/cases/Database/SeriesSubscription.php
+++ b/tests/cases/Database/SeriesSubscription.php
@@ -41,6 +41,15 @@ trait SeriesSubscription {
[6, "john.doe@example.com", 2, "Politics"],
],
],
+ 'arsse_icons' => [
+ 'columns' => [
+ 'id' => "int",
+ 'url' => "str",
+ ],
+ 'rows' => [
+ [1,"http://example.com/favicon.ico"],
+ ],
+ ],
'arsse_feeds' => [
'columns' => [
'id' => "int",
@@ -50,7 +59,7 @@ trait SeriesSubscription {
'password' => "str",
'updated' => "datetime",
'next_fetch' => "datetime",
- 'favicon' => "str",
+ 'icon' => "int",
],
'rows' => [], // filled in the series setup
],
@@ -136,9 +145,9 @@ trait SeriesSubscription {
],
];
$this->data['arsse_feeds']['rows'] = [
- [1,"http://example.com/feed1", "Ook", "", "",strtotime("now"),strtotime("now"),''],
- [2,"http://example.com/feed2", "eek", "", "",strtotime("now - 1 hour"),strtotime("now - 1 hour"),'http://example.com/favicon.ico'],
- [3,"http://example.com/feed3", "Ack", "", "",strtotime("now + 1 hour"),strtotime("now + 1 hour"),''],
+ [1,"http://example.com/feed1", "Ook", "", "",strtotime("now"),strtotime("now"),null],
+ [2,"http://example.com/feed2", "eek", "", "",strtotime("now - 1 hour"),strtotime("now - 1 hour"),1],
+ [3,"http://example.com/feed3", "Ack", "", "",strtotime("now + 1 hour"),strtotime("now + 1 hour"),null],
];
// initialize a partial mock of the Database object to later manipulate the feedUpdate method
Arsse::$db = \Phake::partialMock(Database::class, static::$drv);
diff --git a/tests/cases/Db/BaseUpdate.php b/tests/cases/Db/BaseUpdate.php
index b25e4723..ba93687e 100644
--- a/tests/cases/Db/BaseUpdate.php
+++ b/tests/cases/Db/BaseUpdate.php
@@ -142,14 +142,34 @@ class BaseUpdate extends \JKingWeb\Arsse\Test\AbstractTest {
INSERT INTO arsse_users values('b', 'abc');
INSERT INTO arsse_folders(owner,name) values('a', '1');
INSERT INTO arsse_folders(owner,name) values('b', '2');
+ INSERT INTO arsse_feeds(url,favicon) values('http://example.com/', 'http://example.com/icon');
+ INSERT INTO arsse_feeds(url,favicon) values('http://example.org/', 'http://example.org/icon');
+ INSERT INTO arsse_feeds(url,favicon) values('https://example.com/', 'http://example.com/icon');
+ INSERT INTO arsse_feeds(url,favicon) values('http://example.net/', null);
QUERY_TEXT
);
$this->drv->schemaUpdate(7);
- $exp = [
+ $users = [
['id' => "a", 'password' => "xyz", 'num' => 1],
['id' => "b", 'password' => "abc", 'num' => 2],
];
- $this->assertEquals($exp, $this->drv->query("SELECT id, password, num from arsse_users")->getAll());
- $this->assertSame(2, (int) $this->drv->query("SELECT count(*) from arsse_folders")->getValue());
+ $folders = [
+ ['owner' => "a", 'name' => "1"],
+ ['owner' => "b", 'name' => "2"],
+ ];
+ $icons = [
+ ['id' => 1, 'url' => "http://example.com/icon"],
+ ['id' => 2, 'url' => "http://example.org/icon"],
+ ];
+ $feeds = [
+ ['url' => 'http://example.com/', 'icon' => 1],
+ ['url' => 'http://example.org/', 'icon' => 2],
+ ['url' => 'https://example.com/', 'icon' => 1],
+ ['url' => 'http://example.net/', 'icon' => null],
+ ];
+ $this->assertEquals($users, $this->drv->query("SELECT id, password, num from arsse_users order by id")->getAll());
+ $this->assertEquals($folders, $this->drv->query("SELECT owner, name from arsse_folders order by owner")->getAll());
+ $this->assertEquals($icons, $this->drv->query("SELECT id, url from arsse_icons order by id")->getAll());
+ $this->assertEquals($feeds, $this->drv->query("SELECT url, icon from arsse_feeds order by id")->getAll());
}
}
From af675479b854c9416376bbcc94341a5700b03bdd Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Wed, 4 Nov 2020 18:35:36 -0500
Subject: [PATCH 012/366] Remove excess whitespace
---
sql/SQLite3/6.sql | 17 -----------------
1 file changed, 17 deletions(-)
diff --git a/sql/SQLite3/6.sql b/sql/SQLite3/6.sql
index ab82afcf..513422d8 100644
--- a/sql/SQLite3/6.sql
+++ b/sql/SQLite3/6.sql
@@ -68,23 +68,6 @@ insert into arsse_feeds_new
drop table arsse_feeds;
alter table arsse_feeds_new rename to arsse_feeds;
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-- set version marker
pragma user_version = 7;
update arsse_meta set value = '7' where "key" = 'schema_version';
From c25782f98c2cf3cf370872a38dc74b12371b20d7 Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Wed, 4 Nov 2020 20:00:00 -0500
Subject: [PATCH 013/366] Partial icon handling skeleton
---
lib/Database.php | 18 +++++++++++++++++-
lib/Feed.php | 20 +++++++++++++++-----
2 files changed, 32 insertions(+), 6 deletions(-)
diff --git a/lib/Database.php b/lib/Database.php
index 0df46df7..734fbd4a 100644
--- a/lib/Database.php
+++ b/lib/Database.php
@@ -1226,7 +1226,7 @@ class Database {
[$cHashUC, $tHashUC, $vHashUC] = $this->generateIn($hashesUC, "str");
[$cHashTC, $tHashTC, $vHashTC] = $this->generateIn($hashesTC, "str");
// perform the query
- return $articles = $this->db->prepare(
+ return $this->db->prepare(
"SELECT id, edited, guid, url_title_hash, url_content_hash, title_content_hash FROM arsse_articles WHERE feed = ? and (guid in($cId) or url_title_hash in($cHashUT) or url_content_hash in($cHashUC) or title_content_hash in($cHashTC))",
'int',
$tId,
@@ -1236,6 +1236,22 @@ class Database {
)->run($feedID, $vId, $vHashUT, $vHashUC, $vHashTC);
}
+ protected function iconList(string $user, bool $withData = true): Db\Result {
+ $data = $withData ? "data" : "null as data";
+ $out = $this->db->prepare("SELECT id, url, type, $data, next_fetch from arsse_icons")->run()->getRow();
+ if (!$out) {}
+ return $out;
+ }
+
+ protected function iconGet($id, bool $withData = true, bool $byUrl = false): array {
+ $field = $byUrl ? "url" : "id";
+ $type = $byUrl ? "str" : "int";
+ $data = $withData ? "data" : "null as data";
+ $out = $this->db->prepare("SELECT id, url, type, $data, next_fetch from arsse_icons where $field = ?", $type)->run($id)->getRow();
+ if (!$out) {}
+ return $out;
+ }
+
/** Returns an associative array of result column names and their SQL computations for article queries
*
* This is used for whitelisting and defining both output column and order-by columns, as well as for resolution of some context options
diff --git a/lib/Feed.php b/lib/Feed.php
index e6a8ebcf..2dad3269 100644
--- a/lib/Feed.php
+++ b/lib/Feed.php
@@ -16,7 +16,9 @@ use PicoFeed\Scraper\Scraper;
class Feed {
public $data = null;
- public $favicon;
+ public $iconUrl;
+ public $iconType;
+ public $iconData;
public $resource;
public $modified = false;
public $lastModified;
@@ -113,16 +115,24 @@ class Feed {
$this->resource->getContent(),
$this->resource->getEncoding()
)->execute();
- // Grab the favicon for the feed; returns an empty string if it cannot find one.
- // Some feeds might use a different domain (eg: feedburner), so the site url is
- // used instead of the feed's url.
- $this->favicon = (new Favicon)->find($feed->siteUrl);
} catch (PicoFeedException $e) {
throw new Feed\Exception($this->resource->getUrl(), $e);
} catch (\GuzzleHttp\Exception\GuzzleException $e) { // @codeCoverageIgnore
throw new Feed\Exception($this->resource->getUrl(), $e); // @codeCoverageIgnore
}
+ // Grab the favicon for the feed, or null if no valid icon is found
+ // Some feeds might use a different domain (eg: feedburner), so the site url is
+ // used instead of the feed's url.
+ $icon = new Favicon;
+ $this->iconUrl = $icon->find($feed->siteUrl);
+ $this->iconData = $icon->getContent();
+ if (strlen($this->iconData)) {
+ $this->iconType = $icon->getType();
+ } else {
+ $this->iconUrl = $this->iconData = null;
+ }
+
// PicoFeed does not provide valid ids when there is no id element. Its solution
// of hashing the url, title, and content together for the id if there is no id
// element is stupid. Many feeds are frankenstein mixtures of Atom and RSS, but
From 7c40c81fb3d657508394104e79b97d95b0dd149c Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Thu, 5 Nov 2020 08:13:15 -0500
Subject: [PATCH 014/366] Add icons to the database upon feed update
---
lib/Database.php | 74 ++++++++++++++++++++++++++++++++++++++++--------
1 file changed, 62 insertions(+), 12 deletions(-)
diff --git a/lib/Database.php b/lib/Database.php
index 734fbd4a..ca1246df 100644
--- a/lib/Database.php
+++ b/lib/Database.php
@@ -1091,8 +1091,23 @@ class Database {
'int'
);
}
- // actually perform updates
+ // determine if the feed icon needs to be updated, and update it if appropriate
$tr = $this->db->begin();
+ $icon = null;
+ if ($feed->iconUrl) {
+ $icon = $this->iconGetByUrl($feed->iconUrl);
+ if ($icon) {
+ // update the existing icon if necessary
+ if ($feed->iconType !== $icon['type'] || $feed->iconData !== $icon['data']) {
+ $this->db->prepare("UPDATE arsse_icons set type = ?, data = ? where id = ?", "str", "blob", "int")->run($feed->iconType, $feed->iconData, $icon['id']);
+ }
+ $icon = $icon['id'];
+ } else {
+ // add the new icon to the cache
+ $icon = $this->db->prepare("INSERT INTO arsee_icons(url, type, data) values(?, ?, ?", "str", "str", "blob")->run($feed->iconUrl, $feed->iconType, $feed->iconData)->lastId();
+ }
+ }
+ // actually perform updates
foreach ($feed->newItems as $article) {
$articleID = $qInsertArticle->run(
$article->url,
@@ -1142,13 +1157,14 @@ class Database {
}
// lastly update the feed database itself with updated information.
$this->db->prepare(
- "UPDATE arsse_feeds SET title = ?, source = ?, updated = CURRENT_TIMESTAMP, modified = ?, etag = ?, err_count = 0, err_msg = '', next_fetch = ?, size = ? WHERE id = ?",
+ "UPDATE arsse_feeds SET title = ?, source = ?, updated = CURRENT_TIMESTAMP, modified = ?, etag = ?, err_count = 0, err_msg = '', next_fetch = ?, size = ?, icon = ? WHERE id = ?",
'str',
'str',
'datetime',
'strict str',
'datetime',
'int',
+ 'int',
'int'
)->run(
$feed->data->title,
@@ -1157,6 +1173,7 @@ class Database {
$feed->resource->getEtag(),
$feed->nextFetch,
sizeof($feed->data->items),
+ $icon,
$feedID
);
$tr->commit();
@@ -1236,20 +1253,53 @@ class Database {
)->run($feedID, $vId, $vHashUT, $vHashUC, $vHashTC);
}
- protected function iconList(string $user, bool $withData = true): Db\Result {
+ /** Retrieve a feed icon by URL, for use during feed refreshing
+ *
+ * @param string $url The URL of the icon to Retrieve
+ * @param bool $withData Whether to return the icon content along with the metadata
+ */
+ protected function iconGetByUrl(string $url, bool $withData = true): array {
$data = $withData ? "data" : "null as data";
- $out = $this->db->prepare("SELECT id, url, type, $data, next_fetch from arsse_icons")->run()->getRow();
- if (!$out) {}
+ return $this->db->prepare("SELECT id, url, type, $data, next_fetch from arsse_icons where url = ?", "str")->run($id)->getRow();
+ }
+
+
+ /** Returns information about an icon for a feed to which a user is subscribed, with or without the binary content of the icon itself
+ *
+ * The returned information is:
+ *
+ * - "id": The umeric identifier of the icon (not the subscription)
+ * - "url": The URL of the icon
+ * - "type": The Content-Type of the icon e.g. "image/png"
+ * - "data": The icon itself, as a binary sring; if $withData is false this will be null
+ *
+ * @param string $user The user whose subscription icon is to be retrieved
+ * @param int $subscription The numeric identifier of the subscription with which the icon is associated
+ * @param bool $withData Whether to retrireve the icon content in addition to its metadata
+ */
+ public function iconGet(string $user, int $subscrption, bool $withData = true): array {
+ if (!Arsse::$user->authorize($user, __FUNCTION__)) {
+ throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
+ }
+ $data = $withData ? "data" : "null as data";
+ $out = $this->db->prepare("SELECT i.id, i.url, i.type, $data from arsse_icons as i join arsse_feeds as f on i.id = f.icon join arsse_subscriptions as s on s.feed = f.id where s.owner = ? and s.id = ?", "str", "int")->run($user, $subscription)->getRow();
+ if (!$out) {
+ throw new Db\ExceptionInput("subjectMissing", ["action" => __FUNCTION__, "field" => "subscription", 'id' => $subscription]);
+ }
return $out;
}
- protected function iconGet($id, bool $withData = true, bool $byUrl = false): array {
- $field = $byUrl ? "url" : "id";
- $type = $byUrl ? "str" : "int";
- $data = $withData ? "data" : "null as data";
- $out = $this->db->prepare("SELECT id, url, type, $data, next_fetch from arsse_icons where $field = ?", $type)->run($id)->getRow();
- if (!$out) {}
- return $out;
+ /** Lists icons for feeds to which a user is subscribed, with or without the binary content of the icon itself
+ *
+ * @param string $user The user whose subscription icons are to be retrieved
+ * @param bool $withData Whether to retrireve the icon content in addition to its metadata
+ */
+ public function iconList(string $user, bool $withData = true): Db\Result {
+ if (!Arsse::$user->authorize($user, __FUNCTION__)) {
+ throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
+ }
+ $data = $withData ? "i.data" : "null as data";
+ return $this->db->prepare("SELECT i.id, i.url, i.type, $data from arsse_icons as i join arsse_feeds as f on i.id = f.icon join arsse_subscriptions as s on s.feed = f.id where s.owner = ?", "str")->run($user);
}
/** Returns an associative array of result column names and their SQL computations for article queries
From 50fd127ac4cac17dcc1a0daa01c0439404799ff9 Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Thu, 5 Nov 2020 10:14:42 -0500
Subject: [PATCH 015/366] Test for icon fetching
---
lib/Feed.php | 2 +-
tests/cases/Feed/TestFeed.php | 8 ++++++++
tests/docroot/Feed/Parsing/WithIcon.php | 8 ++++++++
tests/docroot/Icon.php | 4 ++++
4 files changed, 21 insertions(+), 1 deletion(-)
create mode 100644 tests/docroot/Feed/Parsing/WithIcon.php
create mode 100644 tests/docroot/Icon.php
diff --git a/lib/Feed.php b/lib/Feed.php
index 2dad3269..dffbccb9 100644
--- a/lib/Feed.php
+++ b/lib/Feed.php
@@ -125,7 +125,7 @@ class Feed {
// Some feeds might use a different domain (eg: feedburner), so the site url is
// used instead of the feed's url.
$icon = new Favicon;
- $this->iconUrl = $icon->find($feed->siteUrl);
+ $this->iconUrl = $icon->find($feed->siteUrl, $feed->getIcon());
$this->iconData = $icon->getContent();
if (strlen($this->iconData)) {
$this->iconType = $icon->getType();
diff --git a/tests/cases/Feed/TestFeed.php b/tests/cases/Feed/TestFeed.php
index 01f4f5f1..562d9063 100644
--- a/tests/cases/Feed/TestFeed.php
+++ b/tests/cases/Feed/TestFeed.php
@@ -347,4 +347,12 @@ class TestFeed extends \JKingWeb\Arsse\Test\AbstractTest {
$exp = "Partial content, followed by more content
";
$this->assertSame($exp, $f->newItems[0]->content);
}
+
+ public function testFetchWithIcon(): void {
+ $d = base64_decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAAZdEVYdFNvZnR3YXJlAHBhaW50Lm5ldCA0LjAuMjHxIGmVAAAADUlEQVQYV2NgYGBgAAAABQABijPjAAAAAABJRU5ErkJggg==");
+ $f = new Feed(null, $this->base."Parsing/WithIcon");
+ $this->assertSame(self::$host."Icon", $f->iconUrl);
+ $this->assertSame("image/png", $f->iconType);
+ $this->assertSame($d, $f->iconData);
+ }
}
diff --git a/tests/docroot/Feed/Parsing/WithIcon.php b/tests/docroot/Feed/Parsing/WithIcon.php
new file mode 100644
index 00000000..4f1f2776
--- /dev/null
+++ b/tests/docroot/Feed/Parsing/WithIcon.php
@@ -0,0 +1,8 @@
+ "application/atom+xml",
+ 'content' => <<
+ /Icon
+
+MESSAGE_BODY
+];
diff --git a/tests/docroot/Icon.php b/tests/docroot/Icon.php
new file mode 100644
index 00000000..f5c54d63
--- /dev/null
+++ b/tests/docroot/Icon.php
@@ -0,0 +1,4 @@
+ "image/png",
+ 'content' => base64_decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAAZdEVYdFNvZnR3YXJlAHBhaW50Lm5ldCA0LjAuMjHxIGmVAAAADUlEQVQYV2NgYGBgAAAABQABijPjAAAAAABJRU5ErkJggg=="),
+];
From bd650765e1fcf2eb8f26a5644ffcc62837d9f0d8 Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Thu, 5 Nov 2020 12:12:01 -0500
Subject: [PATCH 016/366] Generalize icon fetching tests
---
.gitignore | 1 -
tests/cases/Feed/TestFeed.php | 8 ++++----
.../Feed/{Parsing/WithIcon.php => WithIcon/GIF.php} | 2 +-
tests/docroot/Feed/WithIcon/PNG.php | 8 ++++++++
tests/docroot/Feed/WithIcon/SVG1.php | 8 ++++++++
tests/docroot/Feed/WithIcon/SVG2.php | 8 ++++++++
tests/docroot/Icon/GIF.php | 4 ++++
tests/docroot/{Icon.php => Icon/PNG.php} | 0
tests/docroot/Icon/SVG1.php | 4 ++++
tests/docroot/Icon/SVG2.php | 4 ++++
10 files changed, 41 insertions(+), 6 deletions(-)
rename tests/docroot/Feed/{Parsing/WithIcon.php => WithIcon/GIF.php} (85%)
create mode 100644 tests/docroot/Feed/WithIcon/PNG.php
create mode 100644 tests/docroot/Feed/WithIcon/SVG1.php
create mode 100644 tests/docroot/Feed/WithIcon/SVG2.php
create mode 100644 tests/docroot/Icon/GIF.php
rename tests/docroot/{Icon.php => Icon/PNG.php} (100%)
create mode 100644 tests/docroot/Icon/SVG1.php
create mode 100644 tests/docroot/Icon/SVG2.php
diff --git a/.gitignore b/.gitignore
index d90e245d..16e9c935 100644
--- a/.gitignore
+++ b/.gitignore
@@ -26,7 +26,6 @@ $RECYCLE.BIN/
.DS_Store
.AppleDouble
.LSOverride
-Icon
._*
.Spotlight-V100
.Trashes
diff --git a/tests/cases/Feed/TestFeed.php b/tests/cases/Feed/TestFeed.php
index 562d9063..a5036d28 100644
--- a/tests/cases/Feed/TestFeed.php
+++ b/tests/cases/Feed/TestFeed.php
@@ -349,10 +349,10 @@ class TestFeed extends \JKingWeb\Arsse\Test\AbstractTest {
}
public function testFetchWithIcon(): void {
- $d = base64_decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAAZdEVYdFNvZnR3YXJlAHBhaW50Lm5ldCA0LjAuMjHxIGmVAAAADUlEQVQYV2NgYGBgAAAABQABijPjAAAAAABJRU5ErkJggg==");
- $f = new Feed(null, $this->base."Parsing/WithIcon");
- $this->assertSame(self::$host."Icon", $f->iconUrl);
- $this->assertSame("image/png", $f->iconType);
+ $d = base64_decode("R0lGODlhAQABAIABAAAAAP///yH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==");
+ $f = new Feed(null, $this->base."WithIcon/GIF");
+ $this->assertSame(self::$host."Icon/GIF", $f->iconUrl);
+ $this->assertSame("image/gif", $f->iconType);
$this->assertSame($d, $f->iconData);
}
}
diff --git a/tests/docroot/Feed/Parsing/WithIcon.php b/tests/docroot/Feed/WithIcon/GIF.php
similarity index 85%
rename from tests/docroot/Feed/Parsing/WithIcon.php
rename to tests/docroot/Feed/WithIcon/GIF.php
index 4f1f2776..8b81b58c 100644
--- a/tests/docroot/Feed/Parsing/WithIcon.php
+++ b/tests/docroot/Feed/WithIcon/GIF.php
@@ -2,7 +2,7 @@
'mime' => "application/atom+xml",
'content' => <<
- /Icon
+ /Icon/GIF
MESSAGE_BODY
];
diff --git a/tests/docroot/Feed/WithIcon/PNG.php b/tests/docroot/Feed/WithIcon/PNG.php
new file mode 100644
index 00000000..3bca1c9a
--- /dev/null
+++ b/tests/docroot/Feed/WithIcon/PNG.php
@@ -0,0 +1,8 @@
+ "application/atom+xml",
+ 'content' => <<
+ /Icon/PNG
+
+MESSAGE_BODY
+];
diff --git a/tests/docroot/Feed/WithIcon/SVG1.php b/tests/docroot/Feed/WithIcon/SVG1.php
new file mode 100644
index 00000000..5f5acc87
--- /dev/null
+++ b/tests/docroot/Feed/WithIcon/SVG1.php
@@ -0,0 +1,8 @@
+ "application/atom+xml",
+ 'content' => <<
+ /Icon/SVG1
+
+MESSAGE_BODY
+];
diff --git a/tests/docroot/Feed/WithIcon/SVG2.php b/tests/docroot/Feed/WithIcon/SVG2.php
new file mode 100644
index 00000000..aca3c79c
--- /dev/null
+++ b/tests/docroot/Feed/WithIcon/SVG2.php
@@ -0,0 +1,8 @@
+ "application/atom+xml",
+ 'content' => <<
+ /Icon/SVG2
+
+MESSAGE_BODY
+];
diff --git a/tests/docroot/Icon/GIF.php b/tests/docroot/Icon/GIF.php
new file mode 100644
index 00000000..50f0b78c
--- /dev/null
+++ b/tests/docroot/Icon/GIF.php
@@ -0,0 +1,4 @@
+ "image/gif",
+ 'content' => base64_decode("R0lGODlhAQABAIABAAAAAP///yH5BAEKAAEALAAAAAABAAEAAAICTAEAOw=="),
+];
diff --git a/tests/docroot/Icon.php b/tests/docroot/Icon/PNG.php
similarity index 100%
rename from tests/docroot/Icon.php
rename to tests/docroot/Icon/PNG.php
diff --git a/tests/docroot/Icon/SVG1.php b/tests/docroot/Icon/SVG1.php
new file mode 100644
index 00000000..0543d91a
--- /dev/null
+++ b/tests/docroot/Icon/SVG1.php
@@ -0,0 +1,4 @@
+ "image/svg+xml",
+ 'content' => ' '
+];
diff --git a/tests/docroot/Icon/SVG2.php b/tests/docroot/Icon/SVG2.php
new file mode 100644
index 00000000..4ade7ce4
--- /dev/null
+++ b/tests/docroot/Icon/SVG2.php
@@ -0,0 +1,4 @@
+ "image/svg+xml",
+ 'content' => ' '
+];
From c3a57ca68b1b614b42778220521e7d4ae7478c0a Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Thu, 5 Nov 2020 14:19:17 -0500
Subject: [PATCH 017/366] Tests for icon cache population
---
lib/Database.php | 6 +--
tests/cases/Database/SeriesFeed.php | 70 ++++++++++++++++++++++++++--
tests/docroot/Feed/WithIcon/GIF.php | 3 ++
tests/docroot/Feed/WithIcon/PNG.php | 3 ++
tests/docroot/Feed/WithIcon/SVG1.php | 3 ++
tests/docroot/Feed/WithIcon/SVG2.php | 3 ++
6 files changed, 80 insertions(+), 8 deletions(-)
diff --git a/lib/Database.php b/lib/Database.php
index ca1246df..2bed918e 100644
--- a/lib/Database.php
+++ b/lib/Database.php
@@ -1104,7 +1104,7 @@ class Database {
$icon = $icon['id'];
} else {
// add the new icon to the cache
- $icon = $this->db->prepare("INSERT INTO arsee_icons(url, type, data) values(?, ?, ?", "str", "str", "blob")->run($feed->iconUrl, $feed->iconType, $feed->iconData)->lastId();
+ $icon = $this->db->prepare("INSERT INTO arsse_icons(url, type, data) values(?, ?, ?)", "str", "str", "blob")->run($feed->iconUrl, $feed->iconType, $feed->iconData)->lastId();
}
}
// actually perform updates
@@ -1258,9 +1258,9 @@ class Database {
* @param string $url The URL of the icon to Retrieve
* @param bool $withData Whether to return the icon content along with the metadata
*/
- protected function iconGetByUrl(string $url, bool $withData = true): array {
+ protected function iconGetByUrl(string $url, bool $withData = true): ?array {
$data = $withData ? "data" : "null as data";
- return $this->db->prepare("SELECT id, url, type, $data, next_fetch from arsse_icons where url = ?", "str")->run($id)->getRow();
+ return $this->db->prepare("SELECT id, url, type, $data, next_fetch from arsse_icons where url = ?", "str")->run($url)->getRow();
}
diff --git a/tests/cases/Database/SeriesFeed.php b/tests/cases/Database/SeriesFeed.php
index 1eb23bb0..f79c8cc9 100644
--- a/tests/cases/Database/SeriesFeed.php
+++ b/tests/cases/Database/SeriesFeed.php
@@ -26,6 +26,20 @@ trait SeriesFeed {
["john.doe@example.com", "",2],
],
],
+ 'arsse_icons' => [
+ 'columns' => [
+ 'id' => "int",
+ 'url' => "str",
+ 'type' => "str",
+ 'data' => "blob",
+ ],
+ 'rows' => [
+ [1,'http://localhost:8000/Icon/PNG','image/png',base64_decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAAZdEVYdFNvZnR3YXJlAHBhaW50Lm5ldCA0LjAuMjHxIGmVAAAADUlEQVQYV2NgYGBgAAAABQABijPjAAAAAABJRU5ErkJggg==")],
+ [2,'http://localhost:8000/Icon/GIF','image/gif',base64_decode("R0lGODlhAQABAIABAAAAAP///yH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==")],
+ // this actually contains the data of SVG2, which will lead to a row update when retieved
+ [3,'http://localhost:8000/Icon/SVG1','image/svg+xml',' '],
+ ],
+ ],
'arsse_feeds' => [
'columns' => [
'id' => "int",
@@ -36,13 +50,19 @@ trait SeriesFeed {
'modified' => "datetime",
'next_fetch' => "datetime",
'size' => "int",
+ 'icon' => "int",
],
'rows' => [
- [1,"http://localhost:8000/Feed/Matching/3","Ook",0,"",$past,$past,0],
- [2,"http://localhost:8000/Feed/Matching/1","Eek",5,"There was an error last time",$past,$future,0],
- [3,"http://localhost:8000/Feed/Fetching/Error?code=404","Ack",0,"",$past,$now,0],
- [4,"http://localhost:8000/Feed/NextFetch/NotModified?t=".time(),"Ooook",0,"",$past,$past,0],
- [5,"http://localhost:8000/Feed/Parsing/Valid","Ooook",0,"",$past,$future,0],
+ [1,"http://localhost:8000/Feed/Matching/3","Ook",0,"",$past,$past,0,null],
+ [2,"http://localhost:8000/Feed/Matching/1","Eek",5,"There was an error last time",$past,$future,0,null],
+ [3,"http://localhost:8000/Feed/Fetching/Error?code=404","Ack",0,"",$past,$now,0,null],
+ [4,"http://localhost:8000/Feed/NextFetch/NotModified?t=".time(),"Ooook",0,"",$past,$past,0,null],
+ [5,"http://localhost:8000/Feed/Parsing/Valid","Ooook",0,"",$past,$future,0,null],
+ // these feeds all test icon caching
+ [6,"http://localhost:8000/Feed/WithIcon/PNG",null,0,"",$past,$future,0,1], // no change when updated
+ [7,"http://localhost:8000/Feed/WithIcon/GIF",null,0,"",$past,$future,0,1], // icon ID 2 will be assigned to feed when updated
+ [8,"http://localhost:8000/Feed/WithIcon/SVG1",null,0,"",$past,$future,0,3], // icon ID 3 will be modified when updated
+ [9,"http://localhost:8000/Feed/WithIcon/SVG2",null,0,"",$past,$future,0,null], // icon ID 4 will be created and assigned to feed when updated
],
],
'arsse_subscriptions' => [
@@ -261,4 +281,44 @@ trait SeriesFeed {
Arsse::$db->feedUpdate(4);
$this->assertEquals([1], Arsse::$db->feedListStale());
}
+
+ public function testCheckIconDuringFeedUpdate(): void {
+ Arsse::$db->feedUpdate(6);
+ $state = $this->primeExpectations($this->data, [
+ 'arsse_icons' => ["id","url","type","data"],
+ 'arsse_feeds' => ["id", "icon"],
+ ]);
+ $this->compareExpectations(static::$drv, $state);
+ }
+
+ public function testAssignIconDuringFeedUpdate(): void {
+ Arsse::$db->feedUpdate(7);
+ $state = $this->primeExpectations($this->data, [
+ 'arsse_icons' => ["id","url","type","data"],
+ 'arsse_feeds' => ["id", "icon"],
+ ]);
+ $state['arsse_feeds']['rows'][6][1] = 2;
+ $this->compareExpectations(static::$drv, $state);
+ }
+
+ public function testChangeIconDuringFeedUpdate(): void {
+ Arsse::$db->feedUpdate(8);
+ $state = $this->primeExpectations($this->data, [
+ 'arsse_icons' => ["id","url","type","data"],
+ 'arsse_feeds' => ["id", "icon"],
+ ]);
+ $state['arsse_icons']['rows'][2][3] = ' ';
+ $this->compareExpectations(static::$drv, $state);
+ }
+
+ public function testAddIconDuringFeedUpdate(): void {
+ Arsse::$db->feedUpdate(9);
+ $state = $this->primeExpectations($this->data, [
+ 'arsse_icons' => ["id","url","type","data"],
+ 'arsse_feeds' => ["id", "icon"],
+ ]);
+ $state['arsse_feeds']['rows'][8][1] = 4;
+ $state['arsse_icons']['rows'][] = [4,'http://localhost:8000/Icon/SVG2','image/svg+xml',' '];
+ $this->compareExpectations(static::$drv, $state);
+ }
}
diff --git a/tests/docroot/Feed/WithIcon/GIF.php b/tests/docroot/Feed/WithIcon/GIF.php
index 8b81b58c..ae3ce225 100644
--- a/tests/docroot/Feed/WithIcon/GIF.php
+++ b/tests/docroot/Feed/WithIcon/GIF.php
@@ -3,6 +3,9 @@
'content' => <<
/Icon/GIF
+
+ Example title
+
MESSAGE_BODY
];
diff --git a/tests/docroot/Feed/WithIcon/PNG.php b/tests/docroot/Feed/WithIcon/PNG.php
index 3bca1c9a..1e946d82 100644
--- a/tests/docroot/Feed/WithIcon/PNG.php
+++ b/tests/docroot/Feed/WithIcon/PNG.php
@@ -3,6 +3,9 @@
'content' => <<
/Icon/PNG
+
+ Example title
+
MESSAGE_BODY
];
diff --git a/tests/docroot/Feed/WithIcon/SVG1.php b/tests/docroot/Feed/WithIcon/SVG1.php
index 5f5acc87..8bbabdeb 100644
--- a/tests/docroot/Feed/WithIcon/SVG1.php
+++ b/tests/docroot/Feed/WithIcon/SVG1.php
@@ -3,6 +3,9 @@
'content' => <<
/Icon/SVG1
+
+ Example title
+
MESSAGE_BODY
];
diff --git a/tests/docroot/Feed/WithIcon/SVG2.php b/tests/docroot/Feed/WithIcon/SVG2.php
index aca3c79c..ce36bb76 100644
--- a/tests/docroot/Feed/WithIcon/SVG2.php
+++ b/tests/docroot/Feed/WithIcon/SVG2.php
@@ -3,6 +3,9 @@
'content' => <<
/Icon/SVG2
+
+ Example title
+
MESSAGE_BODY
];
From 4fc208d940dd6bda4092788c70aa49aea8418db1 Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Thu, 5 Nov 2020 16:51:46 -0500
Subject: [PATCH 018/366] More consistent icon API
---
lib/Database.php | 75 ++++++++++++++++------
tests/cases/Database/AbstractTest.php | 1 +
tests/cases/Database/SeriesIcon.php | 89 +++++++++++++++++++++++++++
3 files changed, 146 insertions(+), 19 deletions(-)
create mode 100644 tests/cases/Database/SeriesIcon.php
diff --git a/lib/Database.php b/lib/Database.php
index 2bed918e..0262b78a 100644
--- a/lib/Database.php
+++ b/lib/Database.php
@@ -23,6 +23,7 @@ use JKingWeb\Arsse\Misc\URL;
* - Folders, which belong to users and contain subscriptions
* - Tags, which belong to users and can be assigned to multiple subscriptions
* - Feeds to which users are subscribed
+ * - Icons, which are associated with feeds
* - Articles, which belong to feeds and for which users can only affect metadata
* - Editions, identifying authorial modifications to articles
* - Labels, which belong to users and can be assigned to multiple articles
@@ -933,6 +934,29 @@ class Database {
}
return (string) $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues())->getValue();
}
+
+ /** Retrieves detailed information about the icon for a subscription.
+ *
+ * The returned information is:
+ *
+ * - "id": The umeric identifier of the icon (not the subscription)
+ * - "url": The URL of the icon
+ * - "type": The Content-Type of the icon e.g. "image/png"
+ * - "data": The icon itself, as a binary sring; if $withData is false this will be null
+ *
+ * @param string $user The user whose subscription icon is to be retrieved
+ * @param int $subscription The numeric identifier of the subscription
+ */
+ public function subscriptionIcon(string $user, int $subscription): array {
+ if (!Arsse::$user->authorize($user, __FUNCTION__)) {
+ throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
+ }
+ $out = $this->db->prepare("SELECT i.id, i.url, i.type, i.data from arsse_icons as i join arsse_feeds as f on i.id = f.icon join arsse_subscriptions as s on s.feed = f.id where s.owner = ? and s.id = ?", "str", "int")->run($user, $subscription)->getRow();
+ if (!$out) {
+ throw new Db\ExceptionInput("idMissing", ["action" => __FUNCTION__, "field" => "subscription", 'id' => $subscription]);
+ }
+ return $out;
+ }
/** Returns the time at which any of a user's subscriptions (or a specific subscription) was last refreshed, as a DateTimeImmutable object */
public function subscriptionRefreshed(string $user, int $id = null): ?\DateTimeImmutable {
@@ -1255,16 +1279,13 @@ class Database {
/** Retrieve a feed icon by URL, for use during feed refreshing
*
- * @param string $url The URL of the icon to Retrieve
- * @param bool $withData Whether to return the icon content along with the metadata
+ * @param string $url The URL of the icon to retrieve
*/
- protected function iconGetByUrl(string $url, bool $withData = true): ?array {
- $data = $withData ? "data" : "null as data";
- return $this->db->prepare("SELECT id, url, type, $data, next_fetch from arsse_icons where url = ?", "str")->run($url)->getRow();
+ protected function iconGetByUrl(string $url): ?array {
+ return $this->db->prepare("SELECT id, url, type, data from arsse_icons where url = ?", "str")->run($url)->getRow();
}
-
-
- /** Returns information about an icon for a feed to which a user is subscribed, with or without the binary content of the icon itself
+
+ /** Retrieves information about an icon
*
* The returned information is:
*
@@ -1273,18 +1294,16 @@ class Database {
* - "type": The Content-Type of the icon e.g. "image/png"
* - "data": The icon itself, as a binary sring; if $withData is false this will be null
*
- * @param string $user The user whose subscription icon is to be retrieved
- * @param int $subscription The numeric identifier of the subscription with which the icon is associated
- * @param bool $withData Whether to retrireve the icon content in addition to its metadata
+ * @param string $user The user whose icon is to be retrieved
+ * @param int $subscription The numeric identifier of the icon
*/
- public function iconGet(string $user, int $subscrption, bool $withData = true): array {
+ public function iconGet(string $user, int $id): array {
if (!Arsse::$user->authorize($user, __FUNCTION__)) {
throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
}
- $data = $withData ? "data" : "null as data";
- $out = $this->db->prepare("SELECT i.id, i.url, i.type, $data from arsse_icons as i join arsse_feeds as f on i.id = f.icon join arsse_subscriptions as s on s.feed = f.id where s.owner = ? and s.id = ?", "str", "int")->run($user, $subscription)->getRow();
+ $out = $this->db->prepare("SELECT i.id, i.url, i.type, i.data from arsse_icons as i join arsse_feeds as f on i.id = f.icon join arsse_subscriptions as s on s.feed = f.id where s.owner = ? and i.id = ?", "str", "int")->run($user, $id)->getRow();
if (!$out) {
- throw new Db\ExceptionInput("subjectMissing", ["action" => __FUNCTION__, "field" => "subscription", 'id' => $subscription]);
+ throw new Db\ExceptionInput("subjectMissing", ["action" => __FUNCTION__, "field" => "subscription", 'id' => $id]);
}
return $out;
}
@@ -1292,14 +1311,32 @@ class Database {
/** Lists icons for feeds to which a user is subscribed, with or without the binary content of the icon itself
*
* @param string $user The user whose subscription icons are to be retrieved
- * @param bool $withData Whether to retrireve the icon content in addition to its metadata
*/
- public function iconList(string $user, bool $withData = true): Db\Result {
+ public function iconList(string $user): Db\Result {
if (!Arsse::$user->authorize($user, __FUNCTION__)) {
throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
}
- $data = $withData ? "i.data" : "null as data";
- return $this->db->prepare("SELECT i.id, i.url, i.type, $data from arsse_icons as i join arsse_feeds as f on i.id = f.icon join arsse_subscriptions as s on s.feed = f.id where s.owner = ?", "str")->run($user);
+ return $this->db->prepare("SELECT i.id, i.url, i.type, i.data from arsse_icons as i join arsse_feeds as f on i.id = f.icon join arsse_subscriptions as s on s.feed = f.id where s.owner = ?", "str")->run($user);
+ }
+
+ /** Deletes orphaned icons from the database
+ *
+ * Icons are orphaned if no subscribed newsfeed uses them.
+ */
+ public function iconCleanup(): int {
+ $tr = $this->begin();
+ // first unmark any icons which are no longer orphaned; an icon is considered orphaned if it is not used or only used by feeds which are themselves orphaned
+ $this->db->query("UPDATE arsse_icons set orphaned = null where id in (select distinct icon from arsse_feeds where icon is not null and orphaned is null)");
+ // next mark any newly orphaned icons with the current date and time
+ $this->db->query("UPDATE arsse_icons set orphaned = CURRENT_TIMESTAMP where id not in (select distinct icon from arsse_feeds where icon is not null and orphaned is null)");
+ // finally delete icons that have been orphaned longer than the feed retention period, if a a purge threshold has been specified
+ $out = 0;
+ if (Arsse::$conf->purgeFeeds) {
+ $limit = Date::sub(Arsse::$conf->purgeFeeds);
+ $out += $this->db->prepare("DELETE from arsse_icons where orphaned <= ?", "datetime")->run($limit)->changes();
+ }
+ $tr->commit();
+ return $out;
}
/** Returns an associative array of result column names and their SQL computations for article queries
diff --git a/tests/cases/Database/AbstractTest.php b/tests/cases/Database/AbstractTest.php
index ff5acdd5..6e0e2ec9 100644
--- a/tests/cases/Database/AbstractTest.php
+++ b/tests/cases/Database/AbstractTest.php
@@ -18,6 +18,7 @@ abstract class AbstractTest extends \JKingWeb\Arsse\Test\AbstractTest {
use SeriesToken;
use SeriesFolder;
use SeriesFeed;
+ use SeriesIcon;
use SeriesSubscription;
use SeriesLabel;
use SeriesTag;
diff --git a/tests/cases/Database/SeriesIcon.php b/tests/cases/Database/SeriesIcon.php
new file mode 100644
index 00000000..7a7e348c
--- /dev/null
+++ b/tests/cases/Database/SeriesIcon.php
@@ -0,0 +1,89 @@
+data = [
+ 'arsse_users' => [
+ 'columns' => [
+ 'id' => 'str',
+ 'password' => 'str',
+ 'num' => 'int',
+ ],
+ 'rows' => [
+ ["jane.doe@example.com", "",1],
+ ["john.doe@example.com", "",2],
+ ],
+ ],
+ 'arsse_icons' => [
+ 'columns' => [
+ 'id' => "int",
+ 'url' => "str",
+ 'type' => "str",
+ 'data' => "blob",
+ ],
+ 'rows' => [
+ [1,'http://localhost:8000/Icon/PNG','image/png',base64_decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAAZdEVYdFNvZnR3YXJlAHBhaW50Lm5ldCA0LjAuMjHxIGmVAAAADUlEQVQYV2NgYGBgAAAABQABijPjAAAAAABJRU5ErkJggg==")],
+ [2,'http://localhost:8000/Icon/GIF','image/gif',base64_decode("R0lGODlhAQABAIABAAAAAP///yH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==")],
+ [3,'http://localhost:8000/Icon/SVG1','image/svg+xml',' '],
+ [4,'http://localhost:8000/Icon/SVG2','image/svg+xml',' '],
+ ],
+ ],
+ 'arsse_feeds' => [
+ 'columns' => [
+ 'id' => "int",
+ 'url' => "str",
+ 'title' => "str",
+ 'err_count' => "int",
+ 'err_msg' => "str",
+ 'modified' => "datetime",
+ 'next_fetch' => "datetime",
+ 'size' => "int",
+ 'icon' => "int",
+ ],
+ 'rows' => [
+ [1,"http://localhost:8000/Feed/Matching/3","Ook",0,"",$past,$past,0,null],
+ [2,"http://localhost:8000/Feed/Matching/1","Eek",5,"There was an error last time",$past,$future,0,null],
+ [3,"http://localhost:8000/Feed/Fetching/Error?code=404","Ack",0,"",$past,$now,0,null],
+ [4,"http://localhost:8000/Feed/NextFetch/NotModified?t=".time(),"Ooook",0,"",$past,$past,0,null],
+ [5,"http://localhost:8000/Feed/Parsing/Valid","Ooook",0,"",$past,$future,0,null],
+ // these feeds all test icon caching
+ [6,"http://localhost:8000/Feed/WithIcon/PNG",null,0,"",$past,$future,0,1], // no change when updated
+ [7,"http://localhost:8000/Feed/WithIcon/GIF",null,0,"",$past,$future,0,1], // icon ID 2 will be assigned to feed when updated
+ [8,"http://localhost:8000/Feed/WithIcon/SVG1",null,0,"",$past,$future,0,3], // icon ID 3 will be modified when updated
+ [9,"http://localhost:8000/Feed/WithIcon/SVG2",null,0,"",$past,$future,0,null], // icon ID 4 will be created and assigned to feed when updated
+ ],
+ ],
+ 'arsse_subscriptions' => [
+ 'columns' => [
+ 'id' => "int",
+ 'owner' => "str",
+ 'feed' => "int",
+ ],
+ 'rows' => [
+ [1,'john.doe@example.com',1],
+ [2,'john.doe@example.com',2],
+ [3,'john.doe@example.com',3],
+ [4,'john.doe@example.com',4],
+ [5,'john.doe@example.com',5],
+ [6,'jane.doe@example.com',1],
+ ],
+ ],
+ ];
+ }
+
+ protected function tearDownSeriesIcon(): void {
+ unset($this->data);
+ }
+}
From dd1a80f279ae7e14f4809bb0d0c892f96bd006b9 Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Thu, 5 Nov 2020 18:32:11 -0500
Subject: [PATCH 019/366] Consolidate subscription icon querying
Users and tests still need adjusting
---
lib/Database.php | 43 +++++++++++++------------------------------
1 file changed, 13 insertions(+), 30 deletions(-)
diff --git a/lib/Database.php b/lib/Database.php
index 0262b78a..db4d125e 100644
--- a/lib/Database.php
+++ b/lib/Database.php
@@ -910,30 +910,6 @@ class Database {
$out = $this->db->prepare("SELECT $field from arsse_tags where id in (select tag from arsse_tag_members where subscription = ? and assigned = 1) order by $field", "int")->run($id)->getAll();
return $out ? array_column($out, $field) : [];
}
-
- /** Retrieves the URL of the icon for a subscription.
- *
- * Note that while the $user parameter is optional, it
- * is NOT recommended to omit it, as this can lead to
- * leaks of private information. The parameter is only
- * optional because this is required for Tiny Tiny RSS,
- * the original implementation of which leaks private
- * information due to a design flaw.
- *
- * @param integer $id The numeric identifier of the subscription
- * @param string|null $user The user who owns the subscription being queried
- */
- public function subscriptionFavicon(int $id, string $user = null): string {
- $q = new Query("SELECT i.url as favicon from arsse_feeds as f left join arsse_icons as i on i.id = f.icon join arsse_subscriptions as s on s.feed = f.id");
- $q->setWhere("s.id = ?", "int", $id);
- if (isset($user)) {
- if (!Arsse::$user->authorize($user, __FUNCTION__)) {
- throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
- }
- $q->setWhere("s.owner = ?", "str", $user);
- }
- return (string) $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues())->getValue();
- }
/** Retrieves detailed information about the icon for a subscription.
*
@@ -944,16 +920,23 @@ class Database {
* - "type": The Content-Type of the icon e.g. "image/png"
* - "data": The icon itself, as a binary sring; if $withData is false this will be null
*
- * @param string $user The user whose subscription icon is to be retrieved
+ * @param string|null $user The user who owns the subscription being queried; using null here is supported for TT-RSS and SHOULD NOT be used elsewhere as it leaks information
* @param int $subscription The numeric identifier of the subscription
+ * @param bool $includeData Whether to include the binary data of the icon itself in the result
*/
- public function subscriptionIcon(string $user, int $subscription): array {
- if (!Arsse::$user->authorize($user, __FUNCTION__)) {
- throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
+ public function subscriptionIcon(?string $user, int $id, bool $includeData = true): array {
+ $data = $includeData ? "i.data" : "null as data";
+ $q = new Query("SELECT i.id, i.url, i.type, $data from arsse_icons as i join arsse_feeds as f on i.id = f.icon join arsse_subscriptions as s on s.feed = f.id");
+ $q->setWhere("s.id = ?", "int", $id);
+ if (isset($user)) {
+ if (!Arsse::$user->authorize($user, __FUNCTION__)) {
+ throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
+ }
+ $q->setWhere("s.owner = ?", "str", $user);
}
- $out = $this->db->prepare("SELECT i.id, i.url, i.type, i.data from arsse_icons as i join arsse_feeds as f on i.id = f.icon join arsse_subscriptions as s on s.feed = f.id where s.owner = ? and s.id = ?", "str", "int")->run($user, $subscription)->getRow();
+ $out = $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues())->getRow();
if (!$out) {
- throw new Db\ExceptionInput("idMissing", ["action" => __FUNCTION__, "field" => "subscription", 'id' => $subscription]);
+ throw new Db\ExceptionInput("idMissing", ["action" => __FUNCTION__, "field" => "subscription", 'id' => $id]);
}
return $out;
}
From 424b14d2b44029b8cc2565058c461611ddd0f14e Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Fri, 6 Nov 2020 10:27:30 -0500
Subject: [PATCH 020/366] Clean up use of subscriptionFavicon
---
lib/Database.php | 4 +-
lib/REST/TinyTinyRSS/Icon.php | 11 ++++--
tests/cases/Database/SeriesSubscription.php | 41 +++++++++++----------
tests/cases/REST/TinyTinyRSS/TestIcon.php | 24 ++++++------
4 files changed, 43 insertions(+), 37 deletions(-)
diff --git a/lib/Database.php b/lib/Database.php
index db4d125e..b852ecc5 100644
--- a/lib/Database.php
+++ b/lib/Database.php
@@ -926,7 +926,7 @@ class Database {
*/
public function subscriptionIcon(?string $user, int $id, bool $includeData = true): array {
$data = $includeData ? "i.data" : "null as data";
- $q = new Query("SELECT i.id, i.url, i.type, $data from arsse_icons as i join arsse_feeds as f on i.id = f.icon join arsse_subscriptions as s on s.feed = f.id");
+ $q = new Query("SELECT i.id, i.url, i.type, $data from arsse_subscriptions as s join arsse_feeds as f on s.feed = f.id left join arsse_icons as i on f.icon = i.id");
$q->setWhere("s.id = ?", "int", $id);
if (isset($user)) {
if (!Arsse::$user->authorize($user, __FUNCTION__)) {
@@ -936,7 +936,7 @@ class Database {
}
$out = $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues())->getRow();
if (!$out) {
- throw new Db\ExceptionInput("idMissing", ["action" => __FUNCTION__, "field" => "subscription", 'id' => $id]);
+ throw new Db\ExceptionInput("subjectMissing", ["action" => __FUNCTION__, "field" => "subscription", 'id' => $id]);
}
return $out;
}
diff --git a/lib/REST/TinyTinyRSS/Icon.php b/lib/REST/TinyTinyRSS/Icon.php
index c5c9030e..b49ae4e4 100644
--- a/lib/REST/TinyTinyRSS/Icon.php
+++ b/lib/REST/TinyTinyRSS/Icon.php
@@ -7,6 +7,7 @@ declare(strict_types=1);
namespace JKingWeb\Arsse\REST\TinyTinyRSS;
use JKingWeb\Arsse\Arsse;
+use JKingWeb\Arsse\Db\ExceptionInput;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\ResponseInterface;
use Laminas\Diactoros\Response\EmptyResponse as Response;
@@ -29,14 +30,16 @@ class Icon extends \JKingWeb\Arsse\REST\AbstractHandler {
} elseif (!preg_match("<^(\d+)\.ico$>", $req->getRequestTarget(), $match) || !((int) $match[1])) {
return new Response(404);
}
- $url = Arsse::$db->subscriptionFavicon((int) $match[1], Arsse::$user->id ?? null);
- if ($url) {
- // strip out anything after literal line-end characters; this is to mitigate a potential header (e.g. cookie) injection from the URL
+ try {
+ $url = Arsse::$db->subscriptionIcon(Arsse::$user->id ?? null, (int) $match[1], false)['url'];
+ if (!$url) {
+ return new Response(404);
+ }
if (($pos = strpos($url, "\r")) !== false || ($pos = strpos($url, "\n")) !== false) {
$url = substr($url, 0, $pos);
}
return new Response(301, ['Location' => $url]);
- } else {
+ } catch (ExceptionInput $e) {
return new Response(404);
}
}
diff --git a/tests/cases/Database/SeriesSubscription.php b/tests/cases/Database/SeriesSubscription.php
index 427a9843..0075b992 100644
--- a/tests/cases/Database/SeriesSubscription.php
+++ b/tests/cases/Database/SeriesSubscription.php
@@ -462,32 +462,35 @@ trait SeriesSubscription {
public function testRetrieveTheFaviconOfASubscription(): void {
$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));
+ $this->assertSame($exp, Arsse::$db->subscriptionIcon(null, 1)['url']);
+ $this->assertSame($exp, Arsse::$db->subscriptionIcon(null, 2)['url']);
+ $this->assertSame(null, Arsse::$db->subscriptionIcon(null, 3)['url']);
// 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));
+ $this->assertSame($exp, Arsse::$db->subscriptionIcon(null, 1)['url']);
+ $this->assertSame($exp, Arsse::$db->subscriptionIcon(null, 2)['url']);
+ $this->assertSame(null, Arsse::$db->subscriptionIcon(null, 3)['url']);
+ }
+
+ public function testRetrieveTheFaviconOfAMissingSubscription(): void {
+ $this->assertException("subjectMissing", "Db", "ExceptionInput");
+ Arsse::$db->subscriptionIcon(null, -2112);
}
public function testRetrieveTheFaviconOfASubscriptionWithUser(): void {
$exp = "http://example.com/favicon.ico";
$user = "john.doe@example.com";
- $this->assertSame($exp, Arsse::$db->subscriptionFavicon(1, $user));
- $this->assertSame('', Arsse::$db->subscriptionFavicon(2, $user));
- $this->assertSame('', Arsse::$db->subscriptionFavicon(3, $user));
- $this->assertSame('', Arsse::$db->subscriptionFavicon(4, $user));
+ $this->assertSame($exp, Arsse::$db->subscriptionIcon($user, 1)['url']);
+ $this->assertSame(null, Arsse::$db->subscriptionIcon($user, 3)['url']);
$user = "jane.doe@example.com";
- $this->assertSame('', Arsse::$db->subscriptionFavicon(1, $user));
- $this->assertSame($exp, Arsse::$db->subscriptionFavicon(2, $user));
- $this->assertSame('', Arsse::$db->subscriptionFavicon(3, $user));
- $this->assertSame('', Arsse::$db->subscriptionFavicon(4, $user));
+ $this->assertSame($exp, Arsse::$db->subscriptionIcon($user, 2)['url']);
+ }
+
+ public function testRetrieveTheFaviconOfASubscriptionOfTheWrongUser(): void {
+ $exp = "http://example.com/favicon.ico";
+ $user = "john.doe@example.com";
+ $this->assertException("subjectMissing", "Db", "ExceptionInput");
+ $this->assertSame(null, Arsse::$db->subscriptionIcon($user, 2)['url']);
}
public function testRetrieveTheFaviconOfASubscriptionWithUserWithoutAuthority(): void {
@@ -495,7 +498,7 @@ trait SeriesSubscription {
$user = "john.doe@example.com";
\Phake::when(Arsse::$user)->authorize->thenReturn(false);
$this->assertException("notAuthorized", "User", "ExceptionAuthz");
- Arsse::$db->subscriptionFavicon(-2112, $user);
+ Arsse::$db->subscriptionIcon($user, -2112);
}
public function testListTheTagsOfASubscription(): void {
diff --git a/tests/cases/REST/TinyTinyRSS/TestIcon.php b/tests/cases/REST/TinyTinyRSS/TestIcon.php
index 38dbd8fe..5341238f 100644
--- a/tests/cases/REST/TinyTinyRSS/TestIcon.php
+++ b/tests/cases/REST/TinyTinyRSS/TestIcon.php
@@ -48,10 +48,10 @@ class TestIcon extends \JKingWeb\Arsse\Test\AbstractTest {
}
public function testRetrieveFavion(): void {
- \Phake::when(Arsse::$db)->subscriptionFavicon->thenReturn("");
- \Phake::when(Arsse::$db)->subscriptionFavicon(42, $this->anything())->thenReturn("http://example.com/favicon.ico");
- \Phake::when(Arsse::$db)->subscriptionFavicon(2112, $this->anything())->thenReturn("http://example.net/logo.png");
- \Phake::when(Arsse::$db)->subscriptionFavicon(1337, $this->anything())->thenReturn("http://example.org/icon.gif\r\nLocation: http://bad.example.com/");
+ \Phake::when(Arsse::$db)->subscriptionIcon->thenReturn(['url' => null]);
+ \Phake::when(Arsse::$db)->subscriptionIcon($this->anything(), 42, false)->thenReturn(['url' => "http://example.com/favicon.ico"]);
+ \Phake::when(Arsse::$db)->subscriptionIcon($this->anything(), 2112, false)->thenReturn(['url' => "http://example.net/logo.png"]);
+ \Phake::when(Arsse::$db)->subscriptionIcon($this->anything(), 1337, false)->thenReturn(['url' => "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->assertMessage($exp, $this->req("42.ico"));
@@ -71,14 +71,14 @@ class TestIcon extends \JKingWeb\Arsse\Test\AbstractTest {
}
public function testRetrieveFavionWithHttpAuthentication(): void {
- $url = "http://example.org/icon.gif\r\nLocation: http://bad.example.com/";
- \Phake::when(Arsse::$db)->subscriptionFavicon->thenReturn("");
- \Phake::when(Arsse::$db)->subscriptionFavicon(42, $this->user)->thenReturn($url);
- \Phake::when(Arsse::$db)->subscriptionFavicon(2112, "jane.doe")->thenReturn($url);
- \Phake::when(Arsse::$db)->subscriptionFavicon(1337, $this->user)->thenReturn($url);
- \Phake::when(Arsse::$db)->subscriptionFavicon(42, null)->thenReturn($url);
- \Phake::when(Arsse::$db)->subscriptionFavicon(2112, null)->thenReturn($url);
- \Phake::when(Arsse::$db)->subscriptionFavicon(1337, null)->thenReturn($url);
+ $url = ['url' => "http://example.org/icon.gif\r\nLocation: http://bad.example.com/"];
+ \Phake::when(Arsse::$db)->subscriptionIcon->thenReturn(['url' => null]);
+ \Phake::when(Arsse::$db)->subscriptionIcon($this->user, 42, false)->thenReturn($url);
+ \Phake::when(Arsse::$db)->subscriptionIcon("jane.doe", 2112, false)->thenReturn($url);
+ \Phake::when(Arsse::$db)->subscriptionIcon($this->user, 1337, false)->thenReturn($url);
+ \Phake::when(Arsse::$db)->subscriptionIcon(null, 42, false)->thenReturn($url);
+ \Phake::when(Arsse::$db)->subscriptionIcon(null, 2112, false)->thenReturn($url);
+ \Phake::when(Arsse::$db)->subscriptionIcon(null, 1337, false)->thenReturn($url);
// these requests should succeed
$exp = new Response(301, ['Location' => "http://example.org/icon.gif"]);
$this->assertMessage($exp, $this->req("42.ico"));
From 8f739cec85cdb673dadd07c286114cb86967189a Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Fri, 6 Nov 2020 10:28:28 -0500
Subject: [PATCH 021/366] Excluse empty-string URLs from icons table
---
sql/MySQL/6.sql | 2 +-
sql/PostgreSQL/6.sql | 2 +-
sql/SQLite3/6.sql | 2 +-
3 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/sql/MySQL/6.sql b/sql/MySQL/6.sql
index 281467ec..4d7d4aee 100644
--- a/sql/MySQL/6.sql
+++ b/sql/MySQL/6.sql
@@ -30,7 +30,7 @@ create table arsse_icons(
type text,
data longblob
) character set utf8mb4 collate utf8mb4_unicode_ci;
-insert into arsse_icons(url) select distinct favicon from arsse_feeds where favicon is not null;
+insert into arsse_icons(url) select distinct favicon from arsse_feeds where favicon is not null and favicon <> '';
alter table arsse_feeds add column icon bigint unsigned;
alter table arsse_feeds add constraint foreign key (icon) references arsse_icons(id) on delete set null;
update arsse_feeds as f, arsse_icons as i set f.icon = i.id where f.favicon = i.url;
diff --git a/sql/PostgreSQL/6.sql b/sql/PostgreSQL/6.sql
index 6c128a03..c78f6d44 100644
--- a/sql/PostgreSQL/6.sql
+++ b/sql/PostgreSQL/6.sql
@@ -31,7 +31,7 @@ create table arsse_icons(
type text,
data bytea
);
-insert into arsse_icons(url) select distinct favicon from arsse_feeds where favicon is not null;
+insert into arsse_icons(url) select distinct favicon from arsse_feeds where favicon is not null and favicon <> '';
alter table arsse_feeds add column icon bigint references arsse_icons(id) on delete set null;
update arsse_feeds as f set icon = i.id from arsse_icons as i where f.favicon = i.url;
alter table arsse_feeds drop column favicon;
diff --git a/sql/SQLite3/6.sql b/sql/SQLite3/6.sql
index 513422d8..6e8a993e 100644
--- a/sql/SQLite3/6.sql
+++ b/sql/SQLite3/6.sql
@@ -40,7 +40,7 @@ create table arsse_icons(
type text, -- the Content-Type of the icon, if known
data blob -- the binary data of the icon itself
);
-insert into arsse_icons(url) select distinct favicon from arsse_feeds where favicon is not null;
+insert into arsse_icons(url) select distinct favicon from arsse_feeds where favicon is not null and favicon <> '';
create table arsse_feeds_new(
-- newsfeeds, deduplicated
-- users have subscriptions to these feeds in another table
From b24c469dcae0e5d5933901285ee731a85810b3e6 Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Fri, 6 Nov 2020 11:01:50 -0500
Subject: [PATCH 022/366] Update changelog
---
CHANGELOG | 6 ++++++
1 file changed, 6 insertions(+)
diff --git a/CHANGELOG b/CHANGELOG
index f679cfa1..730e60a4 100644
--- a/CHANGELOG
+++ b/CHANGELOG
@@ -1,3 +1,9 @@
+Version 0.9.0 (????-??-??)
+==========================
+
+Bug fixes:
+- Use icons specified in Atom feeds when available
+
Version 0.8.5 (2020-10-27)
==========================
From e861cca53d959133434bb621931e6f616e93cd44 Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Fri, 6 Nov 2020 11:06:27 -0500
Subject: [PATCH 023/366] Integrate schema change necessary for microsub
---
sql/MySQL/6.sql | 2 ++
sql/PostgreSQL/6.sql | 2 ++
sql/SQLite3/6.sql | 4 ++++
3 files changed, 8 insertions(+)
diff --git a/sql/MySQL/6.sql b/sql/MySQL/6.sql
index 4d7d4aee..6c652e82 100644
--- a/sql/MySQL/6.sql
+++ b/sql/MySQL/6.sql
@@ -4,6 +4,8 @@
-- Please consult the SQLite 3 schemata for commented version
+alter table arsse_tokens add column data longtext default null;
+
alter table arsse_users add column num bigint unsigned unique;
alter table arsse_users add column admin boolean not null default 0;
alter table arsse_users add column lang longtext;
diff --git a/sql/PostgreSQL/6.sql b/sql/PostgreSQL/6.sql
index c78f6d44..f14f8c83 100644
--- a/sql/PostgreSQL/6.sql
+++ b/sql/PostgreSQL/6.sql
@@ -4,6 +4,8 @@
-- Please consult the SQLite 3 schemata for commented version
+alter table arsse_tokens add column data text default null;
+
alter table arsse_users add column num bigint unique;
alter table arsse_users add column admin smallint not null default 0;
alter table arsse_users add column lang text;
diff --git a/sql/SQLite3/6.sql b/sql/SQLite3/6.sql
index 6e8a993e..8c6f73f3 100644
--- a/sql/SQLite3/6.sql
+++ b/sql/SQLite3/6.sql
@@ -2,6 +2,10 @@
-- Copyright 2017 J. King, Dustin Wilson et al.
-- See LICENSE and AUTHORS files for details
+-- Add a column to the token table to hold arbitrary class-specific data
+-- This is a speculative addition to support OAuth login in the future
+alter table arsse_tokens add column data text default null;
+
-- Add multiple columns to the users table
-- In particular this adds a numeric identifier for each user, which Miniflux requires
create table arsse_users_new(
From 4d532cba3f45c15d4351235827ba87d10c990f57 Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Fri, 6 Nov 2020 13:08:53 -0500
Subject: [PATCH 024/366] Initial Miniflux documentation
---
docs/en/010_About.md | 1 +
.../030_Supported_Protocols/005_Miniflux.md | 26 +++++
docs/en/030_Supported_Protocols/index.md | 1 +
docs/en/040_Compatible_Clients.md | 97 +++++++++++++++++--
docs/theme/arsse/arsse.css | 2 +-
docs/theme/src/arsse.scss | 4 +-
6 files changed, 121 insertions(+), 10 deletions(-)
create mode 100644 docs/en/030_Supported_Protocols/005_Miniflux.md
diff --git a/docs/en/010_About.md b/docs/en/010_About.md
index 615185f4..3ffc49bb 100644
--- a/docs/en/010_About.md
+++ b/docs/en/010_About.md
@@ -1,5 +1,6 @@
The Advanced RSS Environment (affectionately called "The Arsse") is a news aggregator server which implements multiple synchronization protocols. Unlike most other aggregator servers, The Arsse does not include a Web front-end (though one is planned as a separate project), and it relies on [existing protocols](Supported_Protocols) to maximize compatibility with [existing clients](Compatible_Clients). Supported protocols are:
+- Miniflux
- Nextcloud News
- Tiny Tiny RSS
- Fever
diff --git a/docs/en/030_Supported_Protocols/005_Miniflux.md b/docs/en/030_Supported_Protocols/005_Miniflux.md
new file mode 100644
index 00000000..cf706ba5
--- /dev/null
+++ b/docs/en/030_Supported_Protocols/005_Miniflux.md
@@ -0,0 +1,26 @@
+[TOC]
+
+# About
+
+
+ Supported since
+ 0.9.0
+ Base URL
+ /
+ API endpoint
+ /v1/
+ Specifications
+ API Reference
+
+
+The Miniflux protocol is a well-designed protocol supporting a wide variety of operations on newsfeeds, folders (termed "categories"), and articles; it also allows for user administration, and native OPML importing and exporting.
+
+Architecturally it is similar to the Nextcloud News protocol, but is generally more efficient.
+
+# Differences
+
+TBD
+
+# Interaction with nested folders
+
+Tiny Tiny RSS is unique in allowing newsfeeds to be grouped into folders nested to arbitrary depth. When newsfeeds are placed into nested folders, they simply appear in the top-level folder when accessed via the Miniflux protocol. This does not affect OPML exports, where full nesting is preserved.
diff --git a/docs/en/030_Supported_Protocols/index.md b/docs/en/030_Supported_Protocols/index.md
index 7e58df6e..b9a9e47a 100644
--- a/docs/en/030_Supported_Protocols/index.md
+++ b/docs/en/030_Supported_Protocols/index.md
@@ -1,5 +1,6 @@
The Arsse was designed from the start as a server for multiple synchronization protocols which clients can make use of. Currently the following protocols are supported:
+- [Miniflux](Miniflux)
- [Nextcloud News](Nextcloud_News)
- [Tiny Tiny RSS](Tiny_Tiny_RSS)
- [Fever](Fever)
diff --git a/docs/en/040_Compatible_Clients.md b/docs/en/040_Compatible_Clients.md
index 9122387f..cd82678b 100644
--- a/docs/en/040_Compatible_Clients.md
+++ b/docs/en/040_Compatible_Clients.md
@@ -5,10 +5,11 @@ The Arsse does not at this time have any first party clients. However, because T
Name
OS
- Protocol
+ Protocol
Notes
+ Miniflux
Nextcloud News
Tiny Tiny RSS
Fever
@@ -16,16 +17,33 @@ The Arsse does not at this time have any first party clients. However, because T
- Desktop
+ Web
+
+
+ reminiflux
+
+ ✔
+ ✘
+ ✘
+ ✘
+
+ Three-pane alternative front-end for Minflux.
+
+
+
+
+
+ Desktop
FeedReader
Linux
+ ✘
✔
✔
✘
- Excellent reader; one of the best on any platform.
+ Excellent reader; discontinued in favour of NewsFlash.
Not compatible with HTTP authentication when using TT-RSS.
@@ -33,6 +51,7 @@ The Arsse does not at this time have any first party clients. However, because T
Liferea
Linux
✘
+ ✘
✔
✘
@@ -44,6 +63,7 @@ The Arsse does not at this time have any first party clients. However, because T
Linux, macOS
✔
✔
+ ✔
✘
Terminal-based client.
@@ -52,11 +72,12 @@ The Arsse does not at this time have any first party clients. However, because T
NewsFlash
Linux
+ ✔
✘
✘
✔
- Successor to FeedReader.
+ Successor to FeedReader. One of the best on any platform
@@ -64,6 +85,7 @@ The Arsse does not at this time have any first party clients. However, because T
macOS
✘
✘
+ ✘
✔
Also available for iOS.
@@ -72,11 +94,12 @@ The Arsse does not at this time have any first party clients. However, because T
RSS Guard
Windows, macOS, Linux
+ ✘
✔
✔
✘
- Very basic client; now discontinued.
+ Very basic client.
@@ -84,6 +107,7 @@ The Arsse does not at this time have any first party clients. However, because T
Tiny Tiny RSS Reader
Windows
✘
+ ✘
✔
✘
@@ -93,11 +117,12 @@ The Arsse does not at this time have any first party clients. However, because T
- Mobile
+ Mobile
CloudNews
iOS
+ ✘
✔
✘
✘
@@ -109,6 +134,7 @@ The Arsse does not at this time have any first party clients. However, because T
FeedMe
Android
✘
+ ✘
✔
✘
@@ -119,6 +145,7 @@ The Arsse does not at this time have any first party clients. However, because T
Fiery Feeds
iOS
✘
+ ✘
✔
✔
@@ -126,9 +153,28 @@ The Arsse does not at this time have any first party clients. However, because T
Currently keeps showing items in the unread badge which have already been read.
+
+ Microflux for Miniflux
+ Android
+ ✔
+ ✘
+ ✘
+ ✘
+
+
+
+ Miniflutt
+ Android
+ ✔
+ ✘
+ ✘
+ ✘
+
+
Newsout
Android, iOS
+ ✘
✔
✘
✘
@@ -139,6 +185,7 @@ The Arsse does not at this time have any first party clients. However, because T
Nextcloud News
Android
+ ✘
✔
✘
✘
@@ -149,6 +196,7 @@ The Arsse does not at this time have any first party clients. However, because T
OCReader
Android
+ ✘
✔
✘
✘
@@ -159,16 +207,29 @@ The Arsse does not at this time have any first party clients. However, because T
Android
✘
✘
+ ✘
✔
Fetches favicons independently.
+
+ Reed
+ Android
+ ✔
+ ✘
+ ✘
+ ✘
+
+ Binaries only available from GitHub.
+
+
Reeder
iOS
✘
✘
+ ✘
✔
Also available for macOS.
@@ -178,6 +239,7 @@ The Arsse does not at this time have any first party clients. However, because T
Tiny Tiny RSS
Android
✘
+ ✘
✔
✘
@@ -188,6 +250,7 @@ The Arsse does not at this time have any first party clients. However, because T
TTRSS-Reader
Android
✘
+ ✘
✔
✘
@@ -199,6 +262,7 @@ The Arsse does not at this time have any first party clients. However, because T
iOS
✘
✘
+ ✘
✔
Trialware with one-time purchase.
@@ -214,10 +278,11 @@ The Arsse does not at this time have any first party clients. However, because T
Name
OS
- Protocol
+ Protocol
Notes
+ Miniflux
Nextcloud News
Tiny Tiny RSS
Fever
@@ -228,15 +293,30 @@ The Arsse does not at this time have any first party clients. However, because T
FeedTheMonkey
Linux
✘
+ ✘
✔
✘
+
Newsie
Ubuntu Touch
+ ✘
✔
✘
✘
@@ -249,6 +329,7 @@ The Arsse does not at this time have any first party clients. However, because T
macOS
✘
✘
+ ✘
✔
Requires purchase. Presumed to work.
@@ -259,6 +340,7 @@ The Arsse does not at this time have any first party clients. However, because T
Windows
✘
✘
+ ✘
✔
Requires manual configuration.
@@ -268,6 +350,7 @@ The Arsse does not at this time have any first party clients. However, because T
tiny Reader RSS
iOS
✘
+ ✘
✔
✘
diff --git a/docs/theme/arsse/arsse.css b/docs/theme/arsse/arsse.css
index 94b17ab9..9971fbaa 100644
--- a/docs/theme/arsse/arsse.css
+++ b/docs/theme/arsse/arsse.css
@@ -1,2 +1,2 @@
/*! normalize.css v4.1.1 | MIT License | github.com/necolas/normalize.css */
-html{font-family:sans-serif;-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%;font-size:14px}body{margin:0;padding:0}article,aside,details,figcaption,figure,footer,header,main,menu,nav,section,summary{display:block}audio,canvas,progress,video{display:inline-block}audio:not([controls]){display:none;height:0}progress,sub,sup{vertical-align:baseline}.s-content pre code:after,.s-content pre code:before,[hidden],template{display:none}a{background-color:transparent;-webkit-text-decoration-skip:objects;text-decoration:none;color:#e63c2f}a:active,a:hover{outline-width:0}abbr[title]{border-bottom:none;-webkit-text-decoration:underline dotted;text-decoration:underline dotted}b,strong{font-weight:bolder}.s-content blockquote cite,dfn{font-style:italic}h1{font-size:2em;margin:.67em 0}mark{background-color:#ff0;color:#000}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}img{border-style:none}svg:not(:root){overflow:hidden}code,kbd,pre,samp{font-family:monospace,monospace;font-size:1em}figure{margin:1em 40px}hr{box-sizing:content-box;height:0;clear:both;margin:1em 0;border:0;border-top:1px solid #ddd}button,input,select,textarea{font:inherit;margin:0}optgroup{font-weight:700}button,hr,input{overflow:visible}button,select{text-transform:none}[type=reset],[type=submit],button,html [type=button]{-webkit-appearance:button}[type=button]::-moz-focus-inner,[type=reset]::-moz-focus-inner,[type=submit]::-moz-focus-inner,button::-moz-focus-inner{border-style:none;padding:0}[type=button]:-moz-focusring,[type=reset]:-moz-focusring,[type=submit]:-moz-focusring,button:-moz-focusring{outline:1px dotted ButtonText}fieldset{border:1px solid silver;margin:0 2px;padding:.35em .625em .75em}legend{color:inherit;display:table;max-width:100%;white-space:normal}textarea{overflow:auto}[type=checkbox],[type=radio],legend{box-sizing:border-box;padding:0}[type=number]::-webkit-inner-spin-button,[type=number]::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}[type=search]::-webkit-search-cancel-button,[type=search]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-input-placeholder{color:inherit;opacity:.54}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}*,:after,:before{box-sizing:border-box}@media (min-width:850px){html{font-size:16px}}body,html{height:100%;background-color:#fff;color:#15284b}.Columns__left{background-color:#e8d5d3}.Columns__right__content{padding:10px;background-color:#fff}@media (max-width:768px){html:not(.no-js) .Collapsible__content{height:0;overflow:hidden;transition:height 400ms ease-in-out}}.Collapsible__trigger{margin:12px;padding:7px 10px;background-color:transparent;border:0;float:right;background-image:none;-webkit-filter:none;filter:none;box-shadow:none}.Collapsible__trigger__bar{display:block;width:18px;height:2px;margin-top:2px;margin-bottom:3px;background-color:#e8d5d3}.Collapsible__trigger:hover{background-color:#93b7bb;box-shadow:none}.Collapsible__trigger:hover .Collapsible__trigger__bar{background-color:#15284b}@media screen and (min-width:769px){body{background-color:#15284b}.Navbar{position:fixed;z-index:1030;width:100%}.Collapsible__trigger{display:none!important}.Collapsible__content{display:block!important}.Columns{height:100%}.Columns:after,.Columns:before{content:" ";display:table}.Columns:after{clear:both}.Columns__left,.Columns__right{position:relative;min-height:1px;float:left;overflow:auto;height:100%}.Columns__left{width:25%;border-right:1px solid #e7e7e9;overflow-x:hidden}.Columns__right{width:75%}.Columns__right__content{padding:0 20px 20px;min-height:100%}}.Page{max-width:860px}body{font-family:"Cabin","Trebuchet MS",-apple-system,".SFNSText-Regular","San Francisco","Roboto","Segoe UI","Helvetica Neue","Lucida Grande",Arial,sans-serif;font-feature-settings:"kern" 1;-webkit-font-kerning:normal;font-kerning:normal;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;line-height:1.618}h1,h2,h3,h4,h5,h6{font-family:"League Gothic",-apple-system,".SFNSText-Regular","San Francisco","Roboto","Segoe UI","Helvetica Neue","Lucida Grande",Arial,sans-serif}.s-content h1,.s-content h2,.s-content h3,.s-content h4,.s-content h5,.s-content h6{cursor:text;line-height:1.4em;margin:2em 0 .5em}.s-content h1 code,.s-content h1 tt,.s-content h2 code,.s-content h2 tt,.s-content h3 code,.s-content h3 tt,.s-content h4 code,.s-content h4 tt,.s-content h5 code,.s-content h5 tt,.s-content h6 code,.s-content h6 tt{font-size:inherit}.s-content h1 i,.s-content h2 i,.s-content h3 i,.s-content h4 i,.s-content h5 i,.s-content h6 i{font-size:.7em}.s-content h1 p,.s-content h2 p,.s-content h3 p,.s-content h4 p,.s-content h5 p,.s-content h6 p{margin-top:0}.s-content h1{margin-top:0;font-size:2.618rem}.s-content h2{font-size:2rem}.s-content h3{font-size:1.618rem}.s-content h4,.s-content h5,.s-content h6,.s-content small{font-size:1.309rem}.s-content a{text-decoration:underline}.s-content p{margin-bottom:1.3em}.s-content ol,.s-content ul{padding-left:2em}.s-content ul p,.s-content ul ul{margin:0}.s-content dl{padding:0}.s-content dl dt{font-weight:700;font-style:italic;padding:0;margin:15px 0 5px}.s-content dl dt:first-child{padding:0}.s-content dl dd{margin:0 0 15px;padding:0 15px}.s-content blockquote{margin:.75em 2em;padding:.5em 1em;font-style:italic;border-left:.25em solid #15284b}.s-content blockquote cite:before{content:"\2014";padding-right:.5em}.s-content table{width:100%;padding:0;margin-bottom:1em;border-collapse:separate;border-spacing:2px;border:2px solid #b3aab1}.s-content table+table{margin-top:1em}.s-content table tr{background-color:#fff;margin:0;padding:0;border-top:0}.s-content table tr:nth-child(2n){background-color:transparent}.s-content table th{font-weight:700;background:#e8d5d3}.s-content table td,.s-content table th{margin:0;padding:.5em}.s-content blockquote>:first-child,.s-content dl dd>:first-child,.s-content dl dt>:first-child,.s-content ol>:first-child,.s-content table td>:first-child,.s-content table th>:first-child,.s-content ul>:first-child{margin-top:0}.s-content blockquote>:last-child,.s-content dl dd>:last-child,.s-content dl dt>:last-child,.s-content ol>:last-child,.s-content table td>:last-child,.s-content table th>:last-child,.s-content ul>:last-child{margin-bottom:0}.s-content img{max-width:100%;display:block;margin:0 auto}.s-content code{font-family:Monaco,Menlo,Consolas,"Lucida Console","Courier New",monospace;padding-top:.1rem;padding-bottom:.1rem;background:#f9f5f4;border-radius:0;box-shadow:none;display:inline-block;padding:.5ch;border:0}.s-content code:after,.s-content code:before{letter-spacing:-.2em;content:"\00a0"}.s-content pre{background:#f5f2f0;line-height:1.5em;overflow:auto;border:0;border-radius:0;padding:.75em 20px;margin:0 -20px 20px}.s-content pre code{margin:0;padding:0;white-space:pre;box-shadow:none}.s-content pre code,.s-content pre tt{background-color:transparent;border:0}.s-content ins,.s-content u{text-decoration:none;border-bottom:1px solid #15284b}.s-content del a,.s-content ins a,.s-content u a{color:inherit}a.Link--external:after{content:" " url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAAVklEQVR4Xn3PgQkAMQhDUXfqTu7kTtkpd5RA8AInfArtQ2iRXFWT2QedAfttj2FsPIOE1eCOlEuoWWjgzYaB/IkeGOrxXhqB+uA9Bfcm0lAZuh+YIeAD+cAqSz4kCMUAAAAASUVORK5CYII=)}a.Link--broken{color:red}p{margin:0 0 1em}.Button{display:inline-block;text-align:center;vertical-align:middle;touch-action:manipulation;cursor:pointer;background-image:none;border:1px solid transparent;white-space:nowrap;margin-bottom:0}.Button--small{font-size:12px;line-height:1.5;border-radius:3px}.Button--default{color:#333;background-color:#fff;border-color:#ccc}.Button--default.Button--active{color:#333;background-color:#e6e6e6;border-color:#adadad}.Brand,.Nav__item a:hover{color:#15284b;text-shadow:none}.Brand,.Navbar{background-color:#e63c2f}.Brand{display:block;padding:.75em .6em;font-size:2rem;font-family:"League Gothic",-apple-system,".SFNSText-Regular","San Francisco","Roboto","Segoe UI","Helvetica Neue","Lucida Grande",Arial,sans-serif}.Navbar{box-shadow:0 1px 5px rgba(0,0,0,.25);margin-bottom:0}.CodeToggler{padding:0 20px}.CodeToggler__text{font-size:12px;line-height:1.5;padding:6px 10px 6px 0;display:inline-block;vertical-align:middle}.no-js .CodeToggler{display:none}.Nav{margin:0;padding:0}.Nav__arrow{display:inline-block;position:relative;width:16px;margin-left:-16px}.Nav__arrow:before{position:absolute;display:block;content:"";margin:-.25em 0 0 -.4em;left:50%;top:50%;border-right:.15em solid #15284b;border-top:.15em solid #15284b;transform:rotate(45deg);transition-duration:.3s}.Nav__item,.Nav__item a{display:block}.Nav__item a{margin:0;padding:6px 15px 6px 20px;font-family:"League Gothic",-apple-system,".SFNSText-Regular","San Francisco","Roboto","Segoe UI","Helvetica Neue","Lucida Grande",Arial,sans-serif;font-weight:400;color:#15284b;text-shadow:none}.Nav__item a:hover{background-color:#93b7bb}.Nav .Nav{margin-left:15px}html:not(.no-js) .Nav .Nav{height:0;transition:height 400ms ease-in-out;overflow:hidden}.Nav .Nav .Nav__item a{margin:0 0 0 -15px;padding:3px 30px;font-family:"Cabin","Trebuchet MS",-apple-system,".SFNSText-Regular","San Francisco","Roboto","Segoe UI","Helvetica Neue","Lucida Grande",Arial,sans-serif;color:#15284b;opacity:.7}.HomepageButtons .Button--hero:hover,.Nav .Nav .Nav__item a:hover{opacity:1}.Nav .Nav .Nav__item--active a{color:#15284b}.Nav__item--active>a,.Nav__item--open>a{background-color:#93b7bb}.Nav__item--open>a>.Nav__arrow:before{margin-left:-.25em;transform:rotate(135deg)}.Page__header{margin:0 0 10px;padding:0}.Page__header:after,.Page__header:before{content:" ";display:table}.Page__header:after{clear:both}.Page__header h1{margin:0;padding:0;line-height:57px}.Page__header--separator{height:.6em}.Page__header a{text-decoration:none}.Page__header .EditOn,.Page__header .ModifiedDate{float:left;font-size:10px;color:gray}.Page__header .EditOn{float:right}.Links,.Twitter{padding:0 20px}.Links a{font-family:"League Gothic",-apple-system,".SFNSText-Regular","San Francisco","Roboto","Segoe UI","Helvetica Neue","Lucida Grande",Arial,sans-serif;font-weight:400;color:#15284b;line-height:2em}.Twitter{font:11px/18px "Helvetica Neue",Arial,sans-serif}.Twitter__button{text-decoration:none;display:inline-block;vertical-align:top;zoom:1;position:relative;height:20px;box-sizing:border-box;padding:1px 8px 1px 6px;background-color:#1b95e0;color:#fff;border-radius:3px;font-weight:500;cursor:pointer}.Twitter__button .Twitter__button__label{display:inline-block;vertical-align:top;zoom:1;margin-left:3px;white-space:nowrap}.Twitter__button svg{position:relative;top:2px;display:inline-block;width:14px;height:14px}.PoweredBy{padding:0 20px 1rem;font-size:1.309rem}.Search{position:relative}.Search__field{display:block;width:100%;height:34px;padding:6px 30px 6px 20px;color:#555;border-width:0 0 1px;border-bottom:1px solid #ccc;background:#fff;transition:border-color ease-in-out .15s}.Search__field:focus{border-color:#93b7bb;outline:0}.Search__icon{position:absolute;right:9px;top:9px;width:16px;height:16px}.Navbar .Search{float:right;margin:8px 20px}.Navbar .Search__field{box-shadow:inset 0 1px 1px rgba(0,0,0,.075);border-width:0;border-radius:4px;padding-left:10px}.TableOfContentsContainer{float:right;min-width:300px;max-width:25%;padding-left:1em}.TableOfContentsContainer__title{margin-bottom:0!important}.TableOfContentsContainer__content{border:1px solid #efefef;border-width:4px 2px 2px 6px}.TableOfContentsContainer__content>.TableOfContents>li+li{border-top:1px solid #ddd}ul.TableOfContents{font-size:1rem;padding-left:0;margin:0;list-style-type:none;border-left:6px solid #e8d5d3}ul.TableOfContents p{margin-bottom:0}ul.TableOfContents a{text-decoration:none;display:block;padding:.2em 0 .2em .75em}ul.TableOfContents .TableOfContents{padding-left:.75em}.Pager{padding-left:0;margin:1em 0;list-style:none;text-align:center}.Pager:after,.Pager:before{content:" ";display:table}.Pager,.Pager:after{clear:both}.Pager li{display:inline}.Pager li>a{display:inline-block;padding:5px 14px;background-color:#fff}.Pager li>a:focus,.Pager li>a:hover{text-decoration:none}.Pager--next>a{float:right}.Pager--prev>a{float:left}.Checkbox{position:relative;display:block;padding-left:30px;cursor:pointer}.Checkbox input{position:absolute;z-index:-1;opacity:0}.Checkbox__indicator{position:absolute;top:50%;left:0;width:20px;height:20px;margin-top:-10px;background:#e6e6e6}.Checkbox__indicator:after{position:absolute;display:none;content:""}.Checkbox input:focus~.Checkbox__indicator,.Checkbox:hover input~.Checkbox__indicator{background:#ccc}.Checkbox input:checked~.Checkbox__indicator{background:#15284b}.Checkbox input:checked~.Checkbox__indicator:after{display:block}.Checkbox input:checked:focus~.Checkbox__indicator,.Checkbox:hover input:not([disabled]):checked~.Checkbox__indicator{background:#93b7bb}.Checkbox input:disabled~.Checkbox__indicator{pointer-events:none;opacity:.6;background:#e6e6e6}.Checkbox .Checkbox__indicator:after{top:4px;left:8px;width:5px;height:10px;transform:rotate(45deg);border:solid #fff;border-width:0 2px 2px 0}.Checkbox input:disabled~.Checkbox__indicator:after{border-color:#7b7b7b}.Hidden{display:none}.Container{margin-right:auto;margin-left:auto}.Container--inner{width:80%;margin:0 auto}@media (min-width:1200px){.Container{width:1170px}}@media (min-width:992px){.Container{width:970px}}@media (min-width:769px){.Container{width:750px}}.Homepage{background-color:#fff;border-radius:0;border:0;color:#15284b;overflow:hidden;padding-bottom:0;margin-bottom:0;box-shadow:none}.HomepageTitle h2{width:80%;font-size:30px;margin:20px auto;text-align:center}.HomepageImage img{display:block;max-width:80%;margin:0 auto;height:auto}.HomepageButtons{padding:20px 0;background-color:#e8d5d3;text-align:center}.HomepageButtons:after,.HomepageButtons:before{content:" ";display:table}.HomepageButtons:after{clear:both}.HomepageButtons .Button--hero{padding:20px 30px;border-radius:0;text-shadow:none;opacity:.8;margin:0 10px;text-transform:uppercase;border:5px solid #15284b;font-family:"League Gothic",-apple-system,".SFNSText-Regular","San Francisco","Roboto","Segoe UI","Helvetica Neue","Lucida Grande",Arial,sans-serif;background-image:none;-webkit-filter:none;filter:none;box-shadow:none}@media (max-width:768px){.HomepageButtons .Button--hero{display:block;margin-bottom:10px}}.HomepageButtons .Button--hero.Button--secondary{background-color:#93b7bb;color:#15284b}.HomepageButtons .Button--hero.Button--primary{background-color:#15284b;color:#e8d5d3}.HomepageContent{background-color:#fff;padding:40px 0}.HomepageContent ol li,.HomepageContent ul li{list-style:none;margin-bottom:.5em;position:relative}.HomepageContent ol li:before,.HomepageContent ul li:before{position:absolute;top:50%;left:-1.5em;content:"";width:0;height:0;border:.5em solid transparent;border-left:.5em solid #93b7bb;float:left;display:block;margin-top:-.5em}.HomepageContent .HeroText,.HomepageFooter__links li a{font-size:16px;font-family:"League Gothic",-apple-system,".SFNSText-Regular","San Francisco","Roboto","Segoe UI","Helvetica Neue","Lucida Grande",Arial,sans-serif}.HomepageContent .HeroText{font-weight:300;margin-bottom:20px;line-height:1.4}@media (min-width:769px){.HomepageContent{padding:40px 20px}.HomepageContent .HeroText{font-size:21px}.HomepageContent .Row{margin:0 -15px}.HomepageContent .Row__half,.HomepageContent .Row__quarter,.HomepageContent .Row__third{float:left;position:relative;min-height:1px;padding-left:15px;padding-right:15px}.HomepageContent .Row__third{width:33.333333%}.HomepageContent .Row__half{width:50%}.HomepageContent .Row__quarter{width:25%}}.HomepageFooter{background-color:#15284b;color:#93b7bb;border:0;box-shadow:none}.HomepageFooter:after,.HomepageFooter:before{content:" ";display:table}.HomepageFooter:after{clear:both}@media (max-width:768px){.HomepageFooter{padding:0 20px;text-align:center}.HomepageFooter .HomepageFooter__links{padding-left:0;list-style-type:none}}@media (min-width:769px){.HomepageFooter .HomepageFooter__links{float:left}.HomepageFooter .HomepageFooter__twitter{float:right}}.HomepageFooter__links,.HomepageFooter__twitter{margin:40px 0}.HomepageFooter__links li a{line-height:32px;font-weight:700}.HomepageFooter__links li a:hover{text-decoration:underline}.HomepageFooter .Twitter__button{margin-bottom:20px}@media print{*{text-shadow:none!important;color:#000!important;background:0 0!important;box-shadow:none!important}h1,h2,h3,h4,h5,h6{page-break-after:avoid;page-break-before:auto}blockquote,img,pre{page-break-inside:avoid}blockquote,pre{border:1px solid #999;font-style:italic}img{border:0}a,a:visited{text-decoration:underline}abbr[title]:after{content:" (" attr(title) ")"}q{quotes:none}.s-content a[href^="#"]:after,q:before{content:""}q:after{content:" (" attr(cite) ")"}.PageBreak{display:block;page-break-before:always}.NoPrint,.Pager,aside{display:none}.Columns__right{width:100%!important}.s-content a:after{content:" (" attr(href) ")";font-size:80%;word-wrap:break-word}h1 a[href]:after{font-size:50%}}@font-face{font-family:'League Gothic';src:url(fonts/leaguegothic.woff2) format('woff2'),url(fonts/leaguegothic.woff) format('woff');font-style:normal;font-display:swap}@font-face{font-family:'Cabin';src:url(fonts/cabin-regular.woff2) format('woff2'),url(fonts/cabin-regular.woff) format('woff');font-weight:400;font-style:normal;font-display:swap}@font-face{font-family:'Cabin';src:url(fonts/cabin-italic.woff2) format('woff2'),url(fonts/cabin-italic.woff) format('woff');font-style:italic;font-display:swap}@font-face{font-family:'Cabin';src:url(fonts/cabin-bold.woff2) format('woff2'),url(fonts/cabin-bold.woff) format('woff');font-weight:700;font-style:normal;font-display:swap}.s-content code::after,.s-content code::before,a.Link--external::after{content:''}pre .s-content code{display:inline}.s-content table tbody,.s-content table thead{background-color:#fff}.s-content table tr:nth-child(2n) td{background-color:#f9f5f4}.s-content table td,.s-content table th{border:0}.Nav__item .Nav__item,.s-content table{font-size:1rem}.Brand,h1,h2,h3,h4,h5,h6{font-weight:400}.Button,.Pager li>a{border-radius:0}.HomepageButtons .Button--hero{font-weight:400;font-size:1.309rem}.Page__header{border-bottom:2px solid #e8d5d3}.Pager li>a{border:2px solid #e8d5d3}.Pager li>a:focus,.Pager li>a:hover{background-color:#e8d5d3}.Pager--prev a::before{content:"\2190\00a0"}.Pager--next a::after{content:"\00a0\2192"}.Navbar{height:auto;box-shadow:none}.Navbar .Brand{float:none;line-height:inherit;height:auto}.Homepage{padding-top:10px!important}.Nav__item{font-size:1.309rem}.Nav .Nav .Nav__item a .Nav__arrow:before,.Nav__arrow:before{font-family:"League Gothic",-apple-system,".SFNSText-Regular","San Francisco","Roboto","Segoe UI","Helvetica Neue","Lucida Grande",Arial,sans-serif;width:1ch;height:1ch}.TableOfContentsContainer__title{border-bottom:4px solid #e8d5d3}.Columns__right--full .TableOfContentsContainer .TableOfContentsContainer__content>.TableOfContents{border-right:2px solid #e8d5d3}.Columns__right--full .TableOfContentsContainer a{border-bottom:1px solid #e8d5d3}.clients thead tr:first-child th{text-align:left}.clients thead tr:first-child th:first-child{width:15%}.clients thead tr:first-child th:nth-child(3){width:50%;text-align:center}.clients thead tr+tr th{width:16.66%;text-align:center}.clients tbody td:nth-child(3),.clients tbody td:nth-child(4),.clients tbody td:nth-child(5){text-align:center}.clients tbody td.Y{color:#2c9a42}.clients tbody td.N{color:#e63c2f}.hljs,.s-content pre{background:#15284b;color:#e8d5d3}.hljs{display:block;overflow-x:auto;padding:.5em}.hljs-emphasis{font-style:italic}.hljs-strong{font-weight:700}.hljs-comment,.hljs-quote{color:#978e9c}.hljs-addition,.hljs-keyword,.hljs-selector-tag{color:#acb39a}.hljs-doctag,.hljs-literal,.hljs-meta .hljs-meta-string,.hljs-number,.hljs-regexp,.hljs-string{color:#93b7bb}.hljs-name,.hljs-section,.hljs-selector-class,.hljs-selector-id,.hljs-title{color:#82b7e5}.hljs-attr,.hljs-attribute,.hljs-class .hljs-title,.hljs-template-variable,.hljs-type,.hljs-variable{color:#c5b031}.hljs-bullet,.hljs-link,.hljs-meta,.hljs-meta .hljs-keyword,.hljs-selector-attr,.hljs-selector-pseudo,.hljs-subst,.hljs-symbol{color:#ea8031}.hljs-built_in,.hljs-deletion{color:#e63c2f}.hljs-formula{background:#686986}@media (min-width:850px){.Columns__left{border:0}}
\ No newline at end of file
+html{font-family:sans-serif;-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%;font-size:14px}body{margin:0;padding:0}article,aside,details,figcaption,figure,footer,header,main,menu,nav,section,summary{display:block}audio,canvas,progress,video{display:inline-block}audio:not([controls]){display:none;height:0}progress,sub,sup{vertical-align:baseline}.s-content pre code:after,.s-content pre code:before,[hidden],template{display:none}a{background-color:transparent;-webkit-text-decoration-skip:objects;text-decoration:none;color:#e63c2f}a:active,a:hover{outline-width:0}abbr[title]{border-bottom:none;-webkit-text-decoration:underline dotted;text-decoration:underline dotted}b,strong{font-weight:bolder}.s-content blockquote cite,dfn{font-style:italic}h1{font-size:2em;margin:.67em 0}mark{background-color:#ff0;color:#000}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}img{border-style:none}svg:not(:root){overflow:hidden}code,kbd,pre,samp{font-family:monospace,monospace;font-size:1em}figure{margin:1em 40px}hr{box-sizing:content-box;height:0;clear:both;margin:1em 0;border:0;border-top:1px solid #ddd}button,input,select,textarea{font:inherit;margin:0}optgroup{font-weight:700}button,hr,input{overflow:visible}button,select{text-transform:none}[type=reset],[type=submit],button,html [type=button]{-webkit-appearance:button}[type=button]::-moz-focus-inner,[type=reset]::-moz-focus-inner,[type=submit]::-moz-focus-inner,button::-moz-focus-inner{border-style:none;padding:0}[type=button]:-moz-focusring,[type=reset]:-moz-focusring,[type=submit]:-moz-focusring,button:-moz-focusring{outline:1px dotted ButtonText}fieldset{border:1px solid silver;margin:0 2px;padding:.35em .625em .75em}legend{color:inherit;display:table;max-width:100%;white-space:normal}textarea{overflow:auto}[type=checkbox],[type=radio],legend{box-sizing:border-box;padding:0}[type=number]::-webkit-inner-spin-button,[type=number]::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}[type=search]::-webkit-search-cancel-button,[type=search]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-input-placeholder{color:inherit;opacity:.54}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}*,:after,:before{box-sizing:border-box}@media (min-width:850px){html{font-size:16px}}body,html{height:100%;background-color:#fff;color:#15284b}.Columns__left{background-color:#e8d5d3}.Columns__right__content{padding:10px;background-color:#fff}@media (max-width:768px){html:not(.no-js) .Collapsible__content{height:0;overflow:hidden;transition:height 400ms ease-in-out}}.Collapsible__trigger{margin:12px;padding:7px 10px;background-color:transparent;border:0;float:right;background-image:none;filter:none;box-shadow:none}.Collapsible__trigger__bar{display:block;width:18px;height:2px;margin-top:2px;margin-bottom:3px;background-color:#e8d5d3}.Collapsible__trigger:hover{background-color:#93b7bb;box-shadow:none}.Collapsible__trigger:hover .Collapsible__trigger__bar{background-color:#15284b}@media screen and (min-width:769px){body{background-color:#15284b}.Navbar{position:fixed;z-index:1030;width:100%}.Collapsible__trigger{display:none!important}.Collapsible__content{display:block!important}.Columns{height:100%}.Columns:after,.Columns:before{content:" ";display:table}.Columns:after{clear:both}.Columns__left,.Columns__right{position:relative;min-height:1px;float:left;overflow:auto;height:100%}.Columns__left{width:25%;border-right:1px solid #e7e7e9;overflow-x:hidden}.Columns__right{width:75%}.Columns__right__content{padding:0 20px 20px;min-height:100%}}.Page{max-width:860px}body{font-family:"Cabin","Trebuchet MS",-apple-system,".SFNSText-Regular","San Francisco","Roboto","Segoe UI","Helvetica Neue","Lucida Grande",Arial,sans-serif;font-feature-settings:"kern" 1;-webkit-font-kerning:normal;font-kerning:normal;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;line-height:1.618}h1,h2,h3,h4,h5,h6{font-family:"League Gothic",-apple-system,".SFNSText-Regular","San Francisco","Roboto","Segoe UI","Helvetica Neue","Lucida Grande",Arial,sans-serif}.s-content h1,.s-content h2,.s-content h3,.s-content h4,.s-content h5,.s-content h6{cursor:text;line-height:1.4em;margin:2em 0 .5em}.s-content h1 code,.s-content h1 tt,.s-content h2 code,.s-content h2 tt,.s-content h3 code,.s-content h3 tt,.s-content h4 code,.s-content h4 tt,.s-content h5 code,.s-content h5 tt,.s-content h6 code,.s-content h6 tt{font-size:inherit}.s-content h1 i,.s-content h2 i,.s-content h3 i,.s-content h4 i,.s-content h5 i,.s-content h6 i{font-size:.7em}.s-content h1 p,.s-content h2 p,.s-content h3 p,.s-content h4 p,.s-content h5 p,.s-content h6 p{margin-top:0}.s-content h1{margin-top:0;font-size:2.618rem}.s-content h2{font-size:2rem}.s-content h3{font-size:1.618rem}.s-content h4,.s-content h5,.s-content h6,.s-content small{font-size:1.309rem}.s-content a{text-decoration:underline}.s-content p{margin-bottom:1.3em}.s-content ol,.s-content ul{padding-left:2em}.s-content ul p,.s-content ul ul{margin:0}.s-content dl{padding:0}.s-content dl dt{font-weight:700;font-style:italic;padding:0;margin:15px 0 5px}.s-content dl dt:first-child{padding:0}.s-content dl dd{margin:0 0 15px;padding:0 15px}.s-content blockquote{margin:.75em 2em;padding:.5em 1em;font-style:italic;border-left:.25em solid #15284b}.s-content blockquote cite:before{content:"\2014";padding-right:.5em}.s-content table{width:100%;padding:0;margin-bottom:1em;border-collapse:separate;border-spacing:2px;border:2px solid #b3aab1}.s-content table+table{margin-top:1em}.s-content table tr{background-color:#fff;margin:0;padding:0;border-top:0}.s-content table tr:nth-child(2n){background-color:transparent}.s-content table th{font-weight:700;background:#e8d5d3}.s-content table td,.s-content table th{margin:0;padding:.5em}.s-content blockquote>:first-child,.s-content dl dd>:first-child,.s-content dl dt>:first-child,.s-content ol>:first-child,.s-content table td>:first-child,.s-content table th>:first-child,.s-content ul>:first-child{margin-top:0}.s-content blockquote>:last-child,.s-content dl dd>:last-child,.s-content dl dt>:last-child,.s-content ol>:last-child,.s-content table td>:last-child,.s-content table th>:last-child,.s-content ul>:last-child{margin-bottom:0}.s-content img{max-width:100%;display:block;margin:0 auto}.s-content code{font-family:Monaco,Menlo,Consolas,"Lucida Console","Courier New",monospace;padding-top:.1rem;padding-bottom:.1rem;background:#f9f5f4;border-radius:0;box-shadow:none;display:inline-block;padding:.5ch;border:0}.s-content code:after,.s-content code:before{letter-spacing:-.2em;content:"\00a0"}.s-content pre{background:#f5f2f0;line-height:1.5em;overflow:auto;border:0;border-radius:0;padding:.75em 20px;margin:0 -20px 20px}.s-content pre code{margin:0;padding:0;white-space:pre;box-shadow:none}.s-content pre code,.s-content pre tt{background-color:transparent;border:0}.s-content ins,.s-content u{text-decoration:none;border-bottom:1px solid #15284b}.s-content del a,.s-content ins a,.s-content u a{color:inherit}a.Link--external:after{content:" " url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAAVklEQVR4Xn3PgQkAMQhDUXfqTu7kTtkpd5RA8AInfArtQ2iRXFWT2QedAfttj2FsPIOE1eCOlEuoWWjgzYaB/IkeGOrxXhqB+uA9Bfcm0lAZuh+YIeAD+cAqSz4kCMUAAAAASUVORK5CYII=)}a.Link--broken{color:red}p{margin:0 0 1em}.Button{display:inline-block;text-align:center;vertical-align:middle;touch-action:manipulation;cursor:pointer;background-image:none;border:1px solid transparent;white-space:nowrap;margin-bottom:0}.Button--small{font-size:12px;line-height:1.5;border-radius:3px}.Button--default{color:#333;background-color:#fff;border-color:#ccc}.Button--default.Button--active{color:#333;background-color:#e6e6e6;border-color:#adadad}.Brand,.Nav__item a:hover{color:#15284b;text-shadow:none}.Brand,.Navbar{background-color:#e63c2f}.Brand{display:block;padding:.75em .6em;font-size:2rem;font-family:"League Gothic",-apple-system,".SFNSText-Regular","San Francisco","Roboto","Segoe UI","Helvetica Neue","Lucida Grande",Arial,sans-serif}.Navbar{box-shadow:0 1px 5px rgba(0,0,0,.25);margin-bottom:0}.CodeToggler{padding:0 20px}.CodeToggler__text{font-size:12px;line-height:1.5;padding:6px 10px 6px 0;display:inline-block;vertical-align:middle}.no-js .CodeToggler{display:none}.Nav{margin:0;padding:0}.Nav__arrow{display:inline-block;position:relative;width:16px;margin-left:-16px}.Nav__arrow:before{position:absolute;display:block;content:"";margin:-.25em 0 0 -.4em;left:50%;top:50%;border-right:.15em solid #15284b;border-top:.15em solid #15284b;transform:rotate(45deg);transition-duration:.3s}.Nav__item,.Nav__item a{display:block}.Nav__item a{margin:0;padding:6px 15px 6px 20px;font-family:"League Gothic",-apple-system,".SFNSText-Regular","San Francisco","Roboto","Segoe UI","Helvetica Neue","Lucida Grande",Arial,sans-serif;font-weight:400;color:#15284b;text-shadow:none}.Nav__item a:hover{background-color:#93b7bb}.Nav .Nav{margin-left:15px}html:not(.no-js) .Nav .Nav{height:0;transition:height 400ms ease-in-out;overflow:hidden}.Nav .Nav .Nav__item a{margin:0 0 0 -15px;padding:3px 30px;font-family:"Cabin","Trebuchet MS",-apple-system,".SFNSText-Regular","San Francisco","Roboto","Segoe UI","Helvetica Neue","Lucida Grande",Arial,sans-serif;color:#15284b;opacity:.7}.HomepageButtons .Button--hero:hover,.Nav .Nav .Nav__item a:hover{opacity:1}.Nav .Nav .Nav__item--active a{color:#15284b}.Nav__item--active>a,.Nav__item--open>a{background-color:#93b7bb}.Nav__item--open>a>.Nav__arrow:before{margin-left:-.25em;transform:rotate(135deg)}.Page__header{margin:0 0 10px;padding:0}.Page__header:after,.Page__header:before{content:" ";display:table}.Page__header:after{clear:both}.Page__header h1{margin:0;padding:0;line-height:57px}.Page__header--separator{height:.6em}.Page__header a{text-decoration:none}.Page__header .EditOn,.Page__header .ModifiedDate{float:left;font-size:10px;color:gray}.Page__header .EditOn{float:right}.Links,.Twitter{padding:0 20px}.Links a{font-family:"League Gothic",-apple-system,".SFNSText-Regular","San Francisco","Roboto","Segoe UI","Helvetica Neue","Lucida Grande",Arial,sans-serif;font-weight:400;color:#15284b;line-height:2em}.Twitter{font:11px/18px "Helvetica Neue",Arial,sans-serif}.Twitter__button{text-decoration:none;display:inline-block;vertical-align:top;zoom:1;position:relative;height:20px;box-sizing:border-box;padding:1px 8px 1px 6px;background-color:#1b95e0;color:#fff;border-radius:3px;font-weight:500;cursor:pointer}.Twitter__button .Twitter__button__label{display:inline-block;vertical-align:top;zoom:1;margin-left:3px;white-space:nowrap}.Twitter__button svg{position:relative;top:2px;display:inline-block;width:14px;height:14px}.PoweredBy{padding:0 20px 1rem;font-size:1.309rem}.Search{position:relative}.Search__field{display:block;width:100%;height:34px;padding:6px 30px 6px 20px;color:#555;border-width:0 0 1px;border-bottom:1px solid #ccc;background:#fff;transition:border-color ease-in-out .15s}.Search__field:focus{border-color:#93b7bb;outline:0}.Search__icon{position:absolute;right:9px;top:9px;width:16px;height:16px}.Navbar .Search{float:right;margin:8px 20px}.Navbar .Search__field{box-shadow:inset 0 1px 1px rgba(0,0,0,.075);border-width:0;border-radius:4px;padding-left:10px}.TableOfContentsContainer{float:right;min-width:300px;max-width:25%;padding-left:1em}.TableOfContentsContainer__title{margin-bottom:0!important}.TableOfContentsContainer__content{border:1px solid #efefef;border-width:4px 2px 2px 6px}.TableOfContentsContainer__content>.TableOfContents>li+li{border-top:1px solid #ddd}ul.TableOfContents{font-size:1rem;padding-left:0;margin:0;list-style-type:none;border-left:6px solid #e8d5d3}ul.TableOfContents p{margin-bottom:0}ul.TableOfContents a{text-decoration:none;display:block;padding:.2em 0 .2em .75em}ul.TableOfContents .TableOfContents{padding-left:.75em}.Pager{padding-left:0;margin:1em 0;list-style:none;text-align:center}.Pager:after,.Pager:before{content:" ";display:table}.Pager,.Pager:after{clear:both}.Pager li{display:inline}.Pager li>a{display:inline-block;padding:5px 14px;background-color:#fff}.Pager li>a:focus,.Pager li>a:hover{text-decoration:none}.Pager--next>a{float:right}.Pager--prev>a{float:left}.Checkbox{position:relative;display:block;padding-left:30px;cursor:pointer}.Checkbox input{position:absolute;z-index:-1;opacity:0}.Checkbox__indicator{position:absolute;top:50%;left:0;width:20px;height:20px;margin-top:-10px;background:#e6e6e6}.Checkbox__indicator:after{position:absolute;display:none;content:""}.Checkbox input:focus~.Checkbox__indicator,.Checkbox:hover input~.Checkbox__indicator{background:#ccc}.Checkbox input:checked~.Checkbox__indicator{background:#15284b}.Checkbox input:checked~.Checkbox__indicator:after{display:block}.Checkbox input:checked:focus~.Checkbox__indicator,.Checkbox:hover input:not([disabled]):checked~.Checkbox__indicator{background:#93b7bb}.Checkbox input:disabled~.Checkbox__indicator{pointer-events:none;opacity:.6;background:#e6e6e6}.Checkbox .Checkbox__indicator:after{top:4px;left:8px;width:5px;height:10px;transform:rotate(45deg);border:solid #fff;border-width:0 2px 2px 0}.Checkbox input:disabled~.Checkbox__indicator:after{border-color:#7b7b7b}.Hidden{display:none}.Container{margin-right:auto;margin-left:auto}.Container--inner{width:80%;margin:0 auto}@media (min-width:1200px){.Container{width:1170px}}@media (min-width:992px){.Container{width:970px}}@media (min-width:769px){.Container{width:750px}}.Homepage{background-color:#fff;border-radius:0;border:0;color:#15284b;overflow:hidden;padding-bottom:0;margin-bottom:0;box-shadow:none}.HomepageTitle h2{width:80%;font-size:30px;margin:20px auto;text-align:center}.HomepageImage img{display:block;max-width:80%;margin:0 auto;height:auto}.HomepageButtons{padding:20px 0;background-color:#e8d5d3;text-align:center}.HomepageButtons:after,.HomepageButtons:before{content:" ";display:table}.HomepageButtons:after{clear:both}.HomepageButtons .Button--hero{padding:20px 30px;border-radius:0;text-shadow:none;opacity:.8;margin:0 10px;text-transform:uppercase;border:5px solid #15284b;font-family:"League Gothic",-apple-system,".SFNSText-Regular","San Francisco","Roboto","Segoe UI","Helvetica Neue","Lucida Grande",Arial,sans-serif;background-image:none;filter:none;box-shadow:none}@media (max-width:768px){.HomepageButtons .Button--hero{display:block;margin-bottom:10px}}.HomepageButtons .Button--hero.Button--secondary{background-color:#93b7bb;color:#15284b}.HomepageButtons .Button--hero.Button--primary{background-color:#15284b;color:#e8d5d3}.HomepageContent{background-color:#fff;padding:40px 0}.HomepageContent ol li,.HomepageContent ul li{list-style:none;margin-bottom:.5em;position:relative}.HomepageContent ol li:before,.HomepageContent ul li:before{position:absolute;top:50%;left:-1.5em;content:"";width:0;height:0;border:.5em solid transparent;border-left:.5em solid #93b7bb;float:left;display:block;margin-top:-.5em}.HomepageContent .HeroText,.HomepageFooter__links li a{font-size:16px;font-family:"League Gothic",-apple-system,".SFNSText-Regular","San Francisco","Roboto","Segoe UI","Helvetica Neue","Lucida Grande",Arial,sans-serif}.HomepageContent .HeroText{font-weight:300;margin-bottom:20px;line-height:1.4}@media (min-width:769px){.HomepageContent{padding:40px 20px}.HomepageContent .HeroText{font-size:21px}.HomepageContent .Row{margin:0 -15px}.HomepageContent .Row__half,.HomepageContent .Row__quarter,.HomepageContent .Row__third{float:left;position:relative;min-height:1px;padding-left:15px;padding-right:15px}.HomepageContent .Row__third{width:33.333333%}.HomepageContent .Row__half{width:50%}.HomepageContent .Row__quarter{width:25%}}.HomepageFooter{background-color:#15284b;color:#93b7bb;border:0;box-shadow:none}.HomepageFooter:after,.HomepageFooter:before{content:" ";display:table}.HomepageFooter:after{clear:both}@media (max-width:768px){.HomepageFooter{padding:0 20px;text-align:center}.HomepageFooter .HomepageFooter__links{padding-left:0;list-style-type:none}}@media (min-width:769px){.HomepageFooter .HomepageFooter__links{float:left}.HomepageFooter .HomepageFooter__twitter{float:right}}.HomepageFooter__links,.HomepageFooter__twitter{margin:40px 0}.HomepageFooter__links li a{line-height:32px;font-weight:700}.HomepageFooter__links li a:hover{text-decoration:underline}.HomepageFooter .Twitter__button{margin-bottom:20px}@media print{*{text-shadow:none!important;color:#000!important;background:0 0!important;box-shadow:none!important}h1,h2,h3,h4,h5,h6{page-break-after:avoid;page-break-before:auto}blockquote,img,pre{page-break-inside:avoid}blockquote,pre{border:1px solid #999;font-style:italic}img{border:0}a,a:visited{text-decoration:underline}abbr[title]:after{content:" (" attr(title) ")"}q{quotes:none}.s-content a[href^="#"]:after,q:before{content:""}q:after{content:" (" attr(cite) ")"}.PageBreak{display:block;page-break-before:always}.NoPrint,.Pager,aside{display:none}.Columns__right{width:100%!important}.s-content a:after{content:" (" attr(href) ")";font-size:80%;word-wrap:break-word}h1 a[href]:after{font-size:50%}}@font-face{font-family:'League Gothic';src:url(fonts/leaguegothic.woff2) format('woff2'),url(fonts/leaguegothic.woff) format('woff');font-style:normal;font-display:swap}@font-face{font-family:'Cabin';src:url(fonts/cabin-regular.woff2) format('woff2'),url(fonts/cabin-regular.woff) format('woff');font-weight:400;font-style:normal;font-display:swap}@font-face{font-family:'Cabin';src:url(fonts/cabin-italic.woff2) format('woff2'),url(fonts/cabin-italic.woff) format('woff');font-style:italic;font-display:swap}@font-face{font-family:'Cabin';src:url(fonts/cabin-bold.woff2) format('woff2'),url(fonts/cabin-bold.woff) format('woff');font-weight:700;font-style:normal;font-display:swap}.s-content code::after,.s-content code::before,a.Link--external::after{content:''}pre .s-content code{display:inline}.s-content table tbody,.s-content table thead{background-color:#fff}.s-content table tr:nth-child(2n) td{background-color:#f9f5f4}.s-content table td,.s-content table th{border:0}.Nav__item .Nav__item,.s-content table{font-size:1rem}.Brand,h1,h2,h3,h4,h5,h6{font-weight:400}.Button,.Pager li>a{border-radius:0}.HomepageButtons .Button--hero{font-weight:400;font-size:1.309rem}.Page__header{border-bottom:2px solid #e8d5d3}.Pager li>a{border:2px solid #e8d5d3}.Pager li>a:focus,.Pager li>a:hover{background-color:#e8d5d3}.Pager--prev a::before{content:"\2190\00a0"}.Pager--next a::after{content:"\00a0\2192"}.Navbar{height:auto;box-shadow:none}.Navbar .Brand{float:none;line-height:inherit;height:auto}.Homepage{padding-top:10px!important}.Nav__item{font-size:1.309rem}.Nav .Nav .Nav__item a .Nav__arrow:before,.Nav__arrow:before{font-family:"League Gothic",-apple-system,".SFNSText-Regular","San Francisco","Roboto","Segoe UI","Helvetica Neue","Lucida Grande",Arial,sans-serif;width:1ch;height:1ch}.TableOfContentsContainer__title{border-bottom:4px solid #e8d5d3}.Columns__right--full .TableOfContentsContainer .TableOfContentsContainer__content>.TableOfContents{border-right:2px solid #e8d5d3}.Columns__right--full .TableOfContentsContainer a{border-bottom:1px solid #e8d5d3}.clients thead tr:first-child th{text-align:left}.clients thead tr:first-child th:first-child{width:15%}.clients thead tr:first-child th:nth-child(3){width:50%;text-align:center}.clients thead tr+tr th{width:12%;text-align:center}.clients tbody td:nth-child(3),.clients tbody td:nth-child(4),.clients tbody td:nth-child(5),.clients tbody td:nth-child(6){text-align:center}.clients tbody td.Y{color:#2c9a42}.clients tbody td.N{color:#e63c2f}.hljs,.s-content pre{background:#15284b;color:#e8d5d3}.hljs{display:block;overflow-x:auto;padding:.5em}.hljs-emphasis{font-style:italic}.hljs-strong{font-weight:700}.hljs-comment,.hljs-quote{color:#978e9c}.hljs-addition,.hljs-keyword,.hljs-selector-tag{color:#acb39a}.hljs-doctag,.hljs-literal,.hljs-meta .hljs-meta-string,.hljs-number,.hljs-regexp,.hljs-string{color:#93b7bb}.hljs-name,.hljs-section,.hljs-selector-class,.hljs-selector-id,.hljs-title{color:#82b7e5}.hljs-attr,.hljs-attribute,.hljs-class .hljs-title,.hljs-template-variable,.hljs-type,.hljs-variable{color:#c5b031}.hljs-bullet,.hljs-link,.hljs-meta,.hljs-meta .hljs-keyword,.hljs-selector-attr,.hljs-selector-pseudo,.hljs-subst,.hljs-symbol{color:#ea8031}.hljs-built_in,.hljs-deletion{color:#e63c2f}.hljs-formula{background:#686986}@media (min-width:850px){.Columns__left{border:0}}
\ No newline at end of file
diff --git a/docs/theme/src/arsse.scss b/docs/theme/src/arsse.scss
index 43a26c19..6f5d9d83 100644
--- a/docs/theme/src/arsse.scss
+++ b/docs/theme/src/arsse.scss
@@ -245,12 +245,12 @@ ul.TableOfContents {
}
thead tr + tr th {
- width: 16.66%;
+ width: 12%;
text-align: center;
}
tbody td {
- &:nth-child(3), &:nth-child(4), &:nth-child(5) {
+ &:nth-child(3), &:nth-child(4), &:nth-child(5), &:nth-child(6) {
text-align: center;
}
From 3d3c20de5cb7381af703a0b10de02a93dc53eab1 Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Fri, 6 Nov 2020 15:57:27 -0500
Subject: [PATCH 025/366] Don't anticipate API features
---
lib/Database.php | 34 +++++-----------------------------
1 file changed, 5 insertions(+), 29 deletions(-)
diff --git a/lib/Database.php b/lib/Database.php
index b852ecc5..6732ecab 100644
--- a/lib/Database.php
+++ b/lib/Database.php
@@ -1102,7 +1102,7 @@ class Database {
$tr = $this->db->begin();
$icon = null;
if ($feed->iconUrl) {
- $icon = $this->iconGetByUrl($feed->iconUrl);
+ $icon = $this->db->prepare("SELECT id, url, type, data from arsse_icons where url = ?", "str")->run($feed->iconUrl)->getRow();
if ($icon) {
// update the existing icon if necessary
if ($feed->iconType !== $icon['type'] || $feed->iconData !== $icon['data']) {
@@ -1260,38 +1260,14 @@ class Database {
)->run($feedID, $vId, $vHashUT, $vHashUC, $vHashTC);
}
- /** Retrieve a feed icon by URL, for use during feed refreshing
+ /** Lists icons for feeds to which a user is subscribed
*
- * @param string $url The URL of the icon to retrieve
- */
- protected function iconGetByUrl(string $url): ?array {
- return $this->db->prepare("SELECT id, url, type, data from arsse_icons where url = ?", "str")->run($url)->getRow();
- }
-
- /** Retrieves information about an icon
+ * The returned information for each icon is:
*
- * The returned information is:
- *
- * - "id": The umeric identifier of the icon (not the subscription)
+ * - "id": The umeric identifier of the icon
* - "url": The URL of the icon
* - "type": The Content-Type of the icon e.g. "image/png"
- * - "data": The icon itself, as a binary sring; if $withData is false this will be null
- *
- * @param string $user The user whose icon is to be retrieved
- * @param int $subscription The numeric identifier of the icon
- */
- public function iconGet(string $user, int $id): array {
- if (!Arsse::$user->authorize($user, __FUNCTION__)) {
- throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
- }
- $out = $this->db->prepare("SELECT i.id, i.url, i.type, i.data from arsse_icons as i join arsse_feeds as f on i.id = f.icon join arsse_subscriptions as s on s.feed = f.id where s.owner = ? and i.id = ?", "str", "int")->run($user, $id)->getRow();
- if (!$out) {
- throw new Db\ExceptionInput("subjectMissing", ["action" => __FUNCTION__, "field" => "subscription", 'id' => $id]);
- }
- return $out;
- }
-
- /** Lists icons for feeds to which a user is subscribed, with or without the binary content of the icon itself
+ * - "data": The icon itself, as a binary sring
*
* @param string $user The user whose subscription icons are to be retrieved
*/
From 311910795ab9fdcb4a915fc928f286c066f01369 Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Fri, 6 Nov 2020 17:06:01 -0500
Subject: [PATCH 026/366] More tests for icon cache
---
lib/Database.php | 2 +-
lib/Service.php | 2 ++
tests/cases/Database/SeriesCleanup.php | 13 ++++++++++
tests/cases/Database/SeriesIcon.php | 34 ++++++++++++++++++--------
tests/cases/Service/TestService.php | 2 ++
5 files changed, 42 insertions(+), 11 deletions(-)
diff --git a/lib/Database.php b/lib/Database.php
index 6732ecab..6844541e 100644
--- a/lib/Database.php
+++ b/lib/Database.php
@@ -1275,7 +1275,7 @@ class Database {
if (!Arsse::$user->authorize($user, __FUNCTION__)) {
throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
}
- return $this->db->prepare("SELECT i.id, i.url, i.type, i.data from arsse_icons as i join arsse_feeds as f on i.id = f.icon join arsse_subscriptions as s on s.feed = f.id where s.owner = ?", "str")->run($user);
+ return $this->db->prepare("SELECT distinct i.id, i.url, i.type, i.data from arsse_icons as i join arsse_feeds as f on i.id = f.icon join arsse_subscriptions as s on s.feed = f.id where s.owner = ?", "str")->run($user);
}
/** Deletes orphaned icons from the database
diff --git a/lib/Service.php b/lib/Service.php
index 597421fd..a69b12c0 100644
--- a/lib/Service.php
+++ b/lib/Service.php
@@ -72,6 +72,8 @@ class Service {
public static function cleanupPre(): bool {
// mark unsubscribed feeds as orphaned and delete orphaned feeds that are beyond their retention period
Arsse::$db->feedCleanup();
+ // do the same for icons
+ Arsse::$db->iconCleanup();
// delete expired log-in sessions
Arsse::$db->sessionCleanup();
return true;
diff --git a/tests/cases/Database/SeriesCleanup.php b/tests/cases/Database/SeriesCleanup.php
index b31f87c7..ad1d7f14 100644
--- a/tests/cases/Database/SeriesCleanup.php
+++ b/tests/cases/Database/SeriesCleanup.php
@@ -66,6 +66,19 @@ trait SeriesCleanup {
["da772f8fa13c11e78667001e673b2560", "class.class", "john.doe@example.com", $soon],
],
],
+ 'arsse_icons' => [
+ 'columns' => [
+ 'id' => "int",
+ 'url' => "str",
+ 'orphaned' => "datetime",
+ ],
+ 'rows' => [
+ [1,'http://localhost:8000/Icon/PNG',null],
+ [2,'http://localhost:8000/Icon/GIF',null],
+ [3,'http://localhost:8000/Icon/SVG1',null],
+ [4,'http://localhost:8000/Icon/SVG2',null],
+ ],
+ ],
'arsse_feeds' => [
'columns' => [
'id' => "int",
diff --git a/tests/cases/Database/SeriesIcon.php b/tests/cases/Database/SeriesIcon.php
index 7a7e348c..d54a4ab9 100644
--- a/tests/cases/Database/SeriesIcon.php
+++ b/tests/cases/Database/SeriesIcon.php
@@ -53,16 +53,11 @@ trait SeriesIcon {
'icon' => "int",
],
'rows' => [
- [1,"http://localhost:8000/Feed/Matching/3","Ook",0,"",$past,$past,0,null],
- [2,"http://localhost:8000/Feed/Matching/1","Eek",5,"There was an error last time",$past,$future,0,null],
- [3,"http://localhost:8000/Feed/Fetching/Error?code=404","Ack",0,"",$past,$now,0,null],
+ [1,"http://localhost:8000/Feed/Matching/3","Ook",0,"",$past,$past,0,1],
+ [2,"http://localhost:8000/Feed/Matching/1","Eek",5,"There was an error last time",$past,$future,0,2],
+ [3,"http://localhost:8000/Feed/Fetching/Error?code=404","Ack",0,"",$past,$now,0,3],
[4,"http://localhost:8000/Feed/NextFetch/NotModified?t=".time(),"Ooook",0,"",$past,$past,0,null],
- [5,"http://localhost:8000/Feed/Parsing/Valid","Ooook",0,"",$past,$future,0,null],
- // these feeds all test icon caching
- [6,"http://localhost:8000/Feed/WithIcon/PNG",null,0,"",$past,$future,0,1], // no change when updated
- [7,"http://localhost:8000/Feed/WithIcon/GIF",null,0,"",$past,$future,0,1], // icon ID 2 will be assigned to feed when updated
- [8,"http://localhost:8000/Feed/WithIcon/SVG1",null,0,"",$past,$future,0,3], // icon ID 3 will be modified when updated
- [9,"http://localhost:8000/Feed/WithIcon/SVG2",null,0,"",$past,$future,0,null], // icon ID 4 will be created and assigned to feed when updated
+ [5,"http://localhost:8000/Feed/Parsing/Valid","Ooook",0,"",$past,$future,0,2],
],
],
'arsse_subscriptions' => [
@@ -77,7 +72,7 @@ trait SeriesIcon {
[3,'john.doe@example.com',3],
[4,'john.doe@example.com',4],
[5,'john.doe@example.com',5],
- [6,'jane.doe@example.com',1],
+ [6,'jane.doe@example.com',5],
],
],
];
@@ -86,4 +81,23 @@ trait SeriesIcon {
protected function tearDownSeriesIcon(): void {
unset($this->data);
}
+
+ public function testListTheIconsOfAUser() {
+ $exp = [
+ ['id' => 1,'url' => 'http://localhost:8000/Icon/PNG', 'type' => 'image/png', 'data' => base64_decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAAZdEVYdFNvZnR3YXJlAHBhaW50Lm5ldCA0LjAuMjHxIGmVAAAADUlEQVQYV2NgYGBgAAAABQABijPjAAAAAABJRU5ErkJggg==")],
+ ['id' => 2,'url' => 'http://localhost:8000/Icon/GIF', 'type' => 'image/gif', 'data' => base64_decode("R0lGODlhAQABAIABAAAAAP///yH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==")],
+ ['id' => 3,'url' => 'http://localhost:8000/Icon/SVG1', 'type' => 'image/svg+xml', 'data' => ' '],
+ ];
+ $this->assertResult($exp, Arsse::$db->iconList("john.doe@example.com"));
+ $exp = [
+ ['id' => 2,'url' => 'http://localhost:8000/Icon/GIF', 'type' => 'image/gif', 'data' => base64_decode("R0lGODlhAQABAIABAAAAAP///yH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==")],
+ ];
+ $this->assertResult($exp, Arsse::$db->iconList("jane.doe@example.com"));
+ }
+
+ public function testListTheIconsOfAUserWithoutAuthority() {
+ \Phake::when(Arsse::$user)->authorize->thenReturn(false);
+ $this->assertException("notAuthorized", "User", "ExceptionAuthz");
+ Arsse::$db->iconList("jane.doe@example.com");
+ }
}
diff --git a/tests/cases/Service/TestService.php b/tests/cases/Service/TestService.php
index 804cd553..9aef50cb 100644
--- a/tests/cases/Service/TestService.php
+++ b/tests/cases/Service/TestService.php
@@ -43,6 +43,7 @@ class TestService extends \JKingWeb\Arsse\Test\AbstractTest {
public function testPerformPreCleanup(): void {
$this->assertTrue(Service::cleanupPre());
\Phake::verify(Arsse::$db)->feedCleanup();
+ \Phake::verify(Arsse::$db)->iconCleanup();
\Phake::verify(Arsse::$db)->sessionCleanup();
}
@@ -76,6 +77,7 @@ class TestService extends \JKingWeb\Arsse\Test\AbstractTest {
\Phake::verify($d)->exec();
\Phake::verify($d)->clean();
\Phake::verify(Arsse::$db)->feedCleanup();
+ \Phake::verify(Arsse::$db)->iconCleanup();
\Phake::verify(Arsse::$db)->sessionCleanup();
\Phake::verify(Arsse::$db)->articleCleanup();
\Phake::verify(Arsse::$db)->metaSet("service_last_checkin", $this->anything(), "datetime");
From 1d3725341a6fae70cdfa12a9544a08873a8a5961 Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Fri, 6 Nov 2020 19:56:32 -0500
Subject: [PATCH 027/366] Fix detection of Xdebug for coverage
---
RoboFile.php | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/RoboFile.php b/RoboFile.php
index 17456c10..31b98924 100644
--- a/RoboFile.php
+++ b/RoboFile.php
@@ -99,7 +99,7 @@ class RoboFile extends \Robo\Tasks {
return $php;
} elseif (file_exists($dir."pcov.$ext")) {
return "$php -d extension=pcov.$ext -d pcov.enabled=1 -d pcov.directory=$code";
- } elseif (file_exists($dir."pcov.$ext")) {
+ } elseif (file_exists($dir."xdebug.$ext")) {
return "$php -d zend_extension=xdebug.$ext";
} else {
if (IS_WIN) {
From b62c11a43eab6f103110346989ea44026802e4b2 Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Sat, 7 Nov 2020 08:11:06 -0500
Subject: [PATCH 028/366] Lasts tests for icon cache; fixes #177
---
lib/Database.php | 2 +-
tests/cases/Database/SeriesCleanup.php | 40 +++++++++++++++++++++-----
2 files changed, 34 insertions(+), 8 deletions(-)
diff --git a/lib/Database.php b/lib/Database.php
index 6844541e..92ddb891 100644
--- a/lib/Database.php
+++ b/lib/Database.php
@@ -1287,7 +1287,7 @@ class Database {
// first unmark any icons which are no longer orphaned; an icon is considered orphaned if it is not used or only used by feeds which are themselves orphaned
$this->db->query("UPDATE arsse_icons set orphaned = null where id in (select distinct icon from arsse_feeds where icon is not null and orphaned is null)");
// next mark any newly orphaned icons with the current date and time
- $this->db->query("UPDATE arsse_icons set orphaned = CURRENT_TIMESTAMP where id not in (select distinct icon from arsse_feeds where icon is not null and orphaned is null)");
+ $this->db->query("UPDATE arsse_icons set orphaned = CURRENT_TIMESTAMP where orphaned is null and id not in (select distinct icon from arsse_feeds where icon is not null and orphaned is null)");
// finally delete icons that have been orphaned longer than the feed retention period, if a a purge threshold has been specified
$out = 0;
if (Arsse::$conf->purgeFeeds) {
diff --git a/tests/cases/Database/SeriesCleanup.php b/tests/cases/Database/SeriesCleanup.php
index ad1d7f14..1a0e1c7e 100644
--- a/tests/cases/Database/SeriesCleanup.php
+++ b/tests/cases/Database/SeriesCleanup.php
@@ -73,10 +73,9 @@ trait SeriesCleanup {
'orphaned' => "datetime",
],
'rows' => [
- [1,'http://localhost:8000/Icon/PNG',null],
- [2,'http://localhost:8000/Icon/GIF',null],
+ [1,'http://localhost:8000/Icon/PNG',$daybefore],
+ [2,'http://localhost:8000/Icon/GIF',$daybefore],
[3,'http://localhost:8000/Icon/SVG1',null],
- [4,'http://localhost:8000/Icon/SVG2',null],
],
],
'arsse_feeds' => [
@@ -86,12 +85,13 @@ trait SeriesCleanup {
'title' => "str",
'orphaned' => "datetime",
'size' => "int",
+ 'icon' => "int",
],
'rows' => [
- [1,"http://example.com/1","",$daybefore,2], //latest two articles should be kept
- [2,"http://example.com/2","",$yesterday,0],
- [3,"http://example.com/3","",null,0],
- [4,"http://example.com/4","",$nowish,0],
+ [1,"http://example.com/1","",$daybefore,2,null], //latest two articles should be kept
+ [2,"http://example.com/2","",$yesterday,0,2],
+ [3,"http://example.com/3","",null,0,1],
+ [4,"http://example.com/4","",$nowish,0,null],
],
],
'arsse_subscriptions' => [
@@ -193,6 +193,32 @@ trait SeriesCleanup {
$this->compareExpectations(static::$drv, $state);
}
+ public function testCleanUpOrphanedIcons(): void {
+ Arsse::$db->iconCleanup();
+ $now = gmdate("Y-m-d H:i:s");
+ $state = $this->primeExpectations($this->data, [
+ 'arsse_icons' => ["id","orphaned"],
+ ]);
+ $state['arsse_icons']['rows'][0][1] = null;
+ unset($state['arsse_icons']['rows'][1]);
+ $state['arsse_icons']['rows'][2][1] = $now;
+ $this->compareExpectations(static::$drv, $state);
+ }
+
+ public function testCleanUpOrphanedIconsWithUnlimitedRetention(): void {
+ Arsse::$conf->import([
+ 'purgeFeeds' => null,
+ ]);
+ Arsse::$db->iconCleanup();
+ $now = gmdate("Y-m-d H:i:s");
+ $state = $this->primeExpectations($this->data, [
+ 'arsse_icons' => ["id","orphaned"],
+ ]);
+ $state['arsse_icons']['rows'][0][1] = null;
+ $state['arsse_icons']['rows'][2][1] = $now;
+ $this->compareExpectations(static::$drv, $state);
+ }
+
public function testCleanUpOldArticlesWithStandardRetention(): void {
Arsse::$db->articleCleanup();
$state = $this->primeExpectations($this->data, [
From 9fb185a8e2265e188eea958492e4355439104f2d Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Sat, 7 Nov 2020 12:00:41 -0500
Subject: [PATCH 029/366] Add TT-RSS Web client to manual
---
docs/en/040_Compatible_Clients.md | 11 +++++++++++
1 file changed, 11 insertions(+)
diff --git a/docs/en/040_Compatible_Clients.md b/docs/en/040_Compatible_Clients.md
index cd82678b..b8a22499 100644
--- a/docs/en/040_Compatible_Clients.md
+++ b/docs/en/040_Compatible_Clients.md
@@ -30,6 +30,17 @@ The Arsse does not at this time have any first party clients. However, because T
Three-pane alternative front-end for Minflux.
+
+ Tiny Tiny RSS Progressive Web App
+
+ ✘
+ ✘
+ ✔
+ ✘
+
+ Does not (yet ) support HTTP authentication.
+
+
From ee050e505c4551e7ba7a6a8bc93a42d4b4a8078f Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Sat, 7 Nov 2020 14:43:46 -0500
Subject: [PATCH 030/366] Add more Android clients to manual
---
docs/en/040_Compatible_Clients.md | 18 ++++++++++++++++++
1 file changed, 18 insertions(+)
diff --git a/docs/en/040_Compatible_Clients.md b/docs/en/040_Compatible_Clients.md
index b8a22499..e3a88006 100644
--- a/docs/en/040_Compatible_Clients.md
+++ b/docs/en/040_Compatible_Clients.md
@@ -182,6 +182,15 @@ The Arsse does not at this time have any first party clients. However, because T
✘
+
+ NewsJet RSS
+ Android
+ ✘
+ ✘
+ ✔
+ ✘
+
+
Newsout
Android, iOS
@@ -224,6 +233,15 @@ The Arsse does not at this time have any first party clients. However, because T
Fetches favicons independently.
+
+ Readrops
+ Android
+ ✘
+ ✔
+ ✘
+ ✘
+
+
Reed
Android
From 532ce4a502d626b0b7b23926208eb43be50039c7 Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Mon, 9 Nov 2020 13:43:07 -0500
Subject: [PATCH 031/366] Prototype changes to user management
The driver itself has not been expnaded; more is probably required to ensure
metadata is kept in sync and users created when the internal database does
not list a user an external database claims to have
---
lib/AbstractException.php | 2 ++
lib/Database.php | 32 +++++++++++++++++++++++++++++
lib/User.php | 41 +++++++++++++++++++++++++++++++++++++
lib/User/ExceptionInput.php | 10 +++++++++
4 files changed, 85 insertions(+)
create mode 100644 lib/User/ExceptionInput.php
diff --git a/lib/AbstractException.php b/lib/AbstractException.php
index 706465e0..ebeeccc3 100644
--- a/lib/AbstractException.php
+++ b/lib/AbstractException.php
@@ -75,6 +75,8 @@ abstract class AbstractException extends \Exception {
"User/Exception.authFailed" => 10412,
"User/ExceptionAuthz.notAuthorized" => 10421,
"User/ExceptionSession.invalid" => 10431,
+ "User/ExceptionInput.invalidTimezone" => 10441,
+ "User/ExceptionInput.invalidBoolean" => 10442,
"Feed/Exception.internalError" => 10500,
"Feed/Exception.invalidCertificate" => 10501,
"Feed/Exception.invalidUrl" => 10502,
diff --git a/lib/Database.php b/lib/Database.php
index 92ddb891..6dc02ddb 100644
--- a/lib/Database.php
+++ b/lib/Database.php
@@ -37,6 +37,9 @@ use JKingWeb\Arsse\Misc\URL;
* associations with articles. There has been an effort to keep public method
* names consistent throughout, but protected methods, having different
* concerns, will typically follow different conventions.
+ *
+ * Note that operations on users should be performed with the User class rather
+ * than the Database class directly. This is to allow for alternate user sources.
*/
class Database {
/** The version number of the latest schema the interface is aware of */
@@ -310,6 +313,35 @@ class Database {
$this->db->prepare("UPDATE arsse_users set password = ? where id = ?", "str", "str")->run($hash, $user);
return true;
}
+
+ public function userPropertiesGet(string $user): array {
+ if (!Arsse::$user->authorize($user, __FUNCTION__)) {
+ throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
+ } elseif (!$this->userExists($user)) {
+ throw new User\Exception("doesNotExist", ["action" => __FUNCTION__, "user" => $user]);
+ }
+ $out = $this->db->prepare("SELECT num, admin, lang, tz, sort_asc from arsse_users where id = ?", "str")->run($user)->getRow();
+ settype($out['admin'], "bool");
+ settype($out['sort_asc'], "bool");
+ return $out;
+ }
+
+ public function userPropertiesSet(string $user, array $data): bool {
+ if (!Arsse::$user->authorize($user, __FUNCTION__)) {
+ throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
+ } elseif (!$this->userExists($user)) {
+ throw new User\Exception("doesNotExist", ["action" => __FUNCTION__, "user" => $user]);
+ }
+ $allowed = [
+ 'admin' => "strict bool",
+ 'lang' => "str",
+ 'tz' => "strict str",
+ 'sort_asc' => "strict bool",
+ ];
+ [$setClause, $setTypes, $setValues] = $this->generateSet($data, $allowed);
+ return (bool) $this->$db->prepare("UPDATE arsse_users set $setClause where user = ?", $setTypes, "str")->run($setValues, $user)->changes();
+
+ }
/** Creates a new session for the given user and returns the session identifier */
public function sessionCreate(string $user): string {
diff --git a/lib/User.php b/lib/User.php
index f5299914..e39516ce 100644
--- a/lib/User.php
+++ b/lib/User.php
@@ -6,6 +6,7 @@
declare(strict_types=1);
namespace JKingWeb\Arsse;
+use JKingWeb\Arsse\Misc\ValueInfo as V;
use PasswordGenerator\Generator as PassGen;
class User {
@@ -120,4 +121,44 @@ class User {
public function generatePassword(): string {
return (new PassGen)->length(Arsse::$conf->userTempPasswordLength)->get();
}
+
+ public function propertiesGet(string $user): array {
+ // unconditionally retrieve from the database to get at least the user number, and anything else the driver does not provide
+ $out = Arsse::$db->userPropertiesGet($user);
+ // layer on the driver's data
+ $extra = $this->u->userPropertiesGet($user);
+ foreach (["lang", "tz", "admin", "sort_asc"] as $k) {
+ if (array_key_exists($k, $extra)) {
+ $out[$k] = $extra[$k] ?? $out[$k];
+ }
+ }
+ return $out;
+ }
+
+ public function propertiesSet(string $user, array $data): bool {
+ $in = [];
+ if (array_key_exists("tz", $data)) {
+ if (!is_string($data['tz'])) {
+ throw new User\ExceptionInput("invalidTimezone");
+ } elseif (!in_array($data['tz'], \DateTimeZone::listIdentifiers())) {
+ throw new User\ExceptionInput("invalidTimezone", $data['tz']);
+ }
+ $in['tz'] = $data['tz'];
+ }
+ foreach (["admin", "sort_asc"] as $k) {
+ if (array_key_exists($k, $data)) {
+ if (($v = V::normalize($data[$k], V::T_BOOL)) === null) {
+ throw new User\ExceptionInput("invalidBoolean", $k);
+ }
+ $in[$k] = $v;
+ }
+ }
+ if (array_key_exists("lang", $data)) {
+ $in['lang'] = V::normalize($data['lang'], V::T_STRING | M_NULL);
+ }
+ $out = $this->u->userPropertiesSet($user, $in);
+ // synchronize the internal database
+ Arsse::$db->userPropertiesSet($user, $in);
+ return $out;
+ }
}
diff --git a/lib/User/ExceptionInput.php b/lib/User/ExceptionInput.php
new file mode 100644
index 00000000..aea8c131
--- /dev/null
+++ b/lib/User/ExceptionInput.php
@@ -0,0 +1,10 @@
+
Date: Mon, 9 Nov 2020 14:47:42 -0500
Subject: [PATCH 032/366] More client compatibility updates
---
docs/en/040_Compatible_Clients.md | 3 +--
1 file changed, 1 insertion(+), 2 deletions(-)
diff --git a/docs/en/040_Compatible_Clients.md b/docs/en/040_Compatible_Clients.md
index e3a88006..f4585c9b 100644
--- a/docs/en/040_Compatible_Clients.md
+++ b/docs/en/040_Compatible_Clients.md
@@ -115,7 +115,7 @@ The Arsse does not at this time have any first party clients. However, because T
- Tiny Tiny RSS Reader
+ Tiny Tiny RSS Reader
Windows
✘
✘
@@ -350,7 +350,6 @@ The Arsse does not at this time have any first party clients. However, because T
✘
✘
- Does not support HTTP authentication.
From 576d7e16a86d28a1a5c28bdf89c642e318b1b910 Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Mon, 9 Nov 2020 16:49:42 -0500
Subject: [PATCH 033/366] Fix handling of bytea-typed nulls
---
lib/Db/PostgreSQL/Result.php | 4 +++-
tests/cases/Db/BaseResult.php | 12 ++++++++++++
tests/cases/Db/PostgreSQL/TestResult.php | 1 +
tests/cases/Db/PostgreSQLPDO/TestResult.php | 1 +
4 files changed, 17 insertions(+), 1 deletion(-)
diff --git a/lib/Db/PostgreSQL/Result.php b/lib/Db/PostgreSQL/Result.php
index 67a7352d..2b4d1b63 100644
--- a/lib/Db/PostgreSQL/Result.php
+++ b/lib/Db/PostgreSQL/Result.php
@@ -49,7 +49,9 @@ class Result extends \JKingWeb\Arsse\Db\AbstractResult {
$this->cur = pg_fetch_row($this->r, null, \PGSQL_ASSOC);
if ($this->cur !== false) {
foreach($this->blobs as $f) {
- $this->cur[$f] = hex2bin(substr($this->cur[$f], 2));
+ if ($this->cur[$f]) {
+ $this->cur[$f] = hex2bin(substr($this->cur[$f], 2));
+ }
}
return true;
}
diff --git a/tests/cases/Db/BaseResult.php b/tests/cases/Db/BaseResult.php
index a43956da..e848f51b 100644
--- a/tests/cases/Db/BaseResult.php
+++ b/tests/cases/Db/BaseResult.php
@@ -11,6 +11,7 @@ use JKingWeb\Arsse\Db\Result;
abstract class BaseResult extends \JKingWeb\Arsse\Test\AbstractTest {
protected static $insertDefault = "INSERT INTO arsse_test default values";
protected static $selectBlob = "SELECT x'DEADBEEF' as \"blob\"";
+ protected static $selectNullBlob = "SELECT null as \"blob\"";
protected static $interface;
protected $resultClass;
@@ -142,4 +143,15 @@ abstract class BaseResult extends \JKingWeb\Arsse\Test\AbstractTest {
$test = new $this->resultClass(...$this->makeResult(static::$selectBlob));
$this->assertEquals($exp, $test->getValue());
}
+
+ public function testGetNullBlobRow(): void {
+ $exp = ['blob' => null];
+ $test = new $this->resultClass(...$this->makeResult(static::$selectNullBlob));
+ $this->assertEquals($exp, $test->getRow());
+ }
+
+ public function testGetNullBlobValue(): void {
+ $test = new $this->resultClass(...$this->makeResult(static::$selectNullBlob));
+ $this->assertNull($test->getValue());
+ }
}
diff --git a/tests/cases/Db/PostgreSQL/TestResult.php b/tests/cases/Db/PostgreSQL/TestResult.php
index 658228e0..9a4413d8 100644
--- a/tests/cases/Db/PostgreSQL/TestResult.php
+++ b/tests/cases/Db/PostgreSQL/TestResult.php
@@ -16,6 +16,7 @@ class TestResult extends \JKingWeb\Arsse\TestCase\Db\BaseResult {
protected static $createMeta = "CREATE TABLE arsse_meta(key text primary key not null, value text)";
protected static $createTest = "CREATE TABLE arsse_test(id bigserial primary key)";
protected static $selectBlob = "SELECT '\\xDEADBEEF'::bytea as blob";
+ protected static $selectNullBlob = "SELECT null::bytea as blob";
protected function makeResult(string $q): array {
$set = pg_query(static::$interface, $q);
diff --git a/tests/cases/Db/PostgreSQLPDO/TestResult.php b/tests/cases/Db/PostgreSQLPDO/TestResult.php
index caddba71..b3d0cb33 100644
--- a/tests/cases/Db/PostgreSQLPDO/TestResult.php
+++ b/tests/cases/Db/PostgreSQLPDO/TestResult.php
@@ -16,6 +16,7 @@ class TestResult extends \JKingWeb\Arsse\TestCase\Db\BaseResult {
protected static $createMeta = "CREATE TABLE arsse_meta(key text primary key not null, value text)";
protected static $createTest = "CREATE TABLE arsse_test(id bigserial primary key)";
protected static $selectBlob = "SELECT '\\xDEADBEEF'::bytea as blob";
+ protected static $selectNullBlob = "SELECT null::bytea as blob";
protected function makeResult(string $q): array {
$set = static::$interface->query($q);
From 771f79323cef438a1664ac31d29ebff9d147c4cd Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Mon, 9 Nov 2020 16:51:30 -0500
Subject: [PATCH 034/366] Strip out remnants of the authorizer
---
lib/AbstractException.php | 1 -
lib/Database.php | 166 +-----------------
lib/User.php | 31 +---
lib/User/Driver.php | 2 -
lib/User/Internal/Driver.php | 4 -
locale/en.php | 5 -
tests/cases/Database/AbstractTest.php | 1 -
tests/cases/Database/SeriesArticle.php | 42 -----
tests/cases/Database/SeriesFolder.php | 42 -----
tests/cases/Database/SeriesIcon.php | 6 -
tests/cases/Database/SeriesLabel.php | 49 ------
tests/cases/Database/SeriesSession.php | 15 --
tests/cases/Database/SeriesSubscription.php | 81 +--------
tests/cases/Database/SeriesTag.php | 55 ------
tests/cases/Database/SeriesToken.php | 15 --
tests/cases/Database/SeriesUser.php | 43 -----
tests/cases/ImportExport/TestImportExport.php | 1 -
tests/cases/User/TestInternal.php | 52 ++----
tests/cases/User/TestUser.php | 132 ++++----------
19 files changed, 71 insertions(+), 672 deletions(-)
diff --git a/lib/AbstractException.php b/lib/AbstractException.php
index ebeeccc3..93798ca1 100644
--- a/lib/AbstractException.php
+++ b/lib/AbstractException.php
@@ -73,7 +73,6 @@ abstract class AbstractException extends \Exception {
"User/Exception.alreadyExists" => 10403,
"User/Exception.authMissing" => 10411,
"User/Exception.authFailed" => 10412,
- "User/ExceptionAuthz.notAuthorized" => 10421,
"User/ExceptionSession.invalid" => 10431,
"User/ExceptionInput.invalidTimezone" => 10441,
"User/ExceptionInput.invalidBoolean" => 10442,
diff --git a/lib/Database.php b/lib/Database.php
index 6dc02ddb..eff40a52 100644
--- a/lib/Database.php
+++ b/lib/Database.php
@@ -242,9 +242,6 @@ class Database {
/** Returns whether the specified user exists in the database */
public function userExists(string $user): bool {
- if (!Arsse::$user->authorize($user, __FUNCTION__)) {
- throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
- }
return (bool) $this->db->prepare("SELECT count(*) from arsse_users where id = ?", "str")->run($user)->getValue();
}
@@ -254,9 +251,7 @@ class Database {
* @param string $passwordThe user's password in cleartext. It will be stored hashed
*/
public function userAdd(string $user, string $password): bool {
- if (!Arsse::$user->authorize($user, __FUNCTION__)) {
- throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
- } elseif ($this->userExists($user)) {
+ if ($this->userExists($user)) {
throw new User\Exception("alreadyExists", ["action" => __FUNCTION__, "user" => $user]);
}
$hash = (strlen($password) > 0) ? password_hash($password, \PASSWORD_DEFAULT) : "";
@@ -267,9 +262,6 @@ class Database {
/** Removes a user from the database */
public function userRemove(string $user): bool {
- if (!Arsse::$user->authorize($user, __FUNCTION__)) {
- throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
- }
if ($this->db->prepare("DELETE from arsse_users where id = ?", "str")->run($user)->changes() < 1) {
throw new User\Exception("doesNotExist", ["action" => __FUNCTION__, "user" => $user]);
}
@@ -279,9 +271,6 @@ class Database {
/** Returns a flat, indexed array of all users in the database */
public function userList(): array {
$out = [];
- if (!Arsse::$user->authorize("", __FUNCTION__)) {
- throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => ""]);
- }
foreach ($this->db->query("SELECT id from arsse_users") as $user) {
$out[] = $user['id'];
}
@@ -290,9 +279,7 @@ class Database {
/** Retrieves the hashed password of a user */
public function userPasswordGet(string $user): ?string {
- if (!Arsse::$user->authorize($user, __FUNCTION__)) {
- throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
- } elseif (!$this->userExists($user)) {
+ if (!$this->userExists($user)) {
throw new User\Exception("doesNotExist", ["action" => __FUNCTION__, "user" => $user]);
}
return $this->db->prepare("SELECT password from arsse_users where id = ?", "str")->run($user)->getValue();
@@ -304,9 +291,7 @@ class Database {
* @param string $password The new password, in cleartext. The password will be stored hashed. If null is passed, the password is unset and authentication not possible
*/
public function userPasswordSet(string $user, string $password = null): bool {
- if (!Arsse::$user->authorize($user, __FUNCTION__)) {
- throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
- } elseif (!$this->userExists($user)) {
+ if (!$this->userExists($user)) {
throw new User\Exception("doesNotExist", ["action" => __FUNCTION__, "user" => $user]);
}
$hash = (strlen($password ?? "") > 0) ? password_hash($password, \PASSWORD_DEFAULT) : $password;
@@ -315,9 +300,7 @@ class Database {
}
public function userPropertiesGet(string $user): array {
- if (!Arsse::$user->authorize($user, __FUNCTION__)) {
- throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
- } elseif (!$this->userExists($user)) {
+ if (!$this->userExists($user)) {
throw new User\Exception("doesNotExist", ["action" => __FUNCTION__, "user" => $user]);
}
$out = $this->db->prepare("SELECT num, admin, lang, tz, sort_asc from arsse_users where id = ?", "str")->run($user)->getRow();
@@ -327,9 +310,7 @@ class Database {
}
public function userPropertiesSet(string $user, array $data): bool {
- if (!Arsse::$user->authorize($user, __FUNCTION__)) {
- throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
- } elseif (!$this->userExists($user)) {
+ if (!$this->userExists($user)) {
throw new User\Exception("doesNotExist", ["action" => __FUNCTION__, "user" => $user]);
}
$allowed = [
@@ -339,16 +320,12 @@ class Database {
'sort_asc' => "strict bool",
];
[$setClause, $setTypes, $setValues] = $this->generateSet($data, $allowed);
- return (bool) $this->$db->prepare("UPDATE arsse_users set $setClause where user = ?", $setTypes, "str")->run($setValues, $user)->changes();
+ return (bool) $this->db->prepare("UPDATE arsse_users set $setClause where user = ?", $setTypes, "str")->run($setValues, $user)->changes();
}
/** Creates a new session for the given user and returns the session identifier */
public function sessionCreate(string $user): string {
- // If the user isn't authorized to perform this action then throw an exception.
- if (!Arsse::$user->authorize($user, __FUNCTION__)) {
- throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
- }
// generate a new session ID and expiry date
$id = UUID::mint()->hex;
$expires = Date::add(Arsse::$conf->userSessionTimeout);
@@ -367,10 +344,6 @@ class Database {
* @param string|null $id The identifier of the session to destroy
*/
public function sessionDestroy(string $user, string $id = null): bool {
- // If the user isn't authorized to perform this action then throw an exception.
- if (!Arsse::$user->authorize($user, __FUNCTION__)) {
- throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
- }
if (is_null($id)) {
// delete all sessions and report success unconditionally if no identifier was specified
$this->db->prepare("DELETE FROM arsse_sessions where \"user\" = ?", "str")->run($user);
@@ -424,10 +397,7 @@ class Database {
* @param \DateTimeInterface|null $expires An optional expiry date and time for the token
*/
public function tokenCreate(string $user, string $class, string $id = null, \DateTimeInterface $expires = null): string {
- // If the user isn't authorized to perform this action then throw an exception.
- if (!Arsse::$user->authorize($user, __FUNCTION__)) {
- throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
- } elseif (!$this->userExists($user)) {
+ if (!$this->userExists($user)) {
throw new User\Exception("doesNotExist", ["action" => __FUNCTION__, "user" => $user]);
}
// generate a token if it's not provided
@@ -445,10 +415,6 @@ class Database {
* @param string|null $id The ID of a specific token, or null for all tokens in the class
*/
public function tokenRevoke(string $user, string $class, string $id = null): bool {
- // If the user isn't authorized to perform this action then throw an exception.
- if (!Arsse::$user->authorize($user, __FUNCTION__)) {
- throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
- }
if (is_null($id)) {
$out = $this->db->prepare("DELETE FROM arsse_tokens where \"user\" = ? and class = ?", "str", "str")->run($user, $class)->changes();
} else {
@@ -484,10 +450,6 @@ class Database {
* @param array $data An associative array defining the folder
*/
public function folderAdd(string $user, array $data): int {
- // If the user isn't authorized to perform this action then throw an exception.
- if (!Arsse::$user->authorize($user, __FUNCTION__)) {
- throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
- }
// normalize folder's parent, if there is one
$parent = array_key_exists("parent", $data) ? $this->folderValidateId($user, $data['parent'])['id'] : null;
// validate the folder name and parent (if specified); this also checks for duplicates
@@ -512,10 +474,6 @@ class Database {
* @param boolean $recursive Whether to list all descendents (true) or only direct children (false)
*/
public function folderList(string $user, $parent = null, bool $recursive = true): Db\Result {
- // if the user isn't authorized to perform this action then throw an exception.
- if (!Arsse::$user->authorize($user, __FUNCTION__)) {
- throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
- }
// check to make sure the parent exists, if one is specified
$parent = $this->folderValidateId($user, $parent)['id'];
$q = new Query(
@@ -548,9 +506,6 @@ class Database {
* @param integer $id The identifier of the folder to delete
*/
public function folderRemove(string $user, $id): bool {
- if (!Arsse::$user->authorize($user, __FUNCTION__)) {
- throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
- }
if (!ValueInfo::id($id)) {
throw new Db\ExceptionInput("typeViolation", ["action" => __FUNCTION__, "field" => "folder", 'type' => "int > 0"]);
}
@@ -563,9 +518,6 @@ class Database {
/** Returns the identifier, name, and parent of the given folder as an associative array */
public function folderPropertiesGet(string $user, $id): array {
- if (!Arsse::$user->authorize($user, __FUNCTION__)) {
- throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
- }
if (!ValueInfo::id($id)) {
throw new Db\ExceptionInput("typeViolation", ["action" => __FUNCTION__, "field" => "folder", 'type' => "int > 0"]);
}
@@ -590,9 +542,6 @@ class Database {
* @param array $data An associative array of properties to modify. Anything not specified will remain unchanged
*/
public function folderPropertiesSet(string $user, $id, array $data): bool {
- if (!Arsse::$user->authorize($user, __FUNCTION__)) {
- throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
- }
// verify the folder belongs to the user
$in = $this->folderValidateId($user, $id, true);
$name = array_key_exists("name", $data);
@@ -739,9 +688,6 @@ class Database {
* @param boolean $discover Whether to perform newsfeed discovery if $url points to an HTML document
*/
public function subscriptionAdd(string $user, string $url, string $fetchUser = "", string $fetchPassword = "", bool $discover = true): int {
- if (!Arsse::$user->authorize($user, __FUNCTION__)) {
- throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
- }
// get the ID of the underlying feed, or add it if it's not yet in the database
$feedID = $this->feedAdd($url, $fetchUser, $fetchPassword, $discover);
// Add the feed to the user's subscriptions and return the new subscription's ID.
@@ -756,9 +702,6 @@ class Database {
* @param integer|null $id The numeric identifier of a particular subscription; used internally by subscriptionPropertiesGet
*/
public function subscriptionList(string $user, $folder = null, bool $recursive = true, int $id = null): Db\Result {
- if (!Arsse::$user->authorize($user, __FUNCTION__)) {
- throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
- }
// validate inputs
$folder = $this->folderValidateId($user, $folder)['id'];
// create a complex query
@@ -804,9 +747,6 @@ class Database {
/** Returns the number of subscriptions in a folder, counting recursively */
public function subscriptionCount(string $user, $folder = null): int {
- if (!Arsse::$user->authorize($user, __FUNCTION__)) {
- throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
- }
// validate inputs
$folder = $this->folderValidateId($user, $folder)['id'];
// create a complex query
@@ -829,9 +769,6 @@ class Database {
* configurable retention period for newsfeeds
*/
public function subscriptionRemove(string $user, $id): bool {
- if (!Arsse::$user->authorize($user, __FUNCTION__)) {
- throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
- }
if (!ValueInfo::id($id)) {
throw new Db\ExceptionInput("typeViolation", ["action" => __FUNCTION__, "field" => "feed", 'type' => "int > 0"]);
}
@@ -861,9 +798,6 @@ class Database {
* - "unread": The number of unread articles associated with the subscription
*/
public function subscriptionPropertiesGet(string $user, $id): array {
- if (!Arsse::$user->authorize($user, __FUNCTION__)) {
- throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
- }
if (!ValueInfo::id($id)) {
throw new Db\ExceptionInput("typeViolation", ["action" => __FUNCTION__, "field" => "feed", 'type' => "int > 0"]);
}
@@ -888,9 +822,6 @@ class Database {
* @param array $data An associative array of properties to modify; any keys not specified will be left unchanged
*/
public function subscriptionPropertiesSet(string $user, $id, array $data): bool {
- if (!Arsse::$user->authorize($user, __FUNCTION__)) {
- throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
- }
$tr = $this->db->begin();
// validate the ID
$id = $this->subscriptionValidateId($user, $id, true)['id'];
@@ -934,9 +865,6 @@ class Database {
* @param boolean $byName Whether to return the tag names (true) instead of the numeric tag identifiers (false)
*/
public function subscriptionTagsGet(string $user, $id, bool $byName = false): array {
- if (!Arsse::$user->authorize($user, __FUNCTION__)) {
- throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
- }
$this->subscriptionValidateId($user, $id, true);
$field = !$byName ? "id" : "name";
$out = $this->db->prepare("SELECT $field from arsse_tags where id in (select tag from arsse_tag_members where subscription = ? and assigned = 1) order by $field", "int")->run($id)->getAll();
@@ -961,9 +889,6 @@ class Database {
$q = new Query("SELECT i.id, i.url, i.type, $data from arsse_subscriptions as s join arsse_feeds as f on s.feed = f.id left join arsse_icons as i on f.icon = i.id");
$q->setWhere("s.id = ?", "int", $id);
if (isset($user)) {
- if (!Arsse::$user->authorize($user, __FUNCTION__)) {
- throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
- }
$q->setWhere("s.owner = ?", "str", $user);
}
$out = $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues())->getRow();
@@ -975,9 +900,6 @@ class Database {
/** Returns the time at which any of a user's subscriptions (or a specific subscription) was last refreshed, as a DateTimeImmutable object */
public function subscriptionRefreshed(string $user, int $id = null): ?\DateTimeImmutable {
- if (!Arsse::$user->authorize($user, __FUNCTION__)) {
- throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
- }
$q = new Query("SELECT max(arsse_feeds.updated) from arsse_feeds join arsse_subscriptions on arsse_subscriptions.feed = arsse_feeds.id");
$q->setWhere("arsse_subscriptions.owner = ?", "str", $user);
if ($id) {
@@ -1304,9 +1226,6 @@ class Database {
* @param string $user The user whose subscription icons are to be retrieved
*/
public function iconList(string $user): Db\Result {
- if (!Arsse::$user->authorize($user, __FUNCTION__)) {
- throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
- }
return $this->db->prepare("SELECT distinct i.id, i.url, i.type, i.data from arsse_icons as i join arsse_feeds as f on i.id = f.icon join arsse_subscriptions as s on s.feed = f.id where s.owner = ?", "str")->run($user);
}
@@ -1646,9 +1565,6 @@ class Database {
* @param array $sort The columns to sort the result by eg. "edition desc" in decreasing order of importance
*/
public function articleList(string $user, Context $context = null, array $fields = ["id"], array $sort = []): Db\Result {
- if (!Arsse::$user->authorize($user, __FUNCTION__)) {
- throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
- }
// make a base query based on context and output columns
$context = $context ?? new Context;
$q = $this->articleQuery($user, $context, $fields);
@@ -1693,9 +1609,6 @@ class Database {
* @param Context $context The search context
*/
public function articleCount(string $user, Context $context = null): int {
- if (!Arsse::$user->authorize($user, __FUNCTION__)) {
- throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
- }
$context = $context ?? new Context;
$q = $this->articleQuery($user, $context, []);
return (int) $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues())->getValue();
@@ -1714,9 +1627,6 @@ class Database {
* @param Context $context The query context to match articles against
*/
public function articleMark(string $user, array $data, Context $context = null): int {
- if (!Arsse::$user->authorize($user, __FUNCTION__)) {
- throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
- }
$data = [
'read' => $data['read'] ?? null,
'starred' => $data['starred'] ?? null,
@@ -1800,9 +1710,6 @@ class Database {
* - "read": The count of starred articles which are read
*/
public function articleStarred(string $user): array {
- if (!Arsse::$user->authorize($user, __FUNCTION__)) {
- throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
- }
return $this->db->prepare(
"SELECT
count(*) as total,
@@ -1822,9 +1729,6 @@ class Database {
* @param boolean $byName Whether to return the label names (true) instead of the numeric label identifiers (false)
*/
public function articleLabelsGet(string $user, $id, bool $byName = false): array {
- if (!Arsse::$user->authorize($user, __FUNCTION__)) {
- throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
- }
$id = $this->articleValidateId($user, $id)['article'];
$field = !$byName ? "id" : "name";
$out = $this->db->prepare("SELECT $field from arsse_labels join arsse_label_members on arsse_label_members.label = arsse_labels.id where owner = ? and article = ? and assigned = 1 order by $field", "str", "int")->run($user, $id)->getAll();
@@ -1833,9 +1737,6 @@ class Database {
/** Returns the author-supplied categories associated with an article */
public function articleCategoriesGet(string $user, $id): array {
- if (!Arsse::$user->authorize($user, __FUNCTION__)) {
- throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
- }
$id = $this->articleValidateId($user, $id)['article'];
$out = $this->db->prepare("SELECT name from arsse_categories where article = ? order by name", "int")->run($id)->getAll();
if (!$out) {
@@ -1937,9 +1838,6 @@ class Database {
/** Returns the numeric identifier of the most recent edition of an article matching the given context */
public function editionLatest(string $user, Context $context = null): int {
- if (!Arsse::$user->authorize($user, __FUNCTION__)) {
- throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
- }
$context = $context ?? new Context;
$q = new Query("SELECT max(arsse_editions.id) from arsse_editions left join arsse_articles on article = arsse_articles.id join arsse_subscriptions on arsse_articles.feed = arsse_subscriptions.feed and arsse_subscriptions.owner = ?", "str", $user);
if ($context->subscription()) {
@@ -1968,10 +1866,6 @@ class Database {
* @param array $data An associative array defining the label's properties; currently only "name" is understood
*/
public function labelAdd(string $user, array $data): int {
- // if the user isn't authorized to perform this action then throw an exception.
- if (!Arsse::$user->authorize($user, __FUNCTION__)) {
- throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
- }
// validate the label name
$name = array_key_exists("name", $data) ? $data['name'] : "";
$this->labelValidateName($name, true);
@@ -1992,10 +1886,6 @@ class Database {
* @param boolean $includeEmpty Whether to include (true) or supress (false) labels which have no articles assigned to them
*/
public function labelList(string $user, bool $includeEmpty = true): Db\Result {
- // if the user isn't authorized to perform this action then throw an exception.
- if (!Arsse::$user->authorize($user, __FUNCTION__)) {
- throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
- }
return $this->db->prepare(
"SELECT * FROM (
SELECT
@@ -2032,9 +1922,6 @@ class Database {
* @param boolean $byName Whether to interpret the $id parameter as the label's name (true) or identifier (false)
*/
public function labelRemove(string $user, $id, bool $byName = false): bool {
- if (!Arsse::$user->authorize($user, __FUNCTION__)) {
- throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
- }
$this->labelValidateId($user, $id, $byName, false);
$field = $byName ? "name" : "id";
$type = $byName ? "str" : "int";
@@ -2059,9 +1946,6 @@ class Database {
* @param boolean $byName Whether to interpret the $id parameter as the label's name (true) or identifier (false)
*/
public function labelPropertiesGet(string $user, $id, bool $byName = false): array {
- if (!Arsse::$user->authorize($user, __FUNCTION__)) {
- throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
- }
$this->labelValidateId($user, $id, $byName, false);
$field = $byName ? "name" : "id";
$type = $byName ? "str" : "int";
@@ -2101,9 +1985,6 @@ class Database {
* @param boolean $byName Whether to interpret the $id parameter as the label's name (true) or identifier (false)
*/
public function labelPropertiesSet(string $user, $id, array $data, bool $byName = false): bool {
- if (!Arsse::$user->authorize($user, __FUNCTION__)) {
- throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
- }
$this->labelValidateId($user, $id, $byName, false);
if (isset($data['name'])) {
$this->labelValidateName($data['name']);
@@ -2132,9 +2013,6 @@ class Database {
* @param boolean $byName Whether to interpret the $id parameter as the label's name (true) or identifier (false)
*/
public function labelArticlesGet(string $user, $id, bool $byName = false): array {
- if (!Arsse::$user->authorize($user, __FUNCTION__)) {
- throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
- }
// just do a syntactic check on the label ID
$this->labelValidateId($user, $id, $byName, false);
$field = !$byName ? "id" : "name";
@@ -2161,9 +2039,6 @@ class Database {
*/
public function labelArticlesSet(string $user, $id, Context $context, int $mode = self::ASSOC_ADD, bool $byName = false): int {
assert(in_array($mode, [self::ASSOC_ADD, self::ASSOC_REMOVE, self::ASSOC_REPLACE]), new Exception("constantUnknown", $mode));
- if (!Arsse::$user->authorize($user, __FUNCTION__)) {
- throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
- }
// validate the tag ID, and get the numeric ID if matching by name
$id = $this->labelValidateId($user, $id, $byName, true)['id'];
// get the list of articles matching the context
@@ -2269,10 +2144,6 @@ class Database {
* @param array $data An associative array defining the tag's properties; currently only "name" is understood
*/
public function tagAdd(string $user, array $data): int {
- // if the user isn't authorized to perform this action then throw an exception.
- if (!Arsse::$user->authorize($user, __FUNCTION__)) {
- throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
- }
// validate the tag name
$name = array_key_exists("name", $data) ? $data['name'] : "";
$this->tagValidateName($name, true);
@@ -2292,10 +2163,6 @@ class Database {
* @param boolean $includeEmpty Whether to include (true) or supress (false) tags which have no subscriptions assigned to them
*/
public function tagList(string $user, bool $includeEmpty = true): Db\Result {
- // if the user isn't authorized to perform this action then throw an exception.
- if (!Arsse::$user->authorize($user, __FUNCTION__)) {
- throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
- }
return $this->db->prepare(
"SELECT * FROM (
SELECT
@@ -2323,10 +2190,6 @@ class Database {
* @param string $user The user whose tags are to be listed
*/
public function tagSummarize(string $user): Db\Result {
- // if the user isn't authorized to perform this action then throw an exception.
- if (!Arsse::$user->authorize($user, __FUNCTION__)) {
- throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
- }
return $this->db->prepare(
"SELECT
arsse_tags.id as id,
@@ -2348,9 +2211,6 @@ class Database {
* @param boolean $byName Whether to interpret the $id parameter as the tag's name (true) or identifier (false)
*/
public function tagRemove(string $user, $id, bool $byName = false): bool {
- if (!Arsse::$user->authorize($user, __FUNCTION__)) {
- throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
- }
$this->tagValidateId($user, $id, $byName, false);
$field = $byName ? "name" : "id";
$type = $byName ? "str" : "int";
@@ -2374,9 +2234,6 @@ class Database {
* @param boolean $byName Whether to interpret the $id parameter as the tag's name (true) or identifier (false)
*/
public function tagPropertiesGet(string $user, $id, bool $byName = false): array {
- if (!Arsse::$user->authorize($user, __FUNCTION__)) {
- throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
- }
$this->tagValidateId($user, $id, $byName, false);
$field = $byName ? "name" : "id";
$type = $byName ? "str" : "int";
@@ -2404,9 +2261,6 @@ class Database {
* @param boolean $byName Whether to interpret the $id parameter as the tag's name (true) or identifier (false)
*/
public function tagPropertiesSet(string $user, $id, array $data, bool $byName = false): bool {
- if (!Arsse::$user->authorize($user, __FUNCTION__)) {
- throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
- }
$this->tagValidateId($user, $id, $byName, false);
if (isset($data['name'])) {
$this->tagValidateName($data['name']);
@@ -2435,9 +2289,6 @@ class Database {
* @param boolean $byName Whether to interpret the $id parameter as the tag's name (true) or identifier (false)
*/
public function tagSubscriptionsGet(string $user, $id, bool $byName = false): array {
- if (!Arsse::$user->authorize($user, __FUNCTION__)) {
- throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
- }
// just do a syntactic check on the tag ID
$this->tagValidateId($user, $id, $byName, false);
$field = !$byName ? "id" : "name";
@@ -2464,9 +2315,6 @@ class Database {
*/
public function tagSubscriptionsSet(string $user, $id, array $subscriptions, int $mode = self::ASSOC_ADD, bool $byName = false): int {
assert(in_array($mode, [self::ASSOC_ADD, self::ASSOC_REMOVE, self::ASSOC_REPLACE]), new Exception("constantUnknown", $mode));
- if (!Arsse::$user->authorize($user, __FUNCTION__)) {
- throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
- }
// validate the tag ID, and get the numeric ID if matching by name
$id = $this->tagValidateId($user, $id, $byName, true)['id'];
// an empty subscription list is a special case
diff --git a/lib/User.php b/lib/User.php
index e39516ce..37ae814b 100644
--- a/lib/User.php
+++ b/lib/User.php
@@ -27,11 +27,6 @@ class User {
return (string) $this->id;
}
- public function authorize(string $affectedUser, string $action): bool {
- // at one time there was a complicated authorization system; it exists vestigially to support a later revival if desired
- return $this->u->authorize($affectedUser, $action);
- }
-
public function auth(string $user, string $password): bool {
$prevUser = $this->id;
$this->id = $user;
@@ -50,34 +45,18 @@ class User {
}
public function list(): array {
- $func = "userList";
- if (!$this->authorize("", $func)) {
- throw new User\ExceptionAuthz("notAuthorized", ["action" => $func, "user" => ""]);
- }
return $this->u->userList();
}
public function exists(string $user): bool {
- $func = "userExists";
- if (!$this->authorize($user, $func)) {
- throw new User\ExceptionAuthz("notAuthorized", ["action" => $func, "user" => $user]);
- }
return $this->u->userExists($user);
}
public function add($user, $password = null): string {
- $func = "userAdd";
- if (!$this->authorize($user, $func)) {
- throw new User\ExceptionAuthz("notAuthorized", ["action" => $func, "user" => $user]);
- }
return $this->u->userAdd($user, $password) ?? $this->u->userAdd($user, $this->generatePassword());
}
public function remove(string $user): bool {
- $func = "userRemove";
- if (!$this->authorize($user, $func)) {
- throw new User\ExceptionAuthz("notAuthorized", ["action" => $func, "user" => $user]);
- }
try {
return $this->u->userRemove($user);
} finally { // @codeCoverageIgnore
@@ -89,10 +68,6 @@ class User {
}
public function passwordSet(string $user, string $newPassword = null, $oldPassword = null): string {
- $func = "userPasswordSet";
- if (!$this->authorize($user, $func)) {
- throw new User\ExceptionAuthz("notAuthorized", ["action" => $func, "user" => $user]);
- }
$out = $this->u->userPasswordSet($user, $newPassword, $oldPassword) ?? $this->u->userPasswordSet($user, $this->generatePassword(), $oldPassword);
if (Arsse::$db->userExists($user)) {
// if the password change was successful and the user exists, set the internal password to the same value
@@ -104,10 +79,6 @@ class User {
}
public function passwordUnset(string $user, $oldPassword = null): bool {
- $func = "userPasswordUnset";
- if (!$this->authorize($user, $func)) {
- throw new User\ExceptionAuthz("notAuthorized", ["action" => $func, "user" => $user]);
- }
$out = $this->u->userPasswordUnset($user, $oldPassword);
if (Arsse::$db->userExists($user)) {
// if the password change was successful and the user exists, set the internal password to the same value
@@ -154,7 +125,7 @@ class User {
}
}
if (array_key_exists("lang", $data)) {
- $in['lang'] = V::normalize($data['lang'], V::T_STRING | M_NULL);
+ $in['lang'] = V::normalize($data['lang'], V::T_STRING | V::M_NULL);
}
$out = $this->u->userPropertiesSet($user, $in);
// synchronize the internal database
diff --git a/lib/User/Driver.php b/lib/User/Driver.php
index 8faaec71..e36ca38a 100644
--- a/lib/User/Driver.php
+++ b/lib/User/Driver.php
@@ -17,8 +17,6 @@ interface Driver {
public static function driverName(): string;
// authenticates a user against their name and password
public function auth(string $user, string $password): bool;
- // check whether a user is authorized to perform a certain action; not currently used and subject to change
- public function authorize(string $affectedUser, string $action): bool;
// checks whether a user exists
public function userExists(string $user): bool;
// adds a user
diff --git a/lib/User/Internal/Driver.php b/lib/User/Internal/Driver.php
index 4fc787f1..85d6fb3f 100644
--- a/lib/User/Internal/Driver.php
+++ b/lib/User/Internal/Driver.php
@@ -32,10 +32,6 @@ class Driver implements \JKingWeb\Arsse\User\Driver {
return password_verify($password, $hash);
}
- public function authorize(string $affectedUser, string $action): bool {
- return true;
- }
-
public function userExists(string $user): bool {
return Arsse::$db->userExists($user);
}
diff --git a/locale/en.php b/locale/en.php
index c19ac94b..bcc71db1 100644
--- a/locale/en.php
+++ b/locale/en.php
@@ -138,11 +138,6 @@ return [
'Exception.JKingWeb/Arsse/User/Exception.doesNotExist' => 'Could not perform action "{action}" because the user {user} does not exist',
'Exception.JKingWeb/Arsse/User/Exception.authMissing' => 'Please log in to proceed',
'Exception.JKingWeb/Arsse/User/Exception.authFailed' => 'Authentication failed',
- 'Exception.JKingWeb/Arsse/User/ExceptionAuthz.notAuthorized' =>
- '{action, select,
- userList {Authenticated user is not authorized to view the user list}
- other {Authenticated user is not authorized to perform the action "{action}" on behalf of {user}}
- }',
'Exception.JKingWeb/Arsse/User/ExceptionSession.invalid' => 'Session with ID {0} does not exist',
'Exception.JKingWeb/Arsse/Feed/Exception.internalError' => 'Could not download feed "{url}" because of an internal error which is probably a bug',
'Exception.JKingWeb/Arsse/Feed/Exception.invalidCertificate' => 'Could not download feed "{url}" because its server is serving an invalid SSL certificate',
diff --git a/tests/cases/Database/AbstractTest.php b/tests/cases/Database/AbstractTest.php
index 6e0e2ec9..5a8626ed 100644
--- a/tests/cases/Database/AbstractTest.php
+++ b/tests/cases/Database/AbstractTest.php
@@ -74,7 +74,6 @@ abstract class AbstractTest extends \JKingWeb\Arsse\Test\AbstractTest {
Arsse::$db->driverSchemaUpdate();
// create a mock user manager
Arsse::$user = \Phake::mock(User::class);
- \Phake::when(Arsse::$user)->authorize->thenReturn(true);
// call the series-specific setup method
$setUp = "setUp".$this->series;
$this->$setUp();
diff --git a/tests/cases/Database/SeriesArticle.php b/tests/cases/Database/SeriesArticle.php
index a9354c71..4edd8c82 100644
--- a/tests/cases/Database/SeriesArticle.php
+++ b/tests/cases/Database/SeriesArticle.php
@@ -597,12 +597,6 @@ trait SeriesArticle {
];
}
- public function testListArticlesWithoutAuthority(): void {
- \Phake::when(Arsse::$user)->authorize->thenReturn(false);
- $this->assertException("notAuthorized", "User", "ExceptionAuthz");
- Arsse::$db->articleList($this->user);
- }
-
public function testMarkNothing(): void {
$this->assertSame(0, Arsse::$db->articleMark($this->user, []));
}
@@ -967,12 +961,6 @@ trait SeriesArticle {
$this->compareExpectations(static::$drv, $state);
}
- public function testMarkArticlesWithoutAuthority(): void {
- \Phake::when(Arsse::$user)->authorize->thenReturn(false);
- $this->assertException("notAuthorized", "User", "ExceptionAuthz");
- Arsse::$db->articleMark($this->user, ['read' => false]);
- }
-
public function testCountArticles(): void {
$setSize = (new \ReflectionClassConstant(Database::class, "LIMIT_SET_SIZE"))->getValue();
$this->assertSame(2, Arsse::$db->articleCount("john.doe@example.com", (new Context)->starred(true)));
@@ -981,12 +969,6 @@ trait SeriesArticle {
$this->assertSame(10, Arsse::$db->articleCount("john.doe@example.com", (new Context)->articles(range(1, $setSize * 3))));
}
- public function testCountArticlesWithoutAuthority(): void {
- \Phake::when(Arsse::$user)->authorize->thenReturn(false);
- $this->assertException("notAuthorized", "User", "ExceptionAuthz");
- Arsse::$db->articleCount($this->user);
- }
-
public function testFetchStarredCounts(): void {
$exp1 = ['total' => 2, 'unread' => 1, 'read' => 1];
$exp2 = ['total' => 0, 'unread' => 0, 'read' => 0];
@@ -994,12 +976,6 @@ trait SeriesArticle {
$this->assertEquals($exp2, Arsse::$db->articleStarred("jane.doe@example.com"));
}
- public function testFetchStarredCountsWithoutAuthority(): void {
- \Phake::when(Arsse::$user)->authorize->thenReturn(false);
- $this->assertException("notAuthorized", "User", "ExceptionAuthz");
- Arsse::$db->articleStarred($this->user);
- }
-
public function testFetchLatestEdition(): void {
$this->assertSame(1001, Arsse::$db->editionLatest($this->user));
$this->assertSame(4, Arsse::$db->editionLatest($this->user, (new Context)->subscription(12)));
@@ -1010,12 +986,6 @@ trait SeriesArticle {
Arsse::$db->editionLatest($this->user, (new Context)->subscription(1));
}
- public function testFetchLatestEditionWithoutAuthority(): void {
- \Phake::when(Arsse::$user)->authorize->thenReturn(false);
- $this->assertException("notAuthorized", "User", "ExceptionAuthz");
- Arsse::$db->editionLatest($this->user);
- }
-
public function testListTheLabelsOfAnArticle(): void {
$this->assertEquals([1,2], Arsse::$db->articleLabelsGet("john.doe@example.com", 1));
$this->assertEquals([2], Arsse::$db->articleLabelsGet("john.doe@example.com", 5));
@@ -1030,12 +1000,6 @@ trait SeriesArticle {
Arsse::$db->articleLabelsGet($this->user, 101);
}
- public function testListTheLabelsOfAnArticleWithoutAuthority(): void {
- \Phake::when(Arsse::$user)->authorize->thenReturn(false);
- $this->assertException("notAuthorized", "User", "ExceptionAuthz");
- Arsse::$db->articleLabelsGet("john.doe@example.com", 1);
- }
-
public function testListTheCategoriesOfAnArticle(): void {
$exp = ["Fascinating", "Logical"];
$this->assertSame($exp, Arsse::$db->articleCategoriesGet($this->user, 19));
@@ -1050,12 +1014,6 @@ trait SeriesArticle {
Arsse::$db->articleCategoriesGet($this->user, 101);
}
- public function testListTheCategoriesOfAnArticleWithoutAuthority(): void {
- \Phake::when(Arsse::$user)->authorize->thenReturn(false);
- $this->assertException("notAuthorized", "User", "ExceptionAuthz");
- Arsse::$db->articleCategoriesGet($this->user, 19);
- }
-
/** @dataProvider provideArrayContextOptions */
public function testUseTooFewValuesInArrayContext(string $option): void {
$this->assertException("tooShort", "Db", "ExceptionInput");
diff --git a/tests/cases/Database/SeriesFolder.php b/tests/cases/Database/SeriesFolder.php
index 98d12d7b..4c488ced 100644
--- a/tests/cases/Database/SeriesFolder.php
+++ b/tests/cases/Database/SeriesFolder.php
@@ -102,7 +102,6 @@ trait SeriesFolder {
$user = "john.doe@example.com";
$folderID = $this->nextID("arsse_folders");
$this->assertSame($folderID, Arsse::$db->folderAdd($user, ['name' => "Entertainment"]));
- \Phake::verify(Arsse::$user)->authorize($user, "folderAdd");
$state = $this->primeExpectations($this->data, ['arsse_folders' => ['id','owner', 'parent', 'name']]);
$state['arsse_folders']['rows'][] = [$folderID, $user, null, "Entertainment"];
$this->compareExpectations(static::$drv, $state);
@@ -117,7 +116,6 @@ trait SeriesFolder {
$user = "john.doe@example.com";
$folderID = $this->nextID("arsse_folders");
$this->assertSame($folderID, Arsse::$db->folderAdd($user, ['name' => "GNOME", 'parent' => 2]));
- \Phake::verify(Arsse::$user)->authorize($user, "folderAdd");
$state = $this->primeExpectations($this->data, ['arsse_folders' => ['id','owner', 'parent', 'name']]);
$state['arsse_folders']['rows'][] = [$folderID, $user, 2, "GNOME"];
$this->compareExpectations(static::$drv, $state);
@@ -153,12 +151,6 @@ trait SeriesFolder {
Arsse::$db->folderAdd("john.doe@example.com", ['name' => " "]);
}
- public function testAddAFolderWithoutAuthority(): void {
- \Phake::when(Arsse::$user)->authorize->thenReturn(false);
- $this->assertException("notAuthorized", "User", "ExceptionAuthz");
- Arsse::$db->folderAdd("john.doe@example.com", ['name' => "Sociology"]);
- }
-
public function testListRootFolders(): void {
$exp = [
['id' => 5, 'name' => "Politics", 'parent' => null, 'children' => 0, 'feeds' => 2],
@@ -171,9 +163,6 @@ trait SeriesFolder {
$this->assertResult($exp, Arsse::$db->folderList("jane.doe@example.com", null, false));
$exp = [];
$this->assertResult($exp, Arsse::$db->folderList("admin@example.net", null, false));
- \Phake::verify(Arsse::$user)->authorize("john.doe@example.com", "folderList");
- \Phake::verify(Arsse::$user)->authorize("jane.doe@example.com", "folderList");
- \Phake::verify(Arsse::$user)->authorize("admin@example.net", "folderList");
}
public function testListFoldersRecursively(): void {
@@ -193,8 +182,6 @@ trait SeriesFolder {
$this->assertResult($exp, Arsse::$db->folderList("john.doe@example.com", 1, true));
$exp = [];
$this->assertResult($exp, Arsse::$db->folderList("jane.doe@example.com", 4, true));
- \Phake::verify(Arsse::$user, \Phake::times(2))->authorize("john.doe@example.com", "folderList");
- \Phake::verify(Arsse::$user)->authorize("jane.doe@example.com", "folderList");
}
public function testListFoldersOfAMissingParent(): void {
@@ -207,15 +194,8 @@ trait SeriesFolder {
Arsse::$db->folderList("john.doe@example.com", 4); // folder ID 4 belongs to Jane
}
- public function testListFoldersWithoutAuthority(): void {
- \Phake::when(Arsse::$user)->authorize->thenReturn(false);
- $this->assertException("notAuthorized", "User", "ExceptionAuthz");
- Arsse::$db->folderList("john.doe@example.com");
- }
-
public function testRemoveAFolder(): void {
$this->assertTrue(Arsse::$db->folderRemove("john.doe@example.com", 6));
- \Phake::verify(Arsse::$user)->authorize("john.doe@example.com", "folderRemove");
$state = $this->primeExpectations($this->data, ['arsse_folders' => ['id','owner', 'parent', 'name']]);
array_pop($state['arsse_folders']['rows']);
$this->compareExpectations(static::$drv, $state);
@@ -223,7 +203,6 @@ trait SeriesFolder {
public function testRemoveAFolderTree(): void {
$this->assertTrue(Arsse::$db->folderRemove("john.doe@example.com", 1));
- \Phake::verify(Arsse::$user)->authorize("john.doe@example.com", "folderRemove");
$state = $this->primeExpectations($this->data, ['arsse_folders' => ['id','owner', 'parent', 'name']]);
foreach ([0,1,2,5] as $index) {
unset($state['arsse_folders']['rows'][$index]);
@@ -246,12 +225,6 @@ trait SeriesFolder {
Arsse::$db->folderRemove("john.doe@example.com", 4); // folder ID 4 belongs to Jane
}
- public function testRemoveAFolderWithoutAuthority(): void {
- \Phake::when(Arsse::$user)->authorize->thenReturn(false);
- $this->assertException("notAuthorized", "User", "ExceptionAuthz");
- Arsse::$db->folderRemove("john.doe@example.com", 1);
- }
-
public function testGetThePropertiesOfAFolder(): void {
$exp = [
'id' => 6,
@@ -259,7 +232,6 @@ trait SeriesFolder {
'parent' => 2,
];
$this->assertArraySubset($exp, Arsse::$db->folderPropertiesGet("john.doe@example.com", 6));
- \Phake::verify(Arsse::$user)->authorize("john.doe@example.com", "folderPropertiesGet");
}
public function testGetThePropertiesOfAMissingFolder(): void {
@@ -277,19 +249,12 @@ trait SeriesFolder {
Arsse::$db->folderPropertiesGet("john.doe@example.com", 4); // folder ID 4 belongs to Jane
}
- public function testGetThePropertiesOfAFolderWithoutAuthority(): void {
- \Phake::when(Arsse::$user)->authorize->thenReturn(false);
- $this->assertException("notAuthorized", "User", "ExceptionAuthz");
- Arsse::$db->folderPropertiesGet("john.doe@example.com", 1);
- }
-
public function testMakeNoChangesToAFolder(): void {
$this->assertFalse(Arsse::$db->folderPropertiesSet("john.doe@example.com", 6, []));
}
public function testRenameAFolder(): void {
$this->assertTrue(Arsse::$db->folderPropertiesSet("john.doe@example.com", 6, ['name' => "Opinion"]));
- \Phake::verify(Arsse::$user)->authorize("john.doe@example.com", "folderPropertiesSet");
$state = $this->primeExpectations($this->data, ['arsse_folders' => ['id','owner', 'parent', 'name']]);
$state['arsse_folders']['rows'][5][3] = "Opinion";
$this->compareExpectations(static::$drv, $state);
@@ -316,7 +281,6 @@ trait SeriesFolder {
public function testMoveAFolder(): void {
$this->assertTrue(Arsse::$db->folderPropertiesSet("john.doe@example.com", 6, ['parent' => 5]));
- \Phake::verify(Arsse::$user)->authorize("john.doe@example.com", "folderPropertiesSet");
$state = $this->primeExpectations($this->data, ['arsse_folders' => ['id','owner', 'parent', 'name']]);
$state['arsse_folders']['rows'][5][2] = 5; // parent should have changed
$this->compareExpectations(static::$drv, $state);
@@ -371,10 +335,4 @@ trait SeriesFolder {
$this->assertException("subjectMissing", "Db", "ExceptionInput");
Arsse::$db->folderPropertiesSet("john.doe@example.com", 4, ['parent' => null]); // folder ID 4 belongs to Jane
}
-
- public function testSetThePropertiesOfAFolderWithoutAuthority(): void {
- \Phake::when(Arsse::$user)->authorize->thenReturn(false);
- $this->assertException("notAuthorized", "User", "ExceptionAuthz");
- Arsse::$db->folderPropertiesSet("john.doe@example.com", 1, ['parent' => null]);
- }
}
diff --git a/tests/cases/Database/SeriesIcon.php b/tests/cases/Database/SeriesIcon.php
index d54a4ab9..667651f2 100644
--- a/tests/cases/Database/SeriesIcon.php
+++ b/tests/cases/Database/SeriesIcon.php
@@ -94,10 +94,4 @@ trait SeriesIcon {
];
$this->assertResult($exp, Arsse::$db->iconList("jane.doe@example.com"));
}
-
- public function testListTheIconsOfAUserWithoutAuthority() {
- \Phake::when(Arsse::$user)->authorize->thenReturn(false);
- $this->assertException("notAuthorized", "User", "ExceptionAuthz");
- Arsse::$db->iconList("jane.doe@example.com");
- }
}
diff --git a/tests/cases/Database/SeriesLabel.php b/tests/cases/Database/SeriesLabel.php
index d66dcdb0..58f3c979 100644
--- a/tests/cases/Database/SeriesLabel.php
+++ b/tests/cases/Database/SeriesLabel.php
@@ -253,7 +253,6 @@ trait SeriesLabel {
$user = "john.doe@example.com";
$labelID = $this->nextID("arsse_labels");
$this->assertSame($labelID, Arsse::$db->labelAdd($user, ['name' => "Entertaining"]));
- \Phake::verify(Arsse::$user)->authorize($user, "labelAdd");
$state = $this->primeExpectations($this->data, $this->checkLabels);
$state['arsse_labels']['rows'][] = [$labelID, $user, "Entertaining"];
$this->compareExpectations(static::$drv, $state);
@@ -279,12 +278,6 @@ trait SeriesLabel {
Arsse::$db->labelAdd("john.doe@example.com", ['name' => " "]);
}
- public function testAddALabelWithoutAuthority(): void {
- \Phake::when(Arsse::$user)->authorize->thenReturn(false);
- $this->assertException("notAuthorized", "User", "ExceptionAuthz");
- Arsse::$db->labelAdd("john.doe@example.com", ['name' => "Boring"]);
- }
-
public function testListLabels(): void {
$exp = [
['id' => 2, 'name' => "Fascinating", 'articles' => 3, 'read' => 1],
@@ -298,18 +291,10 @@ trait SeriesLabel {
$this->assertResult($exp, Arsse::$db->labelList("jane.doe@example.com"));
$exp = [];
$this->assertResult($exp, Arsse::$db->labelList("jane.doe@example.com", false));
- \Phake::verify(Arsse::$user)->authorize("john.doe@example.com", "labelList");
- }
-
- public function testListLabelsWithoutAuthority(): void {
- \Phake::when(Arsse::$user)->authorize->thenReturn(false);
- $this->assertException("notAuthorized", "User", "ExceptionAuthz");
- Arsse::$db->labelList("john.doe@example.com");
}
public function testRemoveALabel(): void {
$this->assertTrue(Arsse::$db->labelRemove("john.doe@example.com", 1));
- \Phake::verify(Arsse::$user)->authorize("john.doe@example.com", "labelRemove");
$state = $this->primeExpectations($this->data, $this->checkLabels);
array_shift($state['arsse_labels']['rows']);
$this->compareExpectations(static::$drv, $state);
@@ -317,7 +302,6 @@ trait SeriesLabel {
public function testRemoveALabelByName(): void {
$this->assertTrue(Arsse::$db->labelRemove("john.doe@example.com", "Interesting", true));
- \Phake::verify(Arsse::$user)->authorize("john.doe@example.com", "labelRemove");
$state = $this->primeExpectations($this->data, $this->checkLabels);
array_shift($state['arsse_labels']['rows']);
$this->compareExpectations(static::$drv, $state);
@@ -343,12 +327,6 @@ trait SeriesLabel {
Arsse::$db->labelRemove("john.doe@example.com", 3); // label ID 3 belongs to Jane
}
- public function testRemoveALabelWithoutAuthority(): void {
- \Phake::when(Arsse::$user)->authorize->thenReturn(false);
- $this->assertException("notAuthorized", "User", "ExceptionAuthz");
- Arsse::$db->labelRemove("john.doe@example.com", 1);
- }
-
public function testGetThePropertiesOfALabel(): void {
$exp = [
'id' => 2,
@@ -358,7 +336,6 @@ trait SeriesLabel {
];
$this->assertArraySubset($exp, Arsse::$db->labelPropertiesGet("john.doe@example.com", 2));
$this->assertArraySubset($exp, Arsse::$db->labelPropertiesGet("john.doe@example.com", "Fascinating", true));
- \Phake::verify(Arsse::$user, \Phake::times(2))->authorize("john.doe@example.com", "labelPropertiesGet");
}
public function testGetThePropertiesOfAMissingLabel(): void {
@@ -381,19 +358,12 @@ trait SeriesLabel {
Arsse::$db->labelPropertiesGet("john.doe@example.com", 3); // label ID 3 belongs to Jane
}
- public function testGetThePropertiesOfALabelWithoutAuthority(): void {
- \Phake::when(Arsse::$user)->authorize->thenReturn(false);
- $this->assertException("notAuthorized", "User", "ExceptionAuthz");
- Arsse::$db->labelPropertiesGet("john.doe@example.com", 1);
- }
-
public function testMakeNoChangesToALabel(): void {
$this->assertFalse(Arsse::$db->labelPropertiesSet("john.doe@example.com", 1, []));
}
public function testRenameALabel(): void {
$this->assertTrue(Arsse::$db->labelPropertiesSet("john.doe@example.com", 1, ['name' => "Curious"]));
- \Phake::verify(Arsse::$user)->authorize("john.doe@example.com", "labelPropertiesSet");
$state = $this->primeExpectations($this->data, $this->checkLabels);
$state['arsse_labels']['rows'][0][2] = "Curious";
$this->compareExpectations(static::$drv, $state);
@@ -401,7 +371,6 @@ trait SeriesLabel {
public function testRenameALabelByName(): void {
$this->assertTrue(Arsse::$db->labelPropertiesSet("john.doe@example.com", "Interesting", ['name' => "Curious"], true));
- \Phake::verify(Arsse::$user)->authorize("john.doe@example.com", "labelPropertiesSet");
$state = $this->primeExpectations($this->data, $this->checkLabels);
$state['arsse_labels']['rows'][0][2] = "Curious";
$this->compareExpectations(static::$drv, $state);
@@ -447,12 +416,6 @@ trait SeriesLabel {
Arsse::$db->labelPropertiesSet("john.doe@example.com", 3, ['name' => "Exciting"]); // label ID 3 belongs to Jane
}
- public function testSetThePropertiesOfALabelWithoutAuthority(): void {
- \Phake::when(Arsse::$user)->authorize->thenReturn(false);
- $this->assertException("notAuthorized", "User", "ExceptionAuthz");
- Arsse::$db->labelPropertiesSet("john.doe@example.com", 1, ['name' => "Exciting"]);
- }
-
public function testListLabelledArticles(): void {
$exp = [1,19];
$this->assertEquals($exp, Arsse::$db->labelArticlesGet("john.doe@example.com", 1));
@@ -475,12 +438,6 @@ trait SeriesLabel {
Arsse::$db->labelArticlesGet("john.doe@example.com", -1);
}
- public function testListLabelledArticlesWithoutAuthority(): void {
- \Phake::when(Arsse::$user)->authorize->thenReturn(false);
- $this->assertException("notAuthorized", "User", "ExceptionAuthz");
- Arsse::$db->labelArticlesGet("john.doe@example.com", 1);
- }
-
public function testApplyALabelToArticles(): void {
Arsse::$db->labelArticlesSet("john.doe@example.com", 1, (new Context)->articles([2,5]));
$state = $this->primeExpectations($this->data, $this->checkMembers);
@@ -540,10 +497,4 @@ trait SeriesLabel {
$state['arsse_label_members']['rows'][2][3] = 0;
$this->compareExpectations(static::$drv, $state);
}
-
- public function testApplyALabelToArticlesWithoutAuthority(): void {
- \Phake::when(Arsse::$user)->authorize->thenReturn(false);
- $this->assertException("notAuthorized", "User", "ExceptionAuthz");
- Arsse::$db->labelArticlesSet("john.doe@example.com", 1, (new Context)->articles([2,5]));
- }
}
diff --git a/tests/cases/Database/SeriesSession.php b/tests/cases/Database/SeriesSession.php
index 163d8bf8..1db319f8 100644
--- a/tests/cases/Database/SeriesSession.php
+++ b/tests/cases/Database/SeriesSession.php
@@ -70,9 +70,6 @@ trait SeriesSession {
$state = $this->primeExpectations($this->data, ['arsse_sessions' => ["id", "created", "expires", "user"]]);
$state['arsse_sessions']['rows'][3][2] = Date::transform(Date::add(Arsse::$conf->userSessionTimeout, $now), "sql");
$this->compareExpectations(static::$drv, $state);
- // session resumption should not check authorization
- \Phake::when(Arsse::$user)->authorize->thenReturn(false);
- $this->assertArraySubset($exp1, Arsse::$db->sessionResume("80fa94c1a11f11e78667001e673b2560"));
}
public function testResumeAMissingSession(): void {
@@ -99,12 +96,6 @@ trait SeriesSession {
$this->compareExpectations(static::$drv, $state);
}
- public function testCreateASessionWithoutAuthority(): void {
- \Phake::when(Arsse::$user)->authorize->thenReturn(false);
- $this->assertException("notAuthorized", "User", "ExceptionAuthz");
- Arsse::$db->sessionCreate("jane.doe@example.com");
- }
-
public function testDestroyASession(): void {
$user = "jane.doe@example.com";
$id = "80fa94c1a11f11e78667001e673b2560";
@@ -131,10 +122,4 @@ trait SeriesSession {
$id = "80fa94c1a11f11e78667001e673b2560";
$this->assertFalse(Arsse::$db->sessionDestroy($user, $id));
}
-
- public function testDestroyASessionWithoutAuthority(): void {
- \Phake::when(Arsse::$user)->authorize->thenReturn(false);
- $this->assertException("notAuthorized", "User", "ExceptionAuthz");
- Arsse::$db->sessionDestroy("jane.doe@example.com", "80fa94c1a11f11e78667001e673b2560");
- }
}
diff --git a/tests/cases/Database/SeriesSubscription.php b/tests/cases/Database/SeriesSubscription.php
index 0075b992..749c8752 100644
--- a/tests/cases/Database/SeriesSubscription.php
+++ b/tests/cases/Database/SeriesSubscription.php
@@ -61,7 +61,11 @@ trait SeriesSubscription {
'next_fetch' => "datetime",
'icon' => "int",
],
- 'rows' => [], // filled in the series setup
+ 'rows' => [
+ [1,"http://example.com/feed1", "Ook", "", "",strtotime("now"),strtotime("now"),null],
+ [2,"http://example.com/feed2", "eek", "", "",strtotime("now - 1 hour"),strtotime("now - 1 hour"),1],
+ [3,"http://example.com/feed3", "Ack", "", "",strtotime("now + 1 hour"),strtotime("now + 1 hour"),null],
+ ],
],
'arsse_subscriptions' => [
'columns' => [
@@ -144,11 +148,6 @@ trait SeriesSubscription {
],
],
];
- $this->data['arsse_feeds']['rows'] = [
- [1,"http://example.com/feed1", "Ook", "", "",strtotime("now"),strtotime("now"),null],
- [2,"http://example.com/feed2", "eek", "", "",strtotime("now - 1 hour"),strtotime("now - 1 hour"),1],
- [3,"http://example.com/feed3", "Ack", "", "",strtotime("now + 1 hour"),strtotime("now + 1 hour"),null],
- ];
// initialize a partial mock of the Database object to later manipulate the feedUpdate method
Arsse::$db = \Phake::partialMock(Database::class, static::$drv);
$this->user = "john.doe@example.com";
@@ -163,7 +162,6 @@ trait SeriesSubscription {
$subID = $this->nextID("arsse_subscriptions");
\Phake::when(Arsse::$db)->feedUpdate->thenReturn(true);
$this->assertSame($subID, Arsse::$db->subscriptionAdd($this->user, $url));
- \Phake::verify(Arsse::$user)->authorize($this->user, "subscriptionAdd");
\Phake::verify(Arsse::$db, \Phake::times(0))->feedUpdate(1, true);
$state = $this->primeExpectations($this->data, [
'arsse_feeds' => ['id','url','username','password'],
@@ -179,7 +177,6 @@ trait SeriesSubscription {
$subID = $this->nextID("arsse_subscriptions");
\Phake::when(Arsse::$db)->feedUpdate->thenReturn(true);
$this->assertSame($subID, Arsse::$db->subscriptionAdd($this->user, $url, "", "", false));
- \Phake::verify(Arsse::$user)->authorize($this->user, "subscriptionAdd");
\Phake::verify(Arsse::$db)->feedUpdate($feedID, true);
$state = $this->primeExpectations($this->data, [
'arsse_feeds' => ['id','url','username','password'],
@@ -197,7 +194,6 @@ trait SeriesSubscription {
$subID = $this->nextID("arsse_subscriptions");
\Phake::when(Arsse::$db)->feedUpdate->thenReturn(true);
$this->assertSame($subID, Arsse::$db->subscriptionAdd($this->user, $url, "", "", true));
- \Phake::verify(Arsse::$user)->authorize($this->user, "subscriptionAdd");
\Phake::verify(Arsse::$db)->feedUpdate($feedID, true);
$state = $this->primeExpectations($this->data, [
'arsse_feeds' => ['id','url','username','password'],
@@ -216,7 +212,6 @@ trait SeriesSubscription {
try {
Arsse::$db->subscriptionAdd($this->user, $url, "", "", false);
} finally {
- \Phake::verify(Arsse::$user)->authorize($this->user, "subscriptionAdd");
\Phake::verify(Arsse::$db)->feedUpdate($feedID, true);
$state = $this->primeExpectations($this->data, [
'arsse_feeds' => ['id','url','username','password'],
@@ -246,16 +241,8 @@ trait SeriesSubscription {
$this->assertSame($subID, Arsse::$db->subscriptionAdd($this->user, $url));
}
- public function testAddASubscriptionWithoutAuthority(): void {
- $url = "http://example.com/feed1";
- \Phake::when(Arsse::$user)->authorize->thenReturn(false);
- $this->assertException("notAuthorized", "User", "ExceptionAuthz");
- Arsse::$db->subscriptionAdd($this->user, $url);
- }
-
public function testRemoveASubscription(): void {
$this->assertTrue(Arsse::$db->subscriptionRemove($this->user, 1));
- \Phake::verify(Arsse::$user)->authorize($this->user, "subscriptionRemove");
$state = $this->primeExpectations($this->data, [
'arsse_feeds' => ['id','url','username','password'],
'arsse_subscriptions' => ['id','owner','feed'],
@@ -280,12 +267,6 @@ trait SeriesSubscription {
Arsse::$db->subscriptionRemove($this->user, 1);
}
- public function testRemoveASubscriptionWithoutAuthority(): void {
- \Phake::when(Arsse::$user)->authorize->thenReturn(false);
- $this->assertException("notAuthorized", "User", "ExceptionAuthz");
- Arsse::$db->subscriptionRemove($this->user, 1);
- }
-
public function testListSubscriptions(): void {
$exp = [
[
@@ -308,9 +289,7 @@ trait SeriesSubscription {
],
];
$this->assertResult($exp, Arsse::$db->subscriptionList($this->user));
- \Phake::verify(Arsse::$user)->authorize($this->user, "subscriptionList");
$this->assertArraySubset($exp[0], Arsse::$db->subscriptionPropertiesGet($this->user, 1));
- \Phake::verify(Arsse::$user)->authorize($this->user, "subscriptionPropertiesGet");
$this->assertArraySubset($exp[1], Arsse::$db->subscriptionPropertiesGet($this->user, 3));
}
@@ -349,12 +328,6 @@ trait SeriesSubscription {
Arsse::$db->subscriptionList($this->user, 4);
}
- public function testListSubscriptionsWithoutAuthority(): void {
- \Phake::when(Arsse::$user)->authorize->thenReturn(false);
- $this->assertException("notAuthorized", "User", "ExceptionAuthz");
- Arsse::$db->subscriptionList($this->user);
- }
-
public function testCountSubscriptions(): void {
$this->assertSame(2, Arsse::$db->subscriptionCount($this->user));
$this->assertSame(1, Arsse::$db->subscriptionCount($this->user, 2));
@@ -365,12 +338,6 @@ trait SeriesSubscription {
Arsse::$db->subscriptionCount($this->user, 4);
}
- public function testCountSubscriptionsWithoutAuthority(): void {
- \Phake::when(Arsse::$user)->authorize->thenReturn(false);
- $this->assertException("notAuthorized", "User", "ExceptionAuthz");
- Arsse::$db->subscriptionCount($this->user);
- }
-
public function testGetThePropertiesOfAMissingSubscription(): void {
$this->assertException("subjectMissing", "Db", "ExceptionInput");
Arsse::$db->subscriptionPropertiesGet($this->user, 2112);
@@ -381,12 +348,6 @@ trait SeriesSubscription {
Arsse::$db->subscriptionPropertiesGet($this->user, -1);
}
- public function testGetThePropertiesOfASubscriptionWithoutAuthority(): void {
- \Phake::when(Arsse::$user)->authorize->thenReturn(false);
- $this->assertException("notAuthorized", "User", "ExceptionAuthz");
- Arsse::$db->subscriptionPropertiesGet($this->user, 1);
- }
-
public function testSetThePropertiesOfASubscription(): void {
Arsse::$db->subscriptionPropertiesSet($this->user, 1, [
'title' => "Ook Ook",
@@ -394,7 +355,6 @@ trait SeriesSubscription {
'pinned' => false,
'order_type' => 0,
]);
- \Phake::verify(Arsse::$user)->authorize($this->user, "subscriptionPropertiesSet");
$state = $this->primeExpectations($this->data, [
'arsse_feeds' => ['id','url','username','password','title'],
'arsse_subscriptions' => ['id','owner','feed','title','folder','pinned','order_type'],
@@ -454,22 +414,11 @@ trait SeriesSubscription {
Arsse::$db->subscriptionPropertiesSet($this->user, -1, ['folder' => null]);
}
- public function testSetThePropertiesOfASubscriptionWithoutAuthority(): void {
- \Phake::when(Arsse::$user)->authorize->thenReturn(false);
- $this->assertException("notAuthorized", "User", "ExceptionAuthz");
- Arsse::$db->subscriptionPropertiesSet($this->user, 1, ['folder' => null]);
- }
-
public function testRetrieveTheFaviconOfASubscription(): void {
$exp = "http://example.com/favicon.ico";
$this->assertSame($exp, Arsse::$db->subscriptionIcon(null, 1)['url']);
$this->assertSame($exp, Arsse::$db->subscriptionIcon(null, 2)['url']);
$this->assertSame(null, Arsse::$db->subscriptionIcon(null, 3)['url']);
- // authorization shouldn't have any bearing on this function
- \Phake::when(Arsse::$user)->authorize->thenReturn(false);
- $this->assertSame($exp, Arsse::$db->subscriptionIcon(null, 1)['url']);
- $this->assertSame($exp, Arsse::$db->subscriptionIcon(null, 2)['url']);
- $this->assertSame(null, Arsse::$db->subscriptionIcon(null, 3)['url']);
}
public function testRetrieveTheFaviconOfAMissingSubscription(): void {
@@ -493,14 +442,6 @@ trait SeriesSubscription {
$this->assertSame(null, Arsse::$db->subscriptionIcon($user, 2)['url']);
}
- public function testRetrieveTheFaviconOfASubscriptionWithUserWithoutAuthority(): void {
- $exp = "http://example.com/favicon.ico";
- $user = "john.doe@example.com";
- \Phake::when(Arsse::$user)->authorize->thenReturn(false);
- $this->assertException("notAuthorized", "User", "ExceptionAuthz");
- Arsse::$db->subscriptionIcon($user, -2112);
- }
-
public function testListTheTagsOfASubscription(): void {
$this->assertEquals([1,2], Arsse::$db->subscriptionTagsGet("john.doe@example.com", 1));
$this->assertEquals([2], Arsse::$db->subscriptionTagsGet("john.doe@example.com", 3));
@@ -513,12 +454,6 @@ trait SeriesSubscription {
Arsse::$db->subscriptionTagsGet($this->user, 101);
}
- public function testListTheTagsOfASubscriptionWithoutAuthority(): void {
- \Phake::when(Arsse::$user)->authorize->thenReturn(false);
- $this->assertException("notAuthorized", "User", "ExceptionAuthz");
- Arsse::$db->subscriptionTagsGet("john.doe@example.com", 1);
- }
-
public function testGetRefreshTimeOfASubscription(): void {
$user = "john.doe@example.com";
$this->assertTime(strtotime("now + 1 hour"), Arsse::$db->subscriptionRefreshed($user));
@@ -529,10 +464,4 @@ trait SeriesSubscription {
$this->assertException("subjectMissing", "Db", "ExceptionInput");
$this->assertTime(strtotime("now - 1 hour"), Arsse::$db->subscriptionRefreshed("john.doe@example.com", 2));
}
-
- public function testGetRefreshTimeOfASubscriptionWithoutAuthority(): void {
- \Phake::when(Arsse::$user)->authorize->thenReturn(false);
- $this->assertException("notAuthorized", "User", "ExceptionAuthz");
- $this->assertTime(strtotime("now + 1 hour"), Arsse::$db->subscriptionRefreshed("john.doe@example.com"));
- }
}
diff --git a/tests/cases/Database/SeriesTag.php b/tests/cases/Database/SeriesTag.php
index 3c4b4ac8..1f2ea9cd 100644
--- a/tests/cases/Database/SeriesTag.php
+++ b/tests/cases/Database/SeriesTag.php
@@ -113,7 +113,6 @@ trait SeriesTag {
$user = "john.doe@example.com";
$tagID = $this->nextID("arsse_tags");
$this->assertSame($tagID, Arsse::$db->tagAdd($user, ['name' => "Entertaining"]));
- \Phake::verify(Arsse::$user)->authorize($user, "tagAdd");
$state = $this->primeExpectations($this->data, $this->checkTags);
$state['arsse_tags']['rows'][] = [$tagID, $user, "Entertaining"];
$this->compareExpectations(static::$drv, $state);
@@ -139,12 +138,6 @@ trait SeriesTag {
Arsse::$db->tagAdd("john.doe@example.com", ['name' => " "]);
}
- public function testAddATagWithoutAuthority(): void {
- \Phake::when(Arsse::$user)->authorize->thenReturn(false);
- $this->assertException("notAuthorized", "User", "ExceptionAuthz");
- Arsse::$db->tagAdd("john.doe@example.com", ['name' => "Boring"]);
- }
-
public function testListTags(): void {
$exp = [
['id' => 2, 'name' => "Fascinating"],
@@ -158,18 +151,10 @@ trait SeriesTag {
$this->assertResult($exp, Arsse::$db->tagList("jane.doe@example.com"));
$exp = [];
$this->assertResult($exp, Arsse::$db->tagList("jane.doe@example.com", false));
- \Phake::verify(Arsse::$user)->authorize("john.doe@example.com", "tagList");
- }
-
- public function testListTagsWithoutAuthority(): void {
- \Phake::when(Arsse::$user)->authorize->thenReturn(false);
- $this->assertException("notAuthorized", "User", "ExceptionAuthz");
- Arsse::$db->tagList("john.doe@example.com");
}
public function testRemoveATag(): void {
$this->assertTrue(Arsse::$db->tagRemove("john.doe@example.com", 1));
- \Phake::verify(Arsse::$user)->authorize("john.doe@example.com", "tagRemove");
$state = $this->primeExpectations($this->data, $this->checkTags);
array_shift($state['arsse_tags']['rows']);
$this->compareExpectations(static::$drv, $state);
@@ -177,7 +162,6 @@ trait SeriesTag {
public function testRemoveATagByName(): void {
$this->assertTrue(Arsse::$db->tagRemove("john.doe@example.com", "Interesting", true));
- \Phake::verify(Arsse::$user)->authorize("john.doe@example.com", "tagRemove");
$state = $this->primeExpectations($this->data, $this->checkTags);
array_shift($state['arsse_tags']['rows']);
$this->compareExpectations(static::$drv, $state);
@@ -203,12 +187,6 @@ trait SeriesTag {
Arsse::$db->tagRemove("john.doe@example.com", 3); // tag ID 3 belongs to Jane
}
- public function testRemoveATagWithoutAuthority(): void {
- \Phake::when(Arsse::$user)->authorize->thenReturn(false);
- $this->assertException("notAuthorized", "User", "ExceptionAuthz");
- Arsse::$db->tagRemove("john.doe@example.com", 1);
- }
-
public function testGetThePropertiesOfATag(): void {
$exp = [
'id' => 2,
@@ -216,7 +194,6 @@ trait SeriesTag {
];
$this->assertArraySubset($exp, Arsse::$db->tagPropertiesGet("john.doe@example.com", 2));
$this->assertArraySubset($exp, Arsse::$db->tagPropertiesGet("john.doe@example.com", "Fascinating", true));
- \Phake::verify(Arsse::$user, \Phake::times(2))->authorize("john.doe@example.com", "tagPropertiesGet");
}
public function testGetThePropertiesOfAMissingTag(): void {
@@ -239,19 +216,12 @@ trait SeriesTag {
Arsse::$db->tagPropertiesGet("john.doe@example.com", 3); // tag ID 3 belongs to Jane
}
- public function testGetThePropertiesOfATagWithoutAuthority(): void {
- \Phake::when(Arsse::$user)->authorize->thenReturn(false);
- $this->assertException("notAuthorized", "User", "ExceptionAuthz");
- Arsse::$db->tagPropertiesGet("john.doe@example.com", 1);
- }
-
public function testMakeNoChangesToATag(): void {
$this->assertFalse(Arsse::$db->tagPropertiesSet("john.doe@example.com", 1, []));
}
public function testRenameATag(): void {
$this->assertTrue(Arsse::$db->tagPropertiesSet("john.doe@example.com", 1, ['name' => "Curious"]));
- \Phake::verify(Arsse::$user)->authorize("john.doe@example.com", "tagPropertiesSet");
$state = $this->primeExpectations($this->data, $this->checkTags);
$state['arsse_tags']['rows'][0][2] = "Curious";
$this->compareExpectations(static::$drv, $state);
@@ -259,7 +229,6 @@ trait SeriesTag {
public function testRenameATagByName(): void {
$this->assertTrue(Arsse::$db->tagPropertiesSet("john.doe@example.com", "Interesting", ['name' => "Curious"], true));
- \Phake::verify(Arsse::$user)->authorize("john.doe@example.com", "tagPropertiesSet");
$state = $this->primeExpectations($this->data, $this->checkTags);
$state['arsse_tags']['rows'][0][2] = "Curious";
$this->compareExpectations(static::$drv, $state);
@@ -305,12 +274,6 @@ trait SeriesTag {
Arsse::$db->tagPropertiesSet("john.doe@example.com", 3, ['name' => "Exciting"]); // tag ID 3 belongs to Jane
}
- public function testSetThePropertiesOfATagWithoutAuthority(): void {
- \Phake::when(Arsse::$user)->authorize->thenReturn(false);
- $this->assertException("notAuthorized", "User", "ExceptionAuthz");
- Arsse::$db->tagPropertiesSet("john.doe@example.com", 1, ['name' => "Exciting"]);
- }
-
public function testListTaggedSubscriptions(): void {
$exp = [1,5];
$this->assertEquals($exp, Arsse::$db->tagSubscriptionsGet("john.doe@example.com", 1));
@@ -333,12 +296,6 @@ trait SeriesTag {
Arsse::$db->tagSubscriptionsGet("john.doe@example.com", -1);
}
- public function testListTaggedSubscriptionsWithoutAuthority(): void {
- \Phake::when(Arsse::$user)->authorize->thenReturn(false);
- $this->assertException("notAuthorized", "User", "ExceptionAuthz");
- Arsse::$db->tagSubscriptionsGet("john.doe@example.com", 1);
- }
-
public function testApplyATagToSubscriptions(): void {
Arsse::$db->tagSubscriptionsSet("john.doe@example.com", 1, [3,4]);
$state = $this->primeExpectations($this->data, $this->checkMembers);
@@ -399,12 +356,6 @@ trait SeriesTag {
$this->compareExpectations(static::$drv, $state);
}
- public function testApplyATagToSubscriptionsWithoutAuthority(): void {
- \Phake::when(Arsse::$user)->authorize->thenReturn(false);
- $this->assertException("notAuthorized", "User", "ExceptionAuthz");
- Arsse::$db->tagSubscriptionsSet("john.doe@example.com", 1, [3,4]);
- }
-
public function testSummarizeTags(): void {
$exp = [
['id' => 1, 'name' => "Interesting", 'subscription' => 1],
@@ -415,10 +366,4 @@ trait SeriesTag {
];
$this->assertResult($exp, Arsse::$db->tagSummarize("john.doe@example.com"));
}
-
- public function testSummarizeTagsWithoutAuthority(): void {
- \Phake::when(Arsse::$user)->authorize->thenReturn(false);
- $this->assertException("notAuthorized", "User", "ExceptionAuthz");
- Arsse::$db->tagSummarize("john.doe@example.com");
- }
}
diff --git a/tests/cases/Database/SeriesToken.php b/tests/cases/Database/SeriesToken.php
index 267be38e..29977b3f 100644
--- a/tests/cases/Database/SeriesToken.php
+++ b/tests/cases/Database/SeriesToken.php
@@ -67,9 +67,6 @@ trait SeriesToken {
$this->assertArraySubset($exp1, Arsse::$db->tokenLookup("fever.login", "80fa94c1a11f11e78667001e673b2560"));
$this->assertArraySubset($exp2, Arsse::$db->tokenLookup("class.class", "da772f8fa13c11e78667001e673b2560"));
$this->assertArraySubset($exp3, Arsse::$db->tokenLookup("class.class", "ab3b3eb8a13311e78667001e673b2560"));
- // token lookup should not check authorization
- \Phake::when(Arsse::$user)->authorize->thenReturn(false);
- $this->assertArraySubset($exp1, Arsse::$db->tokenLookup("fever.login", "80fa94c1a11f11e78667001e673b2560"));
}
public function testLookUpAMissingToken(): void {
@@ -106,12 +103,6 @@ trait SeriesToken {
Arsse::$db->tokenCreate("fever.login", "jane.doe@example.biz");
}
- public function testCreateATokenWithoutAuthority(): void {
- \Phake::when(Arsse::$user)->authorize->thenReturn(false);
- $this->assertException("notAuthorized", "User", "ExceptionAuthz");
- Arsse::$db->tokenCreate("fever.login", "jane.doe@example.com");
- }
-
public function testRevokeAToken(): void {
$user = "jane.doe@example.com";
$id = "80fa94c1a11f11e78667001e673b2560";
@@ -136,10 +127,4 @@ trait SeriesToken {
// revoking tokens which do not exist is not an error
$this->assertFalse(Arsse::$db->tokenRevoke($user, "unknown.class"));
}
-
- public function testRevokeATokenWithoutAuthority(): void {
- \Phake::when(Arsse::$user)->authorize->thenReturn(false);
- $this->assertException("notAuthorized", "User", "ExceptionAuthz");
- Arsse::$db->tokenRevoke("jane.doe@example.com", "fever.login");
- }
}
diff --git a/tests/cases/Database/SeriesUser.php b/tests/cases/Database/SeriesUser.php
index 9b97fd80..3211cc9f 100644
--- a/tests/cases/Database/SeriesUser.php
+++ b/tests/cases/Database/SeriesUser.php
@@ -33,21 +33,12 @@ trait SeriesUser {
public function testCheckThatAUserExists(): void {
$this->assertTrue(Arsse::$db->userExists("jane.doe@example.com"));
$this->assertFalse(Arsse::$db->userExists("jane.doe@example.org"));
- \Phake::verify(Arsse::$user)->authorize("jane.doe@example.com", "userExists");
- \Phake::verify(Arsse::$user)->authorize("jane.doe@example.org", "userExists");
$this->compareExpectations(static::$drv, $this->data);
}
- public function testCheckThatAUserExistsWithoutAuthority(): void {
- \Phake::when(Arsse::$user)->authorize->thenReturn(false);
- $this->assertException("notAuthorized", "User", "ExceptionAuthz");
- Arsse::$db->userExists("jane.doe@example.com");
- }
-
public function testGetAPassword(): void {
$hash = Arsse::$db->userPasswordGet("admin@example.net");
$this->assertSame('$2y$10$PbcG2ZR3Z8TuPzM7aHTF8.v61dtCjzjK78gdZJcp4UePE8T9jEgBW', $hash);
- \Phake::verify(Arsse::$user)->authorize("admin@example.net", "userPasswordGet");
$this->assertTrue(password_verify("secret", $hash));
}
@@ -56,15 +47,8 @@ trait SeriesUser {
Arsse::$db->userPasswordGet("john.doe@example.org");
}
- public function testGetAPasswordWithoutAuthority(): void {
- \Phake::when(Arsse::$user)->authorize->thenReturn(false);
- $this->assertException("notAuthorized", "User", "ExceptionAuthz");
- Arsse::$db->userPasswordGet("admin@example.net");
- }
-
public function testAddANewUser(): void {
$this->assertTrue(Arsse::$db->userAdd("john.doe@example.org", ""));
- \Phake::verify(Arsse::$user)->authorize("john.doe@example.org", "userAdd");
$state = $this->primeExpectations($this->data, ['arsse_users' => ['id']]);
$state['arsse_users']['rows'][] = ["john.doe@example.org"];
$this->compareExpectations(static::$drv, $state);
@@ -75,15 +59,8 @@ trait SeriesUser {
Arsse::$db->userAdd("john.doe@example.com", "");
}
- public function testAddANewUserWithoutAuthority(): void {
- \Phake::when(Arsse::$user)->authorize->thenReturn(false);
- $this->assertException("notAuthorized", "User", "ExceptionAuthz");
- Arsse::$db->userAdd("john.doe@example.org", "");
- }
-
public function testRemoveAUser(): void {
$this->assertTrue(Arsse::$db->userRemove("admin@example.net"));
- \Phake::verify(Arsse::$user)->authorize("admin@example.net", "userRemove");
$state = $this->primeExpectations($this->data, ['arsse_users' => ['id']]);
array_shift($state['arsse_users']['rows']);
$this->compareExpectations(static::$drv, $state);
@@ -94,22 +71,9 @@ trait SeriesUser {
Arsse::$db->userRemove("john.doe@example.org");
}
- public function testRemoveAUserWithoutAuthority(): void {
- \Phake::when(Arsse::$user)->authorize->thenReturn(false);
- $this->assertException("notAuthorized", "User", "ExceptionAuthz");
- Arsse::$db->userRemove("admin@example.net");
- }
-
public function testListAllUsers(): void {
$users = ["admin@example.net", "jane.doe@example.com", "john.doe@example.com"];
$this->assertSame($users, Arsse::$db->userList());
- \Phake::verify(Arsse::$user)->authorize("", "userList");
- }
-
- public function testListAllUsersWithoutAuthority(): void {
- \Phake::when(Arsse::$user)->authorize->thenReturn(false);
- $this->assertException("notAuthorized", "User", "ExceptionAuthz");
- Arsse::$db->userList();
}
/**
@@ -122,7 +86,6 @@ trait SeriesUser {
$this->assertTrue(Arsse::$db->userPasswordSet($user, $pass));
$hash = Arsse::$db->userPasswordGet($user);
$this->assertNotEquals("", $hash);
- \Phake::verify(Arsse::$user)->authorize($user, "userPasswordSet");
$this->assertTrue(password_verify($pass, $hash), "Failed verifying password of $user '$pass' against hash '$hash'.");
}
@@ -137,10 +100,4 @@ trait SeriesUser {
$this->assertException("doesNotExist", "User");
Arsse::$db->userPasswordSet("john.doe@example.org", "secret");
}
-
- public function testSetAPasswordWithoutAuthority(): void {
- \Phake::when(Arsse::$user)->authorize->thenReturn(false);
- $this->assertException("notAuthorized", "User", "ExceptionAuthz");
- Arsse::$db->userPasswordSet("john.doe@example.com", "secret");
- }
}
diff --git a/tests/cases/ImportExport/TestImportExport.php b/tests/cases/ImportExport/TestImportExport.php
index af0b0fe0..64eaf905 100644
--- a/tests/cases/ImportExport/TestImportExport.php
+++ b/tests/cases/ImportExport/TestImportExport.php
@@ -28,7 +28,6 @@ class TestImportExport extends \JKingWeb\Arsse\Test\AbstractTest {
// create a mock user manager
Arsse::$user = \Phake::mock(\JKingWeb\Arsse\User::class);
\Phake::when(Arsse::$user)->exists->thenReturn(true);
- \Phake::when(Arsse::$user)->authorize->thenReturn(true);
// create a mock Import/Export processor
$this->proc = \Phake::partialMock(AbstractImportExport::class);
// initialize an SQLite memeory database
diff --git a/tests/cases/User/TestInternal.php b/tests/cases/User/TestInternal.php
index 4333771d..6a88b4dc 100644
--- a/tests/cases/User/TestInternal.php
+++ b/tests/cases/User/TestInternal.php
@@ -33,16 +33,12 @@ class TestInternal extends \JKingWeb\Arsse\Test\AbstractTest {
* @dataProvider provideAuthentication
* @group slow
*/
- public function testAuthenticateAUser(bool $authorized, string $user, $password, bool $exp): void {
- if ($authorized) {
- \Phake::when(Arsse::$db)->userPasswordGet("john.doe@example.com")->thenReturn('$2y$10$1zbqRJhxM8uUjeSBPp4IhO90xrqK0XjEh9Z16iIYEFRV4U.zeAFom'); // hash of "secret"
- \Phake::when(Arsse::$db)->userPasswordGet("jane.doe@example.com")->thenReturn('$2y$10$bK1ljXfTSyc2D.NYvT.Eq..OpehLRXVbglW.23ihVuyhgwJCd.7Im'); // hash of "superman"
- \Phake::when(Arsse::$db)->userPasswordGet("owen.hardy@example.com")->thenReturn("");
- \Phake::when(Arsse::$db)->userPasswordGet("kira.nerys@example.com")->thenThrow(new \JKingWeb\Arsse\User\Exception("doesNotExist"));
- \Phake::when(Arsse::$db)->userPasswordGet("007@example.com")->thenReturn(null);
- } else {
- \Phake::when(Arsse::$db)->userPasswordGet->thenThrow(new \JKingWeb\Arsse\User\ExceptionAuthz("notAuthorized"));
- }
+ public function testAuthenticateAUser(string $user, $password, bool $exp): void {
+ \Phake::when(Arsse::$db)->userPasswordGet("john.doe@example.com")->thenReturn('$2y$10$1zbqRJhxM8uUjeSBPp4IhO90xrqK0XjEh9Z16iIYEFRV4U.zeAFom'); // hash of "secret"
+ \Phake::when(Arsse::$db)->userPasswordGet("jane.doe@example.com")->thenReturn('$2y$10$bK1ljXfTSyc2D.NYvT.Eq..OpehLRXVbglW.23ihVuyhgwJCd.7Im'); // hash of "superman"
+ \Phake::when(Arsse::$db)->userPasswordGet("owen.hardy@example.com")->thenReturn("");
+ \Phake::when(Arsse::$db)->userPasswordGet("kira.nerys@example.com")->thenThrow(new \JKingWeb\Arsse\User\Exception("doesNotExist"));
+ \Phake::when(Arsse::$db)->userPasswordGet("007@example.com")->thenReturn(null);
$this->assertSame($exp, (new Driver)->auth($user, $password));
}
@@ -53,32 +49,22 @@ class TestInternal extends \JKingWeb\Arsse\Test\AbstractTest {
$kira = "kira.nerys@example.com";
$bond = "007@example.com";
return [
- [false, $john, "secret", false],
- [false, $jane, "superman", false],
- [false, $owen, "", false],
- [false, $kira, "ashalla", false],
- [false, $bond, "", false],
- [true, $john, "secret", true],
- [true, $jane, "superman", true],
- [true, $owen, "", true],
- [true, $kira, "ashalla", false],
- [true, $john, "top secret", false],
- [true, $jane, "clark kent", false],
- [true, $owen, "watchmaker", false],
- [true, $kira, "singha", false],
- [true, $john, "", false],
- [true, $jane, "", false],
- [true, $kira, "", false],
- [true, $bond, "for England", false],
- [true, $bond, "", false],
+ [$john, "secret", true],
+ [$jane, "superman", true],
+ [$owen, "", true],
+ [$kira, "ashalla", false],
+ [$john, "top secret", false],
+ [$jane, "clark kent", false],
+ [$owen, "watchmaker", false],
+ [$kira, "singha", false],
+ [$john, "", false],
+ [$jane, "", false],
+ [$kira, "", false],
+ [$bond, "for England", false],
+ [$bond, "", false],
];
}
- public function testAuthorizeAnAction(): void {
- \Phake::verifyNoFurtherInteraction(Arsse::$db);
- $this->assertTrue((new Driver)->authorize("someone", "something"));
- }
-
public function testListUsers(): void {
$john = "john.doe@example.com";
$jane = "jane.doe@example.com";
diff --git a/tests/cases/User/TestUser.php b/tests/cases/User/TestUser.php
index 93b5ee72..80be0e6f 100644
--- a/tests/cases/User/TestUser.php
+++ b/tests/cases/User/TestUser.php
@@ -69,13 +69,9 @@ class TestUser extends \JKingWeb\Arsse\Test\AbstractTest {
}
/** @dataProvider provideUserList */
- public function testListUsers(bool $authorized, $exp): void {
+ public function testListUsers($exp): void {
$u = new User($this->drv);
- \Phake::when($this->drv)->authorize->thenReturn($authorized);
\Phake::when($this->drv)->userList->thenReturn(["john.doe@example.com", "jane.doe@example.com"]);
- if ($exp instanceof Exception) {
- $this->assertException("notAuthorized", "User", "ExceptionAuthz");
- }
$this->assertSame($exp, $u->list());
}
@@ -83,20 +79,15 @@ class TestUser extends \JKingWeb\Arsse\Test\AbstractTest {
$john = "john.doe@example.com";
$jane = "jane.doe@example.com";
return [
- [false, new \JKingWeb\Arsse\User\ExceptionAuthz("notAuthorized")],
- [true, [$john, $jane]],
+ [[$john, $jane]],
];
}
/** @dataProvider provideExistence */
- public function testCheckThatAUserExists(bool $authorized, string $user, $exp): void {
+ public function testCheckThatAUserExists(string $user, $exp): void {
$u = new User($this->drv);
- \Phake::when($this->drv)->authorize->thenReturn($authorized);
\Phake::when($this->drv)->userExists("john.doe@example.com")->thenReturn(true);
\Phake::when($this->drv)->userExists("jane.doe@example.com")->thenReturn(false);
- if ($exp instanceof Exception) {
- $this->assertException("notAuthorized", "User", "ExceptionAuthz");
- }
$this->assertSame($exp, $u->exists($user));
}
@@ -104,48 +95,35 @@ class TestUser extends \JKingWeb\Arsse\Test\AbstractTest {
$john = "john.doe@example.com";
$jane = "jane.doe@example.com";
return [
- [false, $john, new \JKingWeb\Arsse\User\ExceptionAuthz("notAuthorized")],
- [false, $jane, new \JKingWeb\Arsse\User\ExceptionAuthz("notAuthorized")],
- [true, $john, true],
- [true, $jane, false],
+ [$john, true],
+ [$jane, false],
];
}
/** @dataProvider provideAdditions */
- public function testAddAUser(bool $authorized, string $user, $password, $exp): void {
+ public function testAddAUser(string $user, $password, $exp): void {
$u = new User($this->drv);
- \Phake::when($this->drv)->authorize->thenReturn($authorized);
\Phake::when($this->drv)->userAdd("john.doe@example.com", $this->anything())->thenThrow(new \JKingWeb\Arsse\User\Exception("alreadyExists"));
\Phake::when($this->drv)->userAdd("jane.doe@example.com", $this->anything())->thenReturnCallback(function($user, $pass) {
return $pass ?? "random password";
});
if ($exp instanceof Exception) {
- if ($exp instanceof \JKingWeb\Arsse\User\ExceptionAuthz) {
- $this->assertException("notAuthorized", "User", "ExceptionAuthz");
- } else {
- $this->assertException("alreadyExists", "User");
- }
+ $this->assertException("alreadyExists", "User");
}
$this->assertSame($exp, $u->add($user, $password));
}
/** @dataProvider provideAdditions */
- public function testAddAUserWithARandomPassword(bool $authorized, string $user, $password, $exp): void {
+ public function testAddAUserWithARandomPassword(string $user, $password, $exp): void {
$u = \Phake::partialMock(User::class, $this->drv);
- \Phake::when($this->drv)->authorize->thenReturn($authorized);
\Phake::when($this->drv)->userAdd($this->anything(), $this->isNull())->thenReturn(null);
\Phake::when($this->drv)->userAdd("john.doe@example.com", $this->logicalNot($this->isNull()))->thenThrow(new \JKingWeb\Arsse\User\Exception("alreadyExists"));
\Phake::when($this->drv)->userAdd("jane.doe@example.com", $this->logicalNot($this->isNull()))->thenReturnCallback(function($user, $pass) {
return $pass;
});
if ($exp instanceof Exception) {
- if ($exp instanceof \JKingWeb\Arsse\User\ExceptionAuthz) {
- $this->assertException("notAuthorized", "User", "ExceptionAuthz");
- $calls = 0;
- } else {
- $this->assertException("alreadyExists", "User");
- $calls = 2;
- }
+ $this->assertException("alreadyExists", "User");
+ $calls = 2;
} else {
$calls = 4;
}
@@ -163,34 +141,27 @@ class TestUser extends \JKingWeb\Arsse\Test\AbstractTest {
$john = "john.doe@example.com";
$jane = "jane.doe@example.com";
return [
- [false, $john, "secret", new \JKingWeb\Arsse\User\ExceptionAuthz("notAuthorized")],
- [false, $jane, "superman", new \JKingWeb\Arsse\User\ExceptionAuthz("notAuthorized")],
- [true, $john, "secret", new \JKingWeb\Arsse\User\Exception("alreadyExists")],
- [true, $jane, "superman", "superman"],
- [true, $jane, null, "random password"],
+ [$john, "secret", new \JKingWeb\Arsse\User\Exception("alreadyExists")],
+ [$jane, "superman", "superman"],
+ [$jane, null, "random password"],
];
}
/** @dataProvider provideRemovals */
- public function testRemoveAUser(bool $authorized, string $user, bool $exists, $exp): void {
+ public function testRemoveAUser(string $user, bool $exists, $exp): void {
$u = new User($this->drv);
- \Phake::when($this->drv)->authorize->thenReturn($authorized);
\Phake::when($this->drv)->userRemove("john.doe@example.com")->thenReturn(true);
\Phake::when($this->drv)->userRemove("jane.doe@example.com")->thenThrow(new \JKingWeb\Arsse\User\Exception("doesNotExist"));
\Phake::when(Arsse::$db)->userExists->thenReturn($exists);
\Phake::when(Arsse::$db)->userRemove->thenReturn(true);
if ($exp instanceof Exception) {
- if ($exp instanceof \JKingWeb\Arsse\User\ExceptionAuthz) {
- $this->assertException("notAuthorized", "User", "ExceptionAuthz");
- } else {
- $this->assertException("doesNotExist", "User");
- }
+ $this->assertException("doesNotExist", "User");
}
try {
$this->assertSame($exp, $u->remove($user));
} finally {
- \Phake::verify(Arsse::$db, \Phake::times((int) $authorized))->userExists($user);
- \Phake::verify(Arsse::$db, \Phake::times((int) ($authorized && $exists)))->userRemove($user);
+ \Phake::verify(Arsse::$db, \Phake::times(1))->userExists($user);
+ \Phake::verify(Arsse::$db, \Phake::times((int) $exists))->userRemove($user);
}
}
@@ -198,32 +169,23 @@ class TestUser extends \JKingWeb\Arsse\Test\AbstractTest {
$john = "john.doe@example.com";
$jane = "jane.doe@example.com";
return [
- [false, $john, true, new \JKingWeb\Arsse\User\ExceptionAuthz("notAuthorized")],
- [false, $john, false, new \JKingWeb\Arsse\User\ExceptionAuthz("notAuthorized")],
- [false, $jane, true, new \JKingWeb\Arsse\User\ExceptionAuthz("notAuthorized")],
- [false, $jane, false, new \JKingWeb\Arsse\User\ExceptionAuthz("notAuthorized")],
- [true, $john, true, true],
- [true, $john, false, true],
- [true, $jane, true, new \JKingWeb\Arsse\User\Exception("doesNotExist")],
- [true, $jane, false, new \JKingWeb\Arsse\User\Exception("doesNotExist")],
+ [$john, true, true],
+ [$john, false, true],
+ [$jane, true, new \JKingWeb\Arsse\User\Exception("doesNotExist")],
+ [$jane, false, new \JKingWeb\Arsse\User\Exception("doesNotExist")],
];
}
/** @dataProvider providePasswordChanges */
- public function testChangeAPassword(bool $authorized, string $user, $password, bool $exists, $exp): void {
+ public function testChangeAPassword(string $user, $password, bool $exists, $exp): void {
$u = new User($this->drv);
- \Phake::when($this->drv)->authorize->thenReturn($authorized);
\Phake::when($this->drv)->userPasswordSet("john.doe@example.com", $this->anything(), $this->anything())->thenReturnCallback(function($user, $pass, $old) {
return $pass ?? "random password";
});
\Phake::when($this->drv)->userPasswordSet("jane.doe@example.com", $this->anything(), $this->anything())->thenThrow(new \JKingWeb\Arsse\User\Exception("doesNotExist"));
\Phake::when(Arsse::$db)->userExists->thenReturn($exists);
if ($exp instanceof Exception) {
- if ($exp instanceof \JKingWeb\Arsse\User\ExceptionAuthz) {
- $this->assertException("notAuthorized", "User", "ExceptionAuthz");
- } else {
- $this->assertException("doesNotExist", "User");
- }
+ $this->assertException("doesNotExist", "User");
$calls = 0;
} else {
$calls = 1;
@@ -237,9 +199,8 @@ class TestUser extends \JKingWeb\Arsse\Test\AbstractTest {
}
/** @dataProvider providePasswordChanges */
- public function testChangeAPasswordToARandomPassword(bool $authorized, string $user, $password, bool $exists, $exp): void {
+ public function testChangeAPasswordToARandomPassword(string $user, $password, bool $exists, $exp): void {
$u = \Phake::partialMock(User::class, $this->drv);
- \Phake::when($this->drv)->authorize->thenReturn($authorized);
\Phake::when($this->drv)->userPasswordSet($this->anything(), $this->isNull(), $this->anything())->thenReturn(null);
\Phake::when($this->drv)->userPasswordSet("john.doe@example.com", $this->logicalNot($this->isNull()), $this->anything())->thenReturnCallback(function($user, $pass, $old) {
return $pass ?? "random password";
@@ -247,13 +208,8 @@ class TestUser extends \JKingWeb\Arsse\Test\AbstractTest {
\Phake::when($this->drv)->userPasswordSet("jane.doe@example.com", $this->logicalNot($this->isNull()), $this->anything())->thenThrow(new \JKingWeb\Arsse\User\Exception("doesNotExist"));
\Phake::when(Arsse::$db)->userExists->thenReturn($exists);
if ($exp instanceof Exception) {
- if ($exp instanceof \JKingWeb\Arsse\User\ExceptionAuthz) {
- $this->assertException("notAuthorized", "User", "ExceptionAuthz");
- $calls = 0;
- } else {
- $this->assertException("doesNotExist", "User");
- $calls = 2;
- }
+ $this->assertException("doesNotExist", "User");
+ $calls = 2;
} else {
$calls = 4;
}
@@ -278,19 +234,16 @@ class TestUser extends \JKingWeb\Arsse\Test\AbstractTest {
$john = "john.doe@example.com";
$jane = "jane.doe@example.com";
return [
- [false, $john, "secret", true, new \JKingWeb\Arsse\User\ExceptionAuthz("notAuthorized")],
- [false, $jane, "superman", false, new \JKingWeb\Arsse\User\ExceptionAuthz("notAuthorized")],
- [true, $john, "superman", true, "superman"],
- [true, $john, null, true, "random password"],
- [true, $john, "superman", false, "superman"],
- [true, $john, null, false, "random password"],
- [true, $jane, "secret", true, new \JKingWeb\Arsse\User\Exception("doesNotExist")],
+ [$john, "superman", true, "superman"],
+ [$john, null, true, "random password"],
+ [$john, "superman", false, "superman"],
+ [$john, null, false, "random password"],
+ [$jane, "secret", true, new \JKingWeb\Arsse\User\Exception("doesNotExist")],
];
}
/** @dataProvider providePasswordClearings */
- public function testClearAPassword(bool $authorized, bool $exists, string $user, $exp): void {
- \Phake::when($this->drv)->authorize->thenReturn($authorized);
+ public function testClearAPassword(bool $exists, string $user, $exp): void {
\Phake::when($this->drv)->userPasswordUnset->thenReturn(true);
\Phake::when($this->drv)->userPasswordUnset("jane.doe@example.net", null)->thenThrow(new \JKingWeb\Arsse\User\Exception("doesNotExist"));
\Phake::when(Arsse::$db)->userExists->thenReturn($exists);
@@ -303,26 +256,19 @@ class TestUser extends \JKingWeb\Arsse\Test\AbstractTest {
$this->assertSame($exp, $u->passwordUnset($user));
}
} finally {
- \Phake::verify(Arsse::$db, \Phake::times((int) ($authorized && $exists && is_bool($exp))))->userPasswordSet($user, null);
+ \Phake::verify(Arsse::$db, \Phake::times((int) ($exists && is_bool($exp))))->userPasswordSet($user, null);
}
}
public function providePasswordClearings(): iterable {
- $forbidden = new \JKingWeb\Arsse\User\ExceptionAuthz("notAuthorized");
$missing = new \JKingWeb\Arsse\User\Exception("doesNotExist");
return [
- [false, true, "jane.doe@example.com", $forbidden],
- [false, true, "john.doe@example.com", $forbidden],
- [false, true, "jane.doe@example.net", $forbidden],
- [false, false, "jane.doe@example.com", $forbidden],
- [false, false, "john.doe@example.com", $forbidden],
- [false, false, "jane.doe@example.net", $forbidden],
- [true, true, "jane.doe@example.com", true],
- [true, true, "john.doe@example.com", true],
- [true, true, "jane.doe@example.net", $missing],
- [true, false, "jane.doe@example.com", true],
- [true, false, "john.doe@example.com", true],
- [true, false, "jane.doe@example.net", $missing],
+ [true, "jane.doe@example.com", true],
+ [true, "john.doe@example.com", true],
+ [true, "jane.doe@example.net", $missing],
+ [false, "jane.doe@example.com", true],
+ [false, "john.doe@example.com", true],
+ [false, "jane.doe@example.net", $missing],
];
}
}
From 5a17efc7b5e5000189354009585b2179099b98bc Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Mon, 9 Nov 2020 18:14:03 -0500
Subject: [PATCH 035/366] Clean up user driver API
- It is no longer assumed a driver knows whether a user exists
- The $password param is now required (but nullable when setting
---
lib/ImportExport/AbstractImportExport.php | 2 +-
lib/ImportExport/OPML.php | 2 +-
lib/User.php | 4 --
lib/User/Driver.php | 55 +++++++++++++------
lib/User/ExceptionAuthz.php | 10 ----
lib/User/Internal/Driver.php | 10 ++--
tests/cases/ImportExport/TestImportExport.php | 4 +-
tests/cases/ImportExport/TestOPML.php | 5 +-
tests/cases/User/TestInternal.php | 26 ++-------
tests/cases/User/TestUser.php | 17 ------
10 files changed, 55 insertions(+), 80 deletions(-)
delete mode 100644 lib/User/ExceptionAuthz.php
diff --git a/lib/ImportExport/AbstractImportExport.php b/lib/ImportExport/AbstractImportExport.php
index 22c1f2b1..72064825 100644
--- a/lib/ImportExport/AbstractImportExport.php
+++ b/lib/ImportExport/AbstractImportExport.php
@@ -13,7 +13,7 @@ use JKingWeb\Arsse\User\Exception as UserException;
abstract class AbstractImportExport {
public function import(string $user, string $data, bool $flat = false, bool $replace = false): bool {
- if (!Arsse::$user->exists($user)) {
+ if (!Arsse::$db->userExists($user)) {
throw new UserException("doesNotExist", ["action" => __FUNCTION__, "user" => $user]);
}
// first extract useful information from the input
diff --git a/lib/ImportExport/OPML.php b/lib/ImportExport/OPML.php
index 30a3cc51..30cb4f56 100644
--- a/lib/ImportExport/OPML.php
+++ b/lib/ImportExport/OPML.php
@@ -91,7 +91,7 @@ class OPML extends AbstractImportExport {
}
public function export(string $user, bool $flat = false): string {
- if (!Arsse::$user->exists($user)) {
+ if (!Arsse::$db->userExists($user)) {
throw new UserException("doesNotExist", ["action" => __FUNCTION__, "user" => $user]);
}
$tags = [];
diff --git a/lib/User.php b/lib/User.php
index 37ae814b..56e716b7 100644
--- a/lib/User.php
+++ b/lib/User.php
@@ -48,10 +48,6 @@ class User {
return $this->u->userList();
}
- public function exists(string $user): bool {
- return $this->u->userExists($user);
- }
-
public function add($user, $password = null): string {
return $this->u->userAdd($user, $password) ?? $this->u->userAdd($user, $this->generatePassword());
}
diff --git a/lib/User/Driver.php b/lib/User/Driver.php
index e36ca38a..6bfa25b5 100644
--- a/lib/User/Driver.php
+++ b/lib/User/Driver.php
@@ -7,26 +7,49 @@ declare(strict_types=1);
namespace JKingWeb\Arsse\User;
interface Driver {
- public const FUNC_NOT_IMPLEMENTED = 0;
- public const FUNC_INTERNAL = 1;
- public const FUNC_EXTERNAL = 2;
-
- // returns an instance of a class implementing this interface.
public function __construct();
- // returns a human-friendly name for the driver (for display in installer, for example)
+
+ /** Returns a human-friendly name for the driver (for display in installer, for example) */
public static function driverName(): string;
- // authenticates a user against their name and password
+
+ /** Authenticates a user against their name and password */
public function auth(string $user, string $password): bool;
- // checks whether a user exists
- public function userExists(string $user): bool;
- // adds a user
- public function userAdd(string $user, string $password = null);
- // removes a user
+
+ /** Adds a new user and returns their password
+ *
+ * When given no password the implementation may return null; the user
+ * manager will then generate a random password and try again with that
+ * password. Alternatively the implementation may generate its own
+ * password if desired
+ *
+ * @param string $user The username to create
+ * @param string|null $password The cleartext password to assign to the user, or null to generate a random password
+ */
+ public function userAdd(string $user, string $password = null): ?string;
+
+ /** Removes a user */
public function userRemove(string $user): bool;
- // lists all users
+
+ /** Lists all users */
public function userList(): array;
- // sets a user's password; if the driver does not require the old password, it may be ignored
- public function userPasswordSet(string $user, string $newPassword = null, string $oldPassword = null);
- // removes a user's password; this makes authentication fail unconditionally
+
+ /** sets a user's password
+ *
+ * When given no password the implementation may return null; the user
+ * manager will then generate a random password and try again with that
+ * password. Alternatively the implementation may generate its own
+ * password if desired
+ *
+ * @param string $user The user for whom to change the password
+ * @param string|null $password The cleartext password to assign to the user, or null to generate a random password
+ * @param string|null $oldPassword The user's previous password, if known
+ */
+ public function userPasswordSet(string $user, ?string $newPassword, string $oldPassword = null);
+
+ /** removes a user's password; this makes authentication fail unconditionally
+ *
+ * @param string $user The user for whom to change the password
+ * @param string|null $oldPassword The user's previous password, if known
+ */
public function userPasswordUnset(string $user, string $oldPassword = null): bool;
}
diff --git a/lib/User/ExceptionAuthz.php b/lib/User/ExceptionAuthz.php
deleted file mode 100644
index 2d16f594..00000000
--- a/lib/User/ExceptionAuthz.php
+++ /dev/null
@@ -1,10 +0,0 @@
-userExists($user);
- }
-
public function userAdd(string $user, string $password = null): ?string {
if (isset($password)) {
// only add the user if the password is not null; the user manager will retry with a generated password if null is returned
@@ -52,7 +48,7 @@ class Driver implements \JKingWeb\Arsse\User\Driver {
return Arsse::$db->userList();
}
- public function userPasswordSet(string $user, string $newPassword = null, string $oldPassword = null): ?string {
+ public function userPasswordSet(string $user, ?string $newPassword, string $oldPassword = null): ?string {
// do nothing: the internal database is updated regardless of what the driver does (assuming it does not throw an exception)
return $newPassword;
}
@@ -70,4 +66,8 @@ class Driver implements \JKingWeb\Arsse\User\Driver {
protected function userPasswordGet(string $user): ?string {
return Arsse::$db->userPasswordGet($user);
}
+
+ protected function userExists(string $user): bool {
+ return Arsse::$db->userExists($user);
+ }
}
diff --git a/tests/cases/ImportExport/TestImportExport.php b/tests/cases/ImportExport/TestImportExport.php
index 64eaf905..1a899c4c 100644
--- a/tests/cases/ImportExport/TestImportExport.php
+++ b/tests/cases/ImportExport/TestImportExport.php
@@ -27,7 +27,6 @@ class TestImportExport extends \JKingWeb\Arsse\Test\AbstractTest {
self::clearData();
// create a mock user manager
Arsse::$user = \Phake::mock(\JKingWeb\Arsse\User::class);
- \Phake::when(Arsse::$user)->exists->thenReturn(true);
// create a mock Import/Export processor
$this->proc = \Phake::partialMock(AbstractImportExport::class);
// initialize an SQLite memeory database
@@ -147,9 +146,8 @@ class TestImportExport extends \JKingWeb\Arsse\Test\AbstractTest {
}
public function testImportForAMissingUser(): void {
- \Phake::when(Arsse::$user)->exists->thenReturn(false);
$this->assertException("doesNotExist", "User");
- $this->proc->import("john.doe@example.com", "", false, false);
+ $this->proc->import("no.one@example.com", "", false, false);
}
public function testImportWithInvalidFolder(): void {
diff --git a/tests/cases/ImportExport/TestOPML.php b/tests/cases/ImportExport/TestOPML.php
index 1e65fa1e..36caa77c 100644
--- a/tests/cases/ImportExport/TestOPML.php
+++ b/tests/cases/ImportExport/TestOPML.php
@@ -82,8 +82,7 @@ OPML_EXPORT_SERIALIZATION;
public function setUp(): void {
self::clearData();
Arsse::$db = \Phake::mock(\JKingWeb\Arsse\Database::class);
- Arsse::$user = \Phake::mock(\JKingWeb\Arsse\User::class);
- \Phake::when(Arsse::$user)->exists->thenReturn(true);
+ \Phake::when(Arsse::$db)->userExists->thenReturn(true);
}
public function testExportToOpml(): void {
@@ -101,7 +100,7 @@ OPML_EXPORT_SERIALIZATION;
}
public function testExportToOpmlAMissingUser(): void {
- \Phake::when(Arsse::$user)->exists->thenReturn(false);
+ \Phake::when(Arsse::$db)->userExists->thenReturn(false);
$this->assertException("doesNotExist", "User");
(new OPML)->export("john.doe@example.com");
}
diff --git a/tests/cases/User/TestInternal.php b/tests/cases/User/TestInternal.php
index 6a88b4dc..21587f3d 100644
--- a/tests/cases/User/TestInternal.php
+++ b/tests/cases/User/TestInternal.php
@@ -75,18 +75,6 @@ class TestInternal extends \JKingWeb\Arsse\Test\AbstractTest {
\Phake::verify(Arsse::$db, \Phake::times(2))->userList;
}
- public function testCheckThatAUserExists(): void {
- $john = "john.doe@example.com";
- $jane = "jane.doe@example.com";
- \Phake::when(Arsse::$db)->userExists($john)->thenReturn(true);
- \Phake::when(Arsse::$db)->userExists($jane)->thenReturn(false);
- $driver = new Driver;
- $this->assertTrue($driver->userExists($john));
- \Phake::verify(Arsse::$db)->userExists($john);
- $this->assertFalse($driver->userExists($jane));
- \Phake::verify(Arsse::$db)->userExists($jane);
- }
-
public function testAddAUser(): void {
$john = "john.doe@example.com";
\Phake::when(Arsse::$db)->userAdd->thenReturnCallback(function($user, $pass) {
@@ -119,20 +107,18 @@ class TestInternal extends \JKingWeb\Arsse\Test\AbstractTest {
\Phake::verifyNoFurtherInteraction(Arsse::$db);
$this->assertSame("superman", (new Driver)->userPasswordSet($john, "superman"));
$this->assertSame(null, (new Driver)->userPasswordSet($john, null));
+ \Phake::verify(Arsse::$db, \Phake::times(0))->userPasswordSet;
}
public function testUnsetAPassword(): void {
- $drv = \Phake::partialMock(Driver::class);
- \Phake::when($drv)->userExists->thenReturn(true);
- \Phake::verifyNoFurtherInteraction(Arsse::$db);
- $this->assertTrue($drv->userPasswordUnset("john.doe@example.com"));
+ \Phake::when(Arsse::$db)->userExists->thenReturn(true);
+ $this->assertTrue((new Driver)->userPasswordUnset("john.doe@example.com"));
+ \Phake::verify(Arsse::$db, \Phake::times(0))->userPasswordUnset;
}
public function testUnsetAPasswordForAMssingUser(): void {
- $drv = \Phake::partialMock(Driver::class);
- \Phake::when($drv)->userExists->thenReturn(false);
- \Phake::verifyNoFurtherInteraction(Arsse::$db);
+ \Phake::when(Arsse::$db)->userExists->thenReturn(false);
$this->assertException("doesNotExist", "User");
- $drv->userPasswordUnset("john.doe@example.com");
+ (new Driver)->userPasswordUnset("john.doe@example.com");
}
}
diff --git a/tests/cases/User/TestUser.php b/tests/cases/User/TestUser.php
index 80be0e6f..759241c2 100644
--- a/tests/cases/User/TestUser.php
+++ b/tests/cases/User/TestUser.php
@@ -83,23 +83,6 @@ class TestUser extends \JKingWeb\Arsse\Test\AbstractTest {
];
}
- /** @dataProvider provideExistence */
- public function testCheckThatAUserExists(string $user, $exp): void {
- $u = new User($this->drv);
- \Phake::when($this->drv)->userExists("john.doe@example.com")->thenReturn(true);
- \Phake::when($this->drv)->userExists("jane.doe@example.com")->thenReturn(false);
- $this->assertSame($exp, $u->exists($user));
- }
-
- public function provideExistence(): iterable {
- $john = "john.doe@example.com";
- $jane = "jane.doe@example.com";
- return [
- [$john, true],
- [$jane, false],
- ];
- }
-
/** @dataProvider provideAdditions */
public function testAddAUser(string $user, $password, $exp): void {
$u = new User($this->drv);
From eb2fe522bf529207956d56a50f5c693b3d28085d Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Tue, 10 Nov 2020 17:09:59 -0500
Subject: [PATCH 036/366] Last bits of the new user metadata handling
---
lib/User.php | 6 +++---
lib/User/Driver.php | 29 +++++++++++++++++++++++++++--
lib/User/Internal/Driver.php | 18 ++++++++++++++++++
3 files changed, 48 insertions(+), 5 deletions(-)
diff --git a/lib/User.php b/lib/User.php
index 56e716b7..afe4920c 100644
--- a/lib/User.php
+++ b/lib/User.php
@@ -90,10 +90,10 @@ class User {
}
public function propertiesGet(string $user): array {
+ $extra = $this->u->userPropertiesGet($user);
// unconditionally retrieve from the database to get at least the user number, and anything else the driver does not provide
$out = Arsse::$db->userPropertiesGet($user);
// layer on the driver's data
- $extra = $this->u->userPropertiesGet($user);
foreach (["lang", "tz", "admin", "sort_asc"] as $k) {
if (array_key_exists($k, $extra)) {
$out[$k] = $extra[$k] ?? $out[$k];
@@ -102,7 +102,7 @@ class User {
return $out;
}
- public function propertiesSet(string $user, array $data): bool {
+ public function propertiesSet(string $user, array $data): array {
$in = [];
if (array_key_exists("tz", $data)) {
if (!is_string($data['tz'])) {
@@ -125,7 +125,7 @@ class User {
}
$out = $this->u->userPropertiesSet($user, $in);
// synchronize the internal database
- Arsse::$db->userPropertiesSet($user, $in);
+ Arsse::$db->userPropertiesSet($user, $out);
return $out;
}
}
diff --git a/lib/User/Driver.php b/lib/User/Driver.php
index 6bfa25b5..dbf8ad68 100644
--- a/lib/User/Driver.php
+++ b/lib/User/Driver.php
@@ -33,7 +33,7 @@ interface Driver {
/** Lists all users */
public function userList(): array;
- /** sets a user's password
+ /** Sets a user's password
*
* When given no password the implementation may return null; the user
* manager will then generate a random password and try again with that
@@ -46,10 +46,35 @@ interface Driver {
*/
public function userPasswordSet(string $user, ?string $newPassword, string $oldPassword = null);
- /** removes a user's password; this makes authentication fail unconditionally
+ /** Removes a user's password; this makes authentication fail unconditionally
*
* @param string $user The user for whom to change the password
* @param string|null $oldPassword The user's previous password, if known
*/
public function userPasswordUnset(string $user, string $oldPassword = null): bool;
+
+ /** Retrieves metadata about a user
+ *
+ * Any expected keys not returned by the driver are taken from the internal
+ * database instead; the expected keys at this time are:
+ *
+ * - admin: A boolean denoting whether the user has administrator privileges
+ * - lang: A BCP 47 language tag e.g. "en", "hy-Latn-IT-arevela"
+ * - tz: A zoneinfo timezone e.g. "Asia/Jakarta", "America/Argentina/La_Rioja"
+ * - sort_asc: A boolean denoting whether the user prefers articles to be sorted oldest-first
+ *
+ * Any other keys will be ignored.
+ */
+ public function userPropertiesGet(string $user): array;
+
+ /** Sets metadata about a user
+ *
+ * Output should be the same as the input, unless input is changed prior to storage
+ * (if it is, for instance, normalized in some way), which which case the changes
+ * should be reflected in the output.
+ *
+ * @param string $user The user for which to set metadata
+ * @param array $data The input data; see userPropertiesGet for keys
+ */
+ public function userPropertiesSet(string $user, array $data): array;
}
diff --git a/lib/User/Internal/Driver.php b/lib/User/Internal/Driver.php
index 5114498e..6b8e4438 100644
--- a/lib/User/Internal/Driver.php
+++ b/lib/User/Internal/Driver.php
@@ -70,4 +70,22 @@ class Driver implements \JKingWeb\Arsse\User\Driver {
protected function userExists(string $user): bool {
return Arsse::$db->userExists($user);
}
+
+ public function userPropertiesGet(string $user): array {
+ // do nothing: the internal database will retrieve everything for us
+ if (!$this->userExists($user)) {
+ throw new Exception("doesNotExist", ['action' => "userPasswordUnset", 'user' => $user]);
+ } else {
+ return [];
+ }
+ }
+
+ public function userPropertiesSet(string $user, array $data): array {
+ // do nothing: the internal database will set everything for us
+ if (!$this->userExists($user)) {
+ throw new Exception("doesNotExist", ['action' => "userPasswordUnset", 'user' => $user]);
+ } else {
+ return $data;
+ }
+ }
}
From dde9d7a28a37066b124d6ad41c99464ac9dc1953 Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Wed, 11 Nov 2020 18:50:27 -0500
Subject: [PATCH 037/366] Refinements to user manager
A greater effort is made to keep the internal database synchronized
---
lib/User.php | 21 ++++++++++++++++++++-
lib/User/ExceptionNotImplemented.php | 10 ----------
2 files changed, 20 insertions(+), 11 deletions(-)
delete mode 100644 lib/User/ExceptionNotImplemented.php
diff --git a/lib/User.php b/lib/User.php
index afe4920c..8ebee8a5 100644
--- a/lib/User.php
+++ b/lib/User.php
@@ -49,7 +49,12 @@ class User {
}
public function add($user, $password = null): string {
- return $this->u->userAdd($user, $password) ?? $this->u->userAdd($user, $this->generatePassword());
+ $out = $this->u->userAdd($user, $password) ?? $this->u->userAdd($user, $this->generatePassword());
+ // synchronize the internal database
+ if (!Arsse::$db->userExists($user)) {
+ Arsse::$db->userAdd($user, $out);
+ }
+ return $out;
}
public function remove(string $user): bool {
@@ -70,6 +75,9 @@ class User {
Arsse::$db->userPasswordSet($user, $out);
// also invalidate any current sessions for the user
Arsse::$db->sessionDestroy($user);
+ } else {
+ // if the user does not exist, add it with the new password
+ Arsse::$db->userAdd($user, $out);
}
return $out;
}
@@ -81,6 +89,10 @@ class User {
Arsse::$db->userPasswordSet($user, null);
// also invalidate any current sessions for the user
Arsse::$db->sessionDestroy($user);
+ } else {
+ // if the user does not exist
+ Arsse::$db->userAdd($user, "");
+ Arsse::$db->userPasswordSet($user, null);
}
return $out;
}
@@ -91,6 +103,10 @@ class User {
public function propertiesGet(string $user): array {
$extra = $this->u->userPropertiesGet($user);
+ // synchronize the internal database
+ if (!Arsse::$db->userExists($user)) {
+ Arsse::$db->userAdd($user, $this->generatePassword());
+ }
// unconditionally retrieve from the database to get at least the user number, and anything else the driver does not provide
$out = Arsse::$db->userPropertiesGet($user);
// layer on the driver's data
@@ -125,6 +141,9 @@ class User {
}
$out = $this->u->userPropertiesSet($user, $in);
// synchronize the internal database
+ if (!Arsse::$db->userExists($user)) {
+ Arsse::$db->userAdd($user, $this->generatePassword());
+ }
Arsse::$db->userPropertiesSet($user, $out);
return $out;
}
diff --git a/lib/User/ExceptionNotImplemented.php b/lib/User/ExceptionNotImplemented.php
deleted file mode 100644
index 12518ac7..00000000
--- a/lib/User/ExceptionNotImplemented.php
+++ /dev/null
@@ -1,10 +0,0 @@
-
Date: Fri, 13 Nov 2020 19:30:23 -0500
Subject: [PATCH 038/366] Tests for new user functionality in Database
---
lib/Database.php | 10 ++++--
tests/cases/Database/SeriesUser.php | 56 +++++++++++++++++++++++++++--
2 files changed, 60 insertions(+), 6 deletions(-)
diff --git a/lib/Database.php b/lib/Database.php
index eff40a52..a531f23d 100644
--- a/lib/Database.php
+++ b/lib/Database.php
@@ -300,10 +300,11 @@ class Database {
}
public function userPropertiesGet(string $user): array {
- if (!$this->userExists($user)) {
+ $out = $this->db->prepare("SELECT num, admin, lang, tz, sort_asc from arsse_users where id = ?", "str")->run($user)->getRow();
+ if (!$out) {
throw new User\Exception("doesNotExist", ["action" => __FUNCTION__, "user" => $user]);
}
- $out = $this->db->prepare("SELECT num, admin, lang, tz, sort_asc from arsse_users where id = ?", "str")->run($user)->getRow();
+ settype($out['num'], "int");
settype($out['admin'], "bool");
settype($out['sort_asc'], "bool");
return $out;
@@ -320,7 +321,10 @@ class Database {
'sort_asc' => "strict bool",
];
[$setClause, $setTypes, $setValues] = $this->generateSet($data, $allowed);
- return (bool) $this->db->prepare("UPDATE arsse_users set $setClause where user = ?", $setTypes, "str")->run($setValues, $user)->changes();
+ if (!$setClause) {
+ return false;
+ }
+ return (bool) $this->db->prepare("UPDATE arsse_users set $setClause where id = ?", $setTypes, "str")->run($setValues, $user)->changes();
}
diff --git a/tests/cases/Database/SeriesUser.php b/tests/cases/Database/SeriesUser.php
index 3211cc9f..10bd2582 100644
--- a/tests/cases/Database/SeriesUser.php
+++ b/tests/cases/Database/SeriesUser.php
@@ -16,11 +16,15 @@ trait SeriesUser {
'id' => 'str',
'password' => 'str',
'num' => 'int',
+ 'admin' => 'bool',
+ 'lang' => 'str',
+ 'tz' => 'str',
+ 'sort_asc' => 'bool',
],
'rows' => [
- ["admin@example.net", '$2y$10$PbcG2ZR3Z8TuPzM7aHTF8.v61dtCjzjK78gdZJcp4UePE8T9jEgBW',1], // password is hash of "secret"
- ["jane.doe@example.com", "",2],
- ["john.doe@example.com", "",3],
+ ["admin@example.net", '$2y$10$PbcG2ZR3Z8TuPzM7aHTF8.v61dtCjzjK78gdZJcp4UePE8T9jEgBW',1, 1, "en", "America/Toronto", 0], // password is hash of "secret"
+ ["jane.doe@example.com", "",2, 0, "fr", "Asia/Kuala_Lumpur", 1],
+ ["john.doe@example.com", "",3, 0, null, "Etc/UTC", 0],
],
],
];
@@ -100,4 +104,50 @@ trait SeriesUser {
$this->assertException("doesNotExist", "User");
Arsse::$db->userPasswordSet("john.doe@example.org", "secret");
}
+
+ /** @dataProvider provideMetaData */
+ public function testGetMetadata(string $user, array $exp): void {
+ $this->assertSame($exp, Arsse::$db->userPropertiesGet($user));
+ }
+
+ public function provideMetadata() {
+ return [
+ ["admin@example.net", ['num' => 1, 'admin' => true, 'lang' => "en", 'tz' => "America/Toronto", 'sort_asc' => false]],
+ ["jane.doe@example.com", ['num' => 2, 'admin' => false, 'lang' => "fr", 'tz' => "Asia/Kuala_Lumpur", 'sort_asc' => true]],
+ ["john.doe@example.com", ['num' => 3, 'admin' => false, 'lang' => null, 'tz' => "Etc/UTC", 'sort_asc' => false]],
+ ];
+ }
+
+ public function testGetTheMetadataOfAMissingUser(): void {
+ $this->assertException("doesNotExist", "User");
+ Arsse::$db->userPropertiesGet("john.doe@example.org");
+ }
+
+ public function testSetMetadata(): void {
+ $in = [
+ 'admin' => true,
+ 'lang' => "en-ca",
+ 'tz' => "Atlantic/Reykjavik",
+ 'sort_asc' => true,
+ ];
+ $this->assertTrue(Arsse::$db->userPropertiesSet("john.doe@example.com", $in));
+ $state = $this->primeExpectations($this->data, ['arsse_users' => ['id', 'num', 'admin', 'lang', 'tz', 'sort_asc']]);
+ $state['arsse_users']['rows'][2] = ["john.doe@example.com", 3, 1, "en-ca", "Atlantic/Reykjavik", 1];
+ $this->compareExpectations(static::$drv, $state);
+ }
+
+ public function testSetNoMetadata(): void {
+ $in = [
+ 'num' => 2112,
+ 'blah' => "bloo"
+ ];
+ $this->assertFalse(Arsse::$db->userPropertiesSet("john.doe@example.com", $in));
+ $state = $this->primeExpectations($this->data, ['arsse_users' => ['id', 'num', 'admin', 'lang', 'tz', 'sort_asc']]);
+ $this->compareExpectations(static::$drv, $state);
+ }
+
+ public function testSetTheMetadataOfAMissingUser(): void {
+ $this->assertException("doesNotExist", "User");
+ Arsse::$db->userPropertiesSet("john.doe@example.org", ['admin' => true]);
+ }
}
From 351f97251273fe14b859caac6a938a355f9ea384 Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Fri, 13 Nov 2020 21:41:27 -0500
Subject: [PATCH 039/366] Tests for internal user driver
---
tests/cases/User/TestInternal.php | 37 +++++++++++++++++++++++++++++++
1 file changed, 37 insertions(+)
diff --git a/tests/cases/User/TestInternal.php b/tests/cases/User/TestInternal.php
index 21587f3d..cd231a0a 100644
--- a/tests/cases/User/TestInternal.php
+++ b/tests/cases/User/TestInternal.php
@@ -121,4 +121,41 @@ class TestInternal extends \JKingWeb\Arsse\Test\AbstractTest {
$this->assertException("doesNotExist", "User");
(new Driver)->userPasswordUnset("john.doe@example.com");
}
+
+ public function testGetUserProperties(): void {
+ \Phake::when(Arsse::$db)->userExists->thenReturn(true);
+ $this->assertSame([], (new Driver)->userPropertiesGet("john.doe@example.com"));
+ \Phake::verify(Arsse::$db)->userExists("john.doe@example.com");
+ \Phake::verifyNoFurtherInteraction(Arsse::$db);
+ }
+
+ public function testGetPropertiesForAMissingUser(): void {
+ \Phake::when(Arsse::$db)->userExists->thenReturn(false);
+ $this->assertException("doesNotExist", "User");
+ try {
+ (new Driver)->userPropertiesGet("john.doe@example.com");
+ } finally {
+ \Phake::verify(Arsse::$db)->userExists("john.doe@example.com");
+ \Phake::verifyNoFurtherInteraction(Arsse::$db);
+ }
+ }
+
+ public function testSetUserProperties(): void {
+ $in = ['admin' => true];
+ \Phake::when(Arsse::$db)->userExists->thenReturn(true);
+ $this->assertSame($in, (new Driver)->userPropertiesSet("john.doe@example.com", $in));
+ \Phake::verify(Arsse::$db)->userExists("john.doe@example.com");
+ \Phake::verifyNoFurtherInteraction(Arsse::$db);
+ }
+
+ public function testSetPropertiesForAMissingUser(): void {
+ \Phake::when(Arsse::$db)->userExists->thenReturn(false);
+ $this->assertException("doesNotExist", "User");
+ try {
+ (new Driver)->userPropertiesSet("john.doe@example.com", ['admin' => true]);
+ } finally {
+ \Phake::verify(Arsse::$db)->userExists("john.doe@example.com");
+ \Phake::verifyNoFurtherInteraction(Arsse::$db);
+ }
+ }
}
From 7f2117adaaff3fcd544e56342add5bfd54e9783a Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Sun, 15 Nov 2020 16:24:26 -0500
Subject: [PATCH 040/366] Differentiate between duplicate/missing users and
other failure modes
---
lib/AbstractException.php | 5 +-
lib/Database.php | 22 +-
lib/ImportExport/AbstractImportExport.php | 2 +-
lib/ImportExport/OPML.php | 2 +-
lib/REST/Fever/API.php | 2 +-
lib/User.php | 15 +-
lib/User/ExceptionConflict.php | 10 +
lib/User/Internal/Driver.php | 10 +-
locale/en.php | 4 +-
tests/cases/CLI/TestCLI.php | 8 +-
tests/cases/Database/SeriesToken.php | 2 +-
tests/cases/Database/SeriesUser.php | 14 +-
tests/cases/ImportExport/TestImportExport.php | 2 +-
tests/cases/ImportExport/TestOPML.php | 2 +-
tests/cases/REST/Fever/TestUser.php | 2 +-
tests/cases/User/TestInternal.php | 12 +-
tests/cases/User/TestUser.php | 210 ++++--------------
17 files changed, 109 insertions(+), 215 deletions(-)
create mode 100644 lib/User/ExceptionConflict.php
diff --git a/lib/AbstractException.php b/lib/AbstractException.php
index 93798ca1..22b6eb52 100644
--- a/lib/AbstractException.php
+++ b/lib/AbstractException.php
@@ -68,11 +68,10 @@ abstract class AbstractException extends \Exception {
"Conf/Exception.typeMismatch" => 10311,
"Conf/Exception.semanticMismatch" => 10312,
"Conf/Exception.ambiguousDefault" => 10313,
- "User/Exception.functionNotImplemented" => 10401,
- "User/Exception.doesNotExist" => 10402,
- "User/Exception.alreadyExists" => 10403,
"User/Exception.authMissing" => 10411,
"User/Exception.authFailed" => 10412,
+ "User/ExceptionConflict.doesNotExist" => 10402,
+ "User/ExceptionConflict.alreadyExists" => 10403,
"User/ExceptionSession.invalid" => 10431,
"User/ExceptionInput.invalidTimezone" => 10441,
"User/ExceptionInput.invalidBoolean" => 10442,
diff --git a/lib/Database.php b/lib/Database.php
index a531f23d..660fbc4a 100644
--- a/lib/Database.php
+++ b/lib/Database.php
@@ -248,11 +248,11 @@ class Database {
/** Adds a user to the database
*
* @param string $user The user to add
- * @param string $passwordThe user's password in cleartext. It will be stored hashed
+ * @param string|null $passwordThe user's password in cleartext. It will be stored hashed
*/
- public function userAdd(string $user, string $password): bool {
+ public function userAdd(string $user, ?string $password): bool {
if ($this->userExists($user)) {
- throw new User\Exception("alreadyExists", ["action" => __FUNCTION__, "user" => $user]);
+ throw new User\ExceptionConflict("alreadyExists", ["action" => __FUNCTION__, "user" => $user]);
}
$hash = (strlen($password) > 0) ? password_hash($password, \PASSWORD_DEFAULT) : "";
// NOTE: This roundabout construction (with 'select' rather than 'values') is required by MySQL, because MySQL is riddled with pitfalls and exceptions
@@ -263,7 +263,7 @@ class Database {
/** Removes a user from the database */
public function userRemove(string $user): bool {
if ($this->db->prepare("DELETE from arsse_users where id = ?", "str")->run($user)->changes() < 1) {
- throw new User\Exception("doesNotExist", ["action" => __FUNCTION__, "user" => $user]);
+ throw new User\ExceptionConflict("doesNotExist", ["action" => __FUNCTION__, "user" => $user]);
}
return true;
}
@@ -280,7 +280,7 @@ class Database {
/** Retrieves the hashed password of a user */
public function userPasswordGet(string $user): ?string {
if (!$this->userExists($user)) {
- throw new User\Exception("doesNotExist", ["action" => __FUNCTION__, "user" => $user]);
+ throw new User\ExceptionConflict("doesNotExist", ["action" => __FUNCTION__, "user" => $user]);
}
return $this->db->prepare("SELECT password from arsse_users where id = ?", "str")->run($user)->getValue();
}
@@ -288,11 +288,11 @@ class Database {
/** Sets the password of an existing user
*
* @param string $user The user for whom to set the password
- * @param string $password The new password, in cleartext. The password will be stored hashed. If null is passed, the password is unset and authentication not possible
+ * @param string|null $password The new password, in cleartext. The password will be stored hashed. If null is passed, the password is unset and authentication not possible
*/
- public function userPasswordSet(string $user, string $password = null): bool {
+ public function userPasswordSet(string $user, ?string $password): bool {
if (!$this->userExists($user)) {
- throw new User\Exception("doesNotExist", ["action" => __FUNCTION__, "user" => $user]);
+ throw new User\ExceptionConflict("doesNotExist", ["action" => __FUNCTION__, "user" => $user]);
}
$hash = (strlen($password ?? "") > 0) ? password_hash($password, \PASSWORD_DEFAULT) : $password;
$this->db->prepare("UPDATE arsse_users set password = ? where id = ?", "str", "str")->run($hash, $user);
@@ -302,7 +302,7 @@ class Database {
public function userPropertiesGet(string $user): array {
$out = $this->db->prepare("SELECT num, admin, lang, tz, sort_asc from arsse_users where id = ?", "str")->run($user)->getRow();
if (!$out) {
- throw new User\Exception("doesNotExist", ["action" => __FUNCTION__, "user" => $user]);
+ throw new User\ExceptionConflict("doesNotExist", ["action" => __FUNCTION__, "user" => $user]);
}
settype($out['num'], "int");
settype($out['admin'], "bool");
@@ -312,7 +312,7 @@ class Database {
public function userPropertiesSet(string $user, array $data): bool {
if (!$this->userExists($user)) {
- throw new User\Exception("doesNotExist", ["action" => __FUNCTION__, "user" => $user]);
+ throw new User\ExceptionConflict("doesNotExist", ["action" => __FUNCTION__, "user" => $user]);
}
$allowed = [
'admin' => "strict bool",
@@ -402,7 +402,7 @@ class Database {
*/
public function tokenCreate(string $user, string $class, string $id = null, \DateTimeInterface $expires = null): string {
if (!$this->userExists($user)) {
- throw new User\Exception("doesNotExist", ["action" => __FUNCTION__, "user" => $user]);
+ throw new User\ExceptionConflict("doesNotExist", ["action" => __FUNCTION__, "user" => $user]);
}
// generate a token if it's not provided
$id = $id ?? UUID::mint()->hex;
diff --git a/lib/ImportExport/AbstractImportExport.php b/lib/ImportExport/AbstractImportExport.php
index 72064825..6f0496fd 100644
--- a/lib/ImportExport/AbstractImportExport.php
+++ b/lib/ImportExport/AbstractImportExport.php
@@ -9,7 +9,7 @@ namespace JKingWeb\Arsse\ImportExport;
use JKingWeb\Arsse\Arsse;
use JKingWeb\Arsse\Database;
use JKingWeb\Arsse\Db\ExceptionInput as InputException;
-use JKingWeb\Arsse\User\Exception as UserException;
+use JKingWeb\Arsse\User\ExceptionConflict as UserException;
abstract class AbstractImportExport {
public function import(string $user, string $data, bool $flat = false, bool $replace = false): bool {
diff --git a/lib/ImportExport/OPML.php b/lib/ImportExport/OPML.php
index 30cb4f56..85d136cf 100644
--- a/lib/ImportExport/OPML.php
+++ b/lib/ImportExport/OPML.php
@@ -7,7 +7,7 @@ declare(strict_types=1);
namespace JKingWeb\Arsse\ImportExport;
use JKingWeb\Arsse\Arsse;
-use JKingWeb\Arsse\User\Exception as UserException;
+use JKingWeb\Arsse\User\ExceptionConflict as UserException;
class OPML extends AbstractImportExport {
protected function parse(string $opml, bool $flat): array {
diff --git a/lib/REST/Fever/API.php b/lib/REST/Fever/API.php
index 1901397f..3382e6cb 100644
--- a/lib/REST/Fever/API.php
+++ b/lib/REST/Fever/API.php
@@ -241,7 +241,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler {
try {
// verify the supplied hash is valid
$s = Arsse::$db->TokenLookup("fever.login", $hash);
- } catch (\JKingWeb\Arsse\Db\ExceptionInput $e) {
+ } catch (ExceptionInput $e) {
return false;
}
// set the user name
diff --git a/lib/User.php b/lib/User.php
index 8ebee8a5..ffb6d4aa 100644
--- a/lib/User.php
+++ b/lib/User.php
@@ -48,11 +48,14 @@ class User {
return $this->u->userList();
}
- public function add($user, $password = null): string {
- $out = $this->u->userAdd($user, $password) ?? $this->u->userAdd($user, $this->generatePassword());
- // synchronize the internal database
- if (!Arsse::$db->userExists($user)) {
- Arsse::$db->userAdd($user, $out);
+ public function add(string $user, ?string $password = null): string {
+ try {
+ $out = $this->u->userAdd($user, $password) ?? $this->u->userAdd($user, $this->generatePassword());
+ } finally {
+ // synchronize the internal database
+ if (!Arsse::$db->userExists($user)) {
+ Arsse::$db->userAdd($user, $out ?? null);
+ }
}
return $out;
}
@@ -68,7 +71,7 @@ class User {
}
}
- public function passwordSet(string $user, string $newPassword = null, $oldPassword = null): string {
+ public function passwordSet(string $user, ?string $newPassword, $oldPassword = null): string {
$out = $this->u->userPasswordSet($user, $newPassword, $oldPassword) ?? $this->u->userPasswordSet($user, $this->generatePassword(), $oldPassword);
if (Arsse::$db->userExists($user)) {
// if the password change was successful and the user exists, set the internal password to the same value
diff --git a/lib/User/ExceptionConflict.php b/lib/User/ExceptionConflict.php
new file mode 100644
index 00000000..4fa1bbfd
--- /dev/null
+++ b/lib/User/ExceptionConflict.php
@@ -0,0 +1,10 @@
+userExists($user)) {
- throw new Exception("doesNotExist", ['action' => "userPasswordUnset", 'user' => $user]);
+ throw new ExceptionConflict("doesNotExist", ['action' => "userPasswordUnset", 'user' => $user]);
} else {
return true;
}
@@ -74,7 +74,7 @@ class Driver implements \JKingWeb\Arsse\User\Driver {
public function userPropertiesGet(string $user): array {
// do nothing: the internal database will retrieve everything for us
if (!$this->userExists($user)) {
- throw new Exception("doesNotExist", ['action' => "userPasswordUnset", 'user' => $user]);
+ throw new ExceptionConflict("doesNotExist", ['action' => "userPasswordUnset", 'user' => $user]);
} else {
return [];
}
@@ -83,7 +83,7 @@ class Driver implements \JKingWeb\Arsse\User\Driver {
public function userPropertiesSet(string $user, array $data): array {
// do nothing: the internal database will set everything for us
if (!$this->userExists($user)) {
- throw new Exception("doesNotExist", ['action' => "userPasswordUnset", 'user' => $user]);
+ throw new ExceptionConflict("doesNotExist", ['action' => "userPasswordUnset", 'user' => $user]);
} else {
return $data;
}
diff --git a/locale/en.php b/locale/en.php
index bcc71db1..e5ada184 100644
--- a/locale/en.php
+++ b/locale/en.php
@@ -134,8 +134,8 @@ return [
'Exception.JKingWeb/Arsse/Db/ExceptionInput.engineTypeViolation' => '{0}',
'Exception.JKingWeb/Arsse/Db/ExceptionTimeout.general' => '{0}',
'Exception.JKingWeb/Arsse/Db/ExceptionTimeout.logicalLock' => 'Database is locked',
- 'Exception.JKingWeb/Arsse/User/Exception.alreadyExists' => 'Could not perform action "{action}" because the user {user} already exists',
- 'Exception.JKingWeb/Arsse/User/Exception.doesNotExist' => 'Could not perform action "{action}" because the user {user} does not exist',
+ 'Exception.JKingWeb/Arsse/User/ExceptionConflict.alreadyExists' => 'Could not perform action "{action}" because the user {user} already exists',
+ 'Exception.JKingWeb/Arsse/User/ExceptionConflict.doesNotExist' => 'Could not perform action "{action}" because the user {user} does not exist',
'Exception.JKingWeb/Arsse/User/Exception.authMissing' => 'Please log in to proceed',
'Exception.JKingWeb/Arsse/User/Exception.authFailed' => 'Authentication failed',
'Exception.JKingWeb/Arsse/User/ExceptionSession.invalid' => 'Session with ID {0} does not exist',
diff --git a/tests/cases/CLI/TestCLI.php b/tests/cases/CLI/TestCLI.php
index e6c19e2d..671fbb91 100644
--- a/tests/cases/CLI/TestCLI.php
+++ b/tests/cases/CLI/TestCLI.php
@@ -142,7 +142,7 @@ class TestCLI extends \JKingWeb\Arsse\Test\AbstractTest {
Arsse::$user->method("add")->will($this->returnCallback(function($user, $pass = null) {
switch ($user) {
case "john.doe@example.com":
- throw new \JKingWeb\Arsse\User\Exception("alreadyExists");
+ throw new \JKingWeb\Arsse\User\ExceptionConflict("alreadyExists");
case "jane.doe@example.com":
return is_null($pass) ? "random password" : $pass;
}
@@ -200,7 +200,7 @@ class TestCLI extends \JKingWeb\Arsse\Test\AbstractTest {
if ($user === "john.doe@example.com") {
return true;
}
- throw new \JKingWeb\Arsse\User\Exception("doesNotExist");
+ throw new \JKingWeb\Arsse\User\ExceptionConflict("doesNotExist");
}));
$this->assertConsole($this->cli, $cmd, $exitStatus, $output);
}
@@ -217,7 +217,7 @@ class TestCLI extends \JKingWeb\Arsse\Test\AbstractTest {
$passwordChange = function($user, $pass = null) {
switch ($user) {
case "jane.doe@example.com":
- throw new \JKingWeb\Arsse\User\Exception("doesNotExist");
+ throw new \JKingWeb\Arsse\User\ExceptionConflict("doesNotExist");
case "john.doe@example.com":
return is_null($pass) ? "random password" : $pass;
}
@@ -247,7 +247,7 @@ class TestCLI extends \JKingWeb\Arsse\Test\AbstractTest {
$passwordClear = function($user) {
switch ($user) {
case "jane.doe@example.com":
- throw new \JKingWeb\Arsse\User\Exception("doesNotExist");
+ throw new \JKingWeb\Arsse\User\ExceptionConflict("doesNotExist");
case "john.doe@example.com":
return true;
}
diff --git a/tests/cases/Database/SeriesToken.php b/tests/cases/Database/SeriesToken.php
index 29977b3f..3f766aa3 100644
--- a/tests/cases/Database/SeriesToken.php
+++ b/tests/cases/Database/SeriesToken.php
@@ -99,7 +99,7 @@ trait SeriesToken {
}
public function testCreateATokenForAMissingUser(): void {
- $this->assertException("doesNotExist", "User");
+ $this->assertException("doesNotExist", "User", "ExceptionConflict");
Arsse::$db->tokenCreate("fever.login", "jane.doe@example.biz");
}
diff --git a/tests/cases/Database/SeriesUser.php b/tests/cases/Database/SeriesUser.php
index 10bd2582..350fa272 100644
--- a/tests/cases/Database/SeriesUser.php
+++ b/tests/cases/Database/SeriesUser.php
@@ -47,7 +47,7 @@ trait SeriesUser {
}
public function testGetThePasswordOfAMissingUser(): void {
- $this->assertException("doesNotExist", "User");
+ $this->assertException("doesNotExist", "User", "ExceptionConflict");
Arsse::$db->userPasswordGet("john.doe@example.org");
}
@@ -59,7 +59,7 @@ trait SeriesUser {
}
public function testAddAnExistingUser(): void {
- $this->assertException("alreadyExists", "User");
+ $this->assertException("alreadyExists", "User", "ExceptionConflict");
Arsse::$db->userAdd("john.doe@example.com", "");
}
@@ -71,7 +71,7 @@ trait SeriesUser {
}
public function testRemoveAMissingUser(): void {
- $this->assertException("doesNotExist", "User");
+ $this->assertException("doesNotExist", "User", "ExceptionConflict");
Arsse::$db->userRemove("john.doe@example.org");
}
@@ -101,7 +101,7 @@ trait SeriesUser {
}
public function testSetThePasswordOfAMissingUser(): void {
- $this->assertException("doesNotExist", "User");
+ $this->assertException("doesNotExist", "User", "ExceptionConflict");
Arsse::$db->userPasswordSet("john.doe@example.org", "secret");
}
@@ -110,7 +110,7 @@ trait SeriesUser {
$this->assertSame($exp, Arsse::$db->userPropertiesGet($user));
}
- public function provideMetadata() {
+ public function provideMetadata(): iterable {
return [
["admin@example.net", ['num' => 1, 'admin' => true, 'lang' => "en", 'tz' => "America/Toronto", 'sort_asc' => false]],
["jane.doe@example.com", ['num' => 2, 'admin' => false, 'lang' => "fr", 'tz' => "Asia/Kuala_Lumpur", 'sort_asc' => true]],
@@ -119,7 +119,7 @@ trait SeriesUser {
}
public function testGetTheMetadataOfAMissingUser(): void {
- $this->assertException("doesNotExist", "User");
+ $this->assertException("doesNotExist", "User", "ExceptionConflict");
Arsse::$db->userPropertiesGet("john.doe@example.org");
}
@@ -147,7 +147,7 @@ trait SeriesUser {
}
public function testSetTheMetadataOfAMissingUser(): void {
- $this->assertException("doesNotExist", "User");
+ $this->assertException("doesNotExist", "User", "ExceptionConflict");
Arsse::$db->userPropertiesSet("john.doe@example.org", ['admin' => true]);
}
}
diff --git a/tests/cases/ImportExport/TestImportExport.php b/tests/cases/ImportExport/TestImportExport.php
index 1a899c4c..e113d83c 100644
--- a/tests/cases/ImportExport/TestImportExport.php
+++ b/tests/cases/ImportExport/TestImportExport.php
@@ -146,7 +146,7 @@ class TestImportExport extends \JKingWeb\Arsse\Test\AbstractTest {
}
public function testImportForAMissingUser(): void {
- $this->assertException("doesNotExist", "User");
+ $this->assertException("doesNotExist", "User", "ExceptionConflict");
$this->proc->import("no.one@example.com", "", false, false);
}
diff --git a/tests/cases/ImportExport/TestOPML.php b/tests/cases/ImportExport/TestOPML.php
index 36caa77c..3c61688e 100644
--- a/tests/cases/ImportExport/TestOPML.php
+++ b/tests/cases/ImportExport/TestOPML.php
@@ -101,7 +101,7 @@ OPML_EXPORT_SERIALIZATION;
public function testExportToOpmlAMissingUser(): void {
\Phake::when(Arsse::$db)->userExists->thenReturn(false);
- $this->assertException("doesNotExist", "User");
+ $this->assertException("doesNotExist", "User", "ExceptionConflict");
(new OPML)->export("john.doe@example.com");
}
diff --git a/tests/cases/REST/Fever/TestUser.php b/tests/cases/REST/Fever/TestUser.php
index d6bab6df..2764c420 100644
--- a/tests/cases/REST/Fever/TestUser.php
+++ b/tests/cases/REST/Fever/TestUser.php
@@ -10,7 +10,7 @@ use JKingWeb\Arsse\Arsse;
use JKingWeb\Arsse\User;
use JKingWeb\Arsse\Database;
use JKingWeb\Arsse\Db\ExceptionInput;
-use JKingWeb\Arsse\User\Exception as UserException;
+use JKingWeb\Arsse\User\ExceptionConflict as UserException;
use JKingWeb\Arsse\Db\Transaction;
use JKingWeb\Arsse\REST\Fever\User as FeverUser;
diff --git a/tests/cases/User/TestInternal.php b/tests/cases/User/TestInternal.php
index cd231a0a..21b38b5f 100644
--- a/tests/cases/User/TestInternal.php
+++ b/tests/cases/User/TestInternal.php
@@ -37,7 +37,7 @@ class TestInternal extends \JKingWeb\Arsse\Test\AbstractTest {
\Phake::when(Arsse::$db)->userPasswordGet("john.doe@example.com")->thenReturn('$2y$10$1zbqRJhxM8uUjeSBPp4IhO90xrqK0XjEh9Z16iIYEFRV4U.zeAFom'); // hash of "secret"
\Phake::when(Arsse::$db)->userPasswordGet("jane.doe@example.com")->thenReturn('$2y$10$bK1ljXfTSyc2D.NYvT.Eq..OpehLRXVbglW.23ihVuyhgwJCd.7Im'); // hash of "superman"
\Phake::when(Arsse::$db)->userPasswordGet("owen.hardy@example.com")->thenReturn("");
- \Phake::when(Arsse::$db)->userPasswordGet("kira.nerys@example.com")->thenThrow(new \JKingWeb\Arsse\User\Exception("doesNotExist"));
+ \Phake::when(Arsse::$db)->userPasswordGet("kira.nerys@example.com")->thenThrow(new \JKingWeb\Arsse\User\ExceptionConflict("doesNotExist"));
\Phake::when(Arsse::$db)->userPasswordGet("007@example.com")->thenReturn(null);
$this->assertSame($exp, (new Driver)->auth($user, $password));
}
@@ -90,11 +90,11 @@ class TestInternal extends \JKingWeb\Arsse\Test\AbstractTest {
public function testRemoveAUser(): void {
$john = "john.doe@example.com";
- \Phake::when(Arsse::$db)->userRemove->thenReturn(true)->thenThrow(new \JKingWeb\Arsse\User\Exception("doesNotExist"));
+ \Phake::when(Arsse::$db)->userRemove->thenReturn(true)->thenThrow(new \JKingWeb\Arsse\User\ExceptionConflict("doesNotExist"));
$driver = new Driver;
$this->assertTrue($driver->userRemove($john));
\Phake::verify(Arsse::$db, \Phake::times(1))->userRemove($john);
- $this->assertException("doesNotExist", "User");
+ $this->assertException("doesNotExist", "User", "ExceptionConflict");
try {
$this->assertFalse($driver->userRemove($john));
} finally {
@@ -118,7 +118,7 @@ class TestInternal extends \JKingWeb\Arsse\Test\AbstractTest {
public function testUnsetAPasswordForAMssingUser(): void {
\Phake::when(Arsse::$db)->userExists->thenReturn(false);
- $this->assertException("doesNotExist", "User");
+ $this->assertException("doesNotExist", "User", "ExceptionConflict");
(new Driver)->userPasswordUnset("john.doe@example.com");
}
@@ -131,7 +131,7 @@ class TestInternal extends \JKingWeb\Arsse\Test\AbstractTest {
public function testGetPropertiesForAMissingUser(): void {
\Phake::when(Arsse::$db)->userExists->thenReturn(false);
- $this->assertException("doesNotExist", "User");
+ $this->assertException("doesNotExist", "User", "ExceptionConflict");
try {
(new Driver)->userPropertiesGet("john.doe@example.com");
} finally {
@@ -150,7 +150,7 @@ class TestInternal extends \JKingWeb\Arsse\Test\AbstractTest {
public function testSetPropertiesForAMissingUser(): void {
\Phake::when(Arsse::$db)->userExists->thenReturn(false);
- $this->assertException("doesNotExist", "User");
+ $this->assertException("doesNotExist", "User", "ExceptionConflict");
try {
(new Driver)->userPropertiesSet("john.doe@example.com", ['admin' => true]);
} finally {
diff --git a/tests/cases/User/TestUser.php b/tests/cases/User/TestUser.php
index 759241c2..97a93523 100644
--- a/tests/cases/User/TestUser.php
+++ b/tests/cases/User/TestUser.php
@@ -10,6 +10,7 @@ use JKingWeb\Arsse\Arsse;
use JKingWeb\Arsse\Database;
use JKingWeb\Arsse\User;
use JKingWeb\Arsse\AbstractException as Exception;
+use JKingWeb\Arsse\User\ExceptionConflict;
use JKingWeb\Arsse\User\Driver;
/** @covers \JKingWeb\Arsse\User */
@@ -23,6 +24,11 @@ class TestUser extends \JKingWeb\Arsse\Test\AbstractTest {
// create a mock user driver
$this->drv = \Phake::mock(Driver::class);
}
+
+ public function tearDown(): void {
+ \Phake::verifyNoOtherInteractions($this->drv);
+ \Phake::verifyNoOtherInteractions(Arsse::$db);
+ }
public function testConstruct(): void {
$this->assertInstanceOf(User::class, new User($this->drv));
@@ -49,6 +55,7 @@ class TestUser extends \JKingWeb\Arsse\Test\AbstractTest {
$u = new User($this->drv);
$this->assertSame($exp, $u->auth($user, $password));
$this->assertNull($u->id);
+ \Phake::verify($this->drv, \Phake::times((int) !$preAuth))->auth($user, $password);
\Phake::verify(Arsse::$db, \Phake::times($exp ? 1 : 0))->userExists($user);
\Phake::verify(Arsse::$db, \Phake::times($exp && $user === "jane.doe@example.com" ? 1 : 0))->userAdd($user, $password);
}
@@ -68,190 +75,65 @@ class TestUser extends \JKingWeb\Arsse\Test\AbstractTest {
];
}
- /** @dataProvider provideUserList */
- public function testListUsers($exp): void {
+ public function testListUsers(): void {
+ $exp = ["john.doe@example.com", "jane.doe@example.com"];
$u = new User($this->drv);
\Phake::when($this->drv)->userList->thenReturn(["john.doe@example.com", "jane.doe@example.com"]);
$this->assertSame($exp, $u->list());
+ \Phake::verify($this->drv)->userList();
}
- public function provideUserList(): iterable {
- $john = "john.doe@example.com";
- $jane = "jane.doe@example.com";
- return [
- [[$john, $jane]],
- ];
- }
-
- /** @dataProvider provideAdditions */
- public function testAddAUser(string $user, $password, $exp): void {
+ public function testAddAUser(): void {
+ $user = "ohn.doe@example.com";
+ $pass = "secret";
$u = new User($this->drv);
- \Phake::when($this->drv)->userAdd("john.doe@example.com", $this->anything())->thenThrow(new \JKingWeb\Arsse\User\Exception("alreadyExists"));
- \Phake::when($this->drv)->userAdd("jane.doe@example.com", $this->anything())->thenReturnCallback(function($user, $pass) {
- return $pass ?? "random password";
- });
- if ($exp instanceof Exception) {
- $this->assertException("alreadyExists", "User");
- }
- $this->assertSame($exp, $u->add($user, $password));
+ \Phake::when($this->drv)->userAdd->thenReturn($pass);
+ \Phake::when(Arsse::$db)->userExists->thenReturn(true);
+ $this->assertSame($pass, $u->add($user, $pass));
+ \Phake::verify($this->drv)->userAdd($user, $pass);
+ \Phake::verify(Arsse::$db)->userExists($user);
}
- /** @dataProvider provideAdditions */
- public function testAddAUserWithARandomPassword(string $user, $password, $exp): void {
- $u = \Phake::partialMock(User::class, $this->drv);
- \Phake::when($this->drv)->userAdd($this->anything(), $this->isNull())->thenReturn(null);
- \Phake::when($this->drv)->userAdd("john.doe@example.com", $this->logicalNot($this->isNull()))->thenThrow(new \JKingWeb\Arsse\User\Exception("alreadyExists"));
- \Phake::when($this->drv)->userAdd("jane.doe@example.com", $this->logicalNot($this->isNull()))->thenReturnCallback(function($user, $pass) {
- return $pass;
- });
- if ($exp instanceof Exception) {
- $this->assertException("alreadyExists", "User");
- $calls = 2;
- } else {
- $calls = 4;
- }
- try {
- $pass1 = $u->add($user, null);
- $pass2 = $u->add($user, null);
- $this->assertNotEquals($pass1, $pass2);
- } finally {
- \Phake::verify($this->drv, \Phake::times($calls))->userAdd;
- \Phake::verify($u, \Phake::times($calls / 2))->generatePassword;
- }
- }
-
- public function provideAdditions(): iterable {
- $john = "john.doe@example.com";
- $jane = "jane.doe@example.com";
- return [
- [$john, "secret", new \JKingWeb\Arsse\User\Exception("alreadyExists")],
- [$jane, "superman", "superman"],
- [$jane, null, "random password"],
- ];
- }
-
- /** @dataProvider provideRemovals */
- public function testRemoveAUser(string $user, bool $exists, $exp): void {
+ public function testAddAUserWeDoNotKnow(): void {
+ $user = "ohn.doe@example.com";
+ $pass = "secret";
$u = new User($this->drv);
- \Phake::when($this->drv)->userRemove("john.doe@example.com")->thenReturn(true);
- \Phake::when($this->drv)->userRemove("jane.doe@example.com")->thenThrow(new \JKingWeb\Arsse\User\Exception("doesNotExist"));
- \Phake::when(Arsse::$db)->userExists->thenReturn($exists);
- \Phake::when(Arsse::$db)->userRemove->thenReturn(true);
- if ($exp instanceof Exception) {
- $this->assertException("doesNotExist", "User");
- }
- try {
- $this->assertSame($exp, $u->remove($user));
- } finally {
- \Phake::verify(Arsse::$db, \Phake::times(1))->userExists($user);
- \Phake::verify(Arsse::$db, \Phake::times((int) $exists))->userRemove($user);
- }
+ \Phake::when($this->drv)->userAdd->thenReturn($pass);
+ \Phake::when(Arsse::$db)->userExists->thenReturn(false);
+ $this->assertSame($pass, $u->add($user, $pass));
+ \Phake::verify($this->drv)->userAdd($user, $pass);
+ \Phake::verify(Arsse::$db)->userExists($user);
+ \Phake::verify(Arsse::$db)->userAdd($user, $pass);
}
- public function provideRemovals(): iterable {
- $john = "john.doe@example.com";
- $jane = "jane.doe@example.com";
- return [
- [$john, true, true],
- [$john, false, true],
- [$jane, true, new \JKingWeb\Arsse\User\Exception("doesNotExist")],
- [$jane, false, new \JKingWeb\Arsse\User\Exception("doesNotExist")],
- ];
- }
-
- /** @dataProvider providePasswordChanges */
- public function testChangeAPassword(string $user, $password, bool $exists, $exp): void {
+ public function testAddADuplicateUser(): void {
+ $user = "ohn.doe@example.com";
+ $pass = "secret";
$u = new User($this->drv);
- \Phake::when($this->drv)->userPasswordSet("john.doe@example.com", $this->anything(), $this->anything())->thenReturnCallback(function($user, $pass, $old) {
- return $pass ?? "random password";
- });
- \Phake::when($this->drv)->userPasswordSet("jane.doe@example.com", $this->anything(), $this->anything())->thenThrow(new \JKingWeb\Arsse\User\Exception("doesNotExist"));
- \Phake::when(Arsse::$db)->userExists->thenReturn($exists);
- if ($exp instanceof Exception) {
- $this->assertException("doesNotExist", "User");
- $calls = 0;
- } else {
- $calls = 1;
- }
+ \Phake::when($this->drv)->userAdd->thenThrow(new ExceptionConflict("alreadyExists"));
+ \Phake::when(Arsse::$db)->userExists->thenReturn(true);
+ $this->assertException("alreadyExists", "User", "ExceptionConflict");
try {
- $this->assertSame($exp, $u->passwordSet($user, $password));
+ $u->add($user, $pass);
} finally {
- \Phake::verify(Arsse::$db, \Phake::times($calls))->userExists($user);
- \Phake::verify(Arsse::$db, \Phake::times($exists ? $calls : 0))->userPasswordSet($user, $password ?? "random password", null);
+ \Phake::verify(Arsse::$db)->userExists($user);
+ \Phake::verify($this->drv)->userAdd($user, $pass);
}
}
- /** @dataProvider providePasswordChanges */
- public function testChangeAPasswordToARandomPassword(string $user, $password, bool $exists, $exp): void {
- $u = \Phake::partialMock(User::class, $this->drv);
- \Phake::when($this->drv)->userPasswordSet($this->anything(), $this->isNull(), $this->anything())->thenReturn(null);
- \Phake::when($this->drv)->userPasswordSet("john.doe@example.com", $this->logicalNot($this->isNull()), $this->anything())->thenReturnCallback(function($user, $pass, $old) {
- return $pass ?? "random password";
- });
- \Phake::when($this->drv)->userPasswordSet("jane.doe@example.com", $this->logicalNot($this->isNull()), $this->anything())->thenThrow(new \JKingWeb\Arsse\User\Exception("doesNotExist"));
- \Phake::when(Arsse::$db)->userExists->thenReturn($exists);
- if ($exp instanceof Exception) {
- $this->assertException("doesNotExist", "User");
- $calls = 2;
- } else {
- $calls = 4;
- }
- try {
- $pass1 = $u->passwordSet($user, null);
- $pass2 = $u->passwordSet($user, null);
- $this->assertNotEquals($pass1, $pass2);
- } finally {
- \Phake::verify($this->drv, \Phake::times($calls))->userPasswordSet;
- \Phake::verify($u, \Phake::times($calls / 2))->generatePassword;
- \Phake::verify(Arsse::$db, \Phake::times($calls == 4 ? 2 : 0))->userExists($user);
- if ($calls == 4) {
- \Phake::verify(Arsse::$db, \Phake::times($exists ? 1 : 0))->userPasswordSet($user, $pass1, null);
- \Phake::verify(Arsse::$db, \Phake::times($exists ? 1 : 0))->userPasswordSet($user, $pass2, null);
- } else {
- \Phake::verify(Arsse::$db, \Phake::times(0))->userPasswordSet;
- }
- }
- }
-
- public function providePasswordChanges(): iterable {
- $john = "john.doe@example.com";
- $jane = "jane.doe@example.com";
- return [
- [$john, "superman", true, "superman"],
- [$john, null, true, "random password"],
- [$john, "superman", false, "superman"],
- [$john, null, false, "random password"],
- [$jane, "secret", true, new \JKingWeb\Arsse\User\Exception("doesNotExist")],
- ];
- }
-
- /** @dataProvider providePasswordClearings */
- public function testClearAPassword(bool $exists, string $user, $exp): void {
- \Phake::when($this->drv)->userPasswordUnset->thenReturn(true);
- \Phake::when($this->drv)->userPasswordUnset("jane.doe@example.net", null)->thenThrow(new \JKingWeb\Arsse\User\Exception("doesNotExist"));
- \Phake::when(Arsse::$db)->userExists->thenReturn($exists);
+ public function testAddADuplicateUserWeDoNotKnow(): void {
+ $user = "ohn.doe@example.com";
+ $pass = "secret";
$u = new User($this->drv);
+ \Phake::when($this->drv)->userAdd->thenThrow(new ExceptionConflict("alreadyExists"));
+ \Phake::when(Arsse::$db)->userExists->thenReturn(false);
+ $this->assertException("alreadyExists", "User", "ExceptionConflict");
try {
- if ($exp instanceof \JKingWeb\Arsse\AbstractException) {
- $this->assertException($exp);
- $u->passwordUnset($user);
- } else {
- $this->assertSame($exp, $u->passwordUnset($user));
- }
+ $u->add($user, $pass);
} finally {
- \Phake::verify(Arsse::$db, \Phake::times((int) ($exists && is_bool($exp))))->userPasswordSet($user, null);
+ \Phake::verify(Arsse::$db)->userExists($user);
+ \Phake::verify(Arsse::$db)->userAdd($user, null);
+ \Phake::verify($this->drv)->userAdd($user, $pass);
}
}
-
- public function providePasswordClearings(): iterable {
- $missing = new \JKingWeb\Arsse\User\Exception("doesNotExist");
- return [
- [true, "jane.doe@example.com", true],
- [true, "john.doe@example.com", true],
- [true, "jane.doe@example.net", $missing],
- [false, "jane.doe@example.com", true],
- [false, "john.doe@example.com", true],
- [false, "jane.doe@example.net", $missing],
- ];
- }
}
From 27d9c046d53de240206840a1147088a97ef2cce9 Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Mon, 16 Nov 2020 00:11:19 -0500
Subject: [PATCH 041/366] More work on user management
---
CHANGELOG | 4 ++
lib/AbstractException.php | 1 +
lib/User.php | 28 +++++++--
locale/en.php | 1 +
tests/cases/User/TestUser.php | 115 ++++++++++++++++++++++++++++++++--
5 files changed, 139 insertions(+), 10 deletions(-)
diff --git a/CHANGELOG b/CHANGELOG
index 730e60a4..3b65066b 100644
--- a/CHANGELOG
+++ b/CHANGELOG
@@ -4,6 +4,10 @@ Version 0.9.0 (????-??-??)
Bug fixes:
- Use icons specified in Atom feeds when available
+Changes:
+- Explicitly forbid U+003A COLON in usernames, for compatibility with HTTP
+ Basic authentication
+
Version 0.8.5 (2020-10-27)
==========================
diff --git a/lib/AbstractException.php b/lib/AbstractException.php
index 22b6eb52..d26b3cd3 100644
--- a/lib/AbstractException.php
+++ b/lib/AbstractException.php
@@ -75,6 +75,7 @@ abstract class AbstractException extends \Exception {
"User/ExceptionSession.invalid" => 10431,
"User/ExceptionInput.invalidTimezone" => 10441,
"User/ExceptionInput.invalidBoolean" => 10442,
+ "User/ExceptionInput.invalidUsername" => 10443,
"Feed/Exception.internalError" => 10500,
"Feed/Exception.invalidCertificate" => 10501,
"Feed/Exception.invalidUrl" => 10502,
diff --git a/lib/User.php b/lib/User.php
index ffb6d4aa..2fec130f 100644
--- a/lib/User.php
+++ b/lib/User.php
@@ -7,6 +7,7 @@ declare(strict_types=1);
namespace JKingWeb\Arsse;
use JKingWeb\Arsse\Misc\ValueInfo as V;
+use JKingWeb\Arsse\User\ExceptionConflict as Conflict;
use PasswordGenerator\Generator as PassGen;
class User {
@@ -49,26 +50,41 @@ class User {
}
public function add(string $user, ?string $password = null): string {
+ // ensure the user name does not contain any U+003A COLON characters, as
+ // this is incompatible with HTTP Basic authentication
+ if (strpos($user, ":") !== false) {
+ throw new User\ExceptionInput("invalidUsername", "U+003A COLON");
+ }
try {
$out = $this->u->userAdd($user, $password) ?? $this->u->userAdd($user, $this->generatePassword());
- } finally {
- // synchronize the internal database
+ } catch (Conflict $e) {
if (!Arsse::$db->userExists($user)) {
- Arsse::$db->userAdd($user, $out ?? null);
+ Arsse::$db->userAdd($user, null);
}
+ throw $e;
+ }
+ // synchronize the internal database
+ if (!Arsse::$db->userExists($user)) {
+ Arsse::$db->userAdd($user, $out);
}
return $out;
}
+
public function remove(string $user): bool {
try {
- return $this->u->userRemove($user);
- } finally { // @codeCoverageIgnore
+ $out = $this->u->userRemove($user);
+ } catch (Conflict $e) {
if (Arsse::$db->userExists($user)) {
- // if the user was removed and we (still) have it in the internal database, remove it there
Arsse::$db->userRemove($user);
}
+ throw $e;
}
+ if (Arsse::$db->userExists($user)) {
+ // if the user was removed and we (still) have it in the internal database, remove it there
+ Arsse::$db->userRemove($user);
+ }
+ return $out;
}
public function passwordSet(string $user, ?string $newPassword, $oldPassword = null): string {
diff --git a/locale/en.php b/locale/en.php
index e5ada184..2791c5b1 100644
--- a/locale/en.php
+++ b/locale/en.php
@@ -139,6 +139,7 @@ return [
'Exception.JKingWeb/Arsse/User/Exception.authMissing' => 'Please log in to proceed',
'Exception.JKingWeb/Arsse/User/Exception.authFailed' => 'Authentication failed',
'Exception.JKingWeb/Arsse/User/ExceptionSession.invalid' => 'Session with ID {0} does not exist',
+ 'Exception.JKingWeb/Arsse/User/ExceptionInput.invalidUsername' => 'User names may not contain the Unicode character {0}',
'Exception.JKingWeb/Arsse/Feed/Exception.internalError' => 'Could not download feed "{url}" because of an internal error which is probably a bug',
'Exception.JKingWeb/Arsse/Feed/Exception.invalidCertificate' => 'Could not download feed "{url}" because its server is serving an invalid SSL certificate',
'Exception.JKingWeb/Arsse/Feed/Exception.invalidUrl' => 'Feed URL "{url}" is invalid',
diff --git a/tests/cases/User/TestUser.php b/tests/cases/User/TestUser.php
index 97a93523..9e34a2e6 100644
--- a/tests/cases/User/TestUser.php
+++ b/tests/cases/User/TestUser.php
@@ -11,6 +11,7 @@ use JKingWeb\Arsse\Database;
use JKingWeb\Arsse\User;
use JKingWeb\Arsse\AbstractException as Exception;
use JKingWeb\Arsse\User\ExceptionConflict;
+use JKingWeb\Arsse\User\ExceptionInput;
use JKingWeb\Arsse\User\Driver;
/** @covers \JKingWeb\Arsse\User */
@@ -84,7 +85,7 @@ class TestUser extends \JKingWeb\Arsse\Test\AbstractTest {
}
public function testAddAUser(): void {
- $user = "ohn.doe@example.com";
+ $user = "john.doe@example.com";
$pass = "secret";
$u = new User($this->drv);
\Phake::when($this->drv)->userAdd->thenReturn($pass);
@@ -95,7 +96,7 @@ class TestUser extends \JKingWeb\Arsse\Test\AbstractTest {
}
public function testAddAUserWeDoNotKnow(): void {
- $user = "ohn.doe@example.com";
+ $user = "john.doe@example.com";
$pass = "secret";
$u = new User($this->drv);
\Phake::when($this->drv)->userAdd->thenReturn($pass);
@@ -107,7 +108,7 @@ class TestUser extends \JKingWeb\Arsse\Test\AbstractTest {
}
public function testAddADuplicateUser(): void {
- $user = "ohn.doe@example.com";
+ $user = "john.doe@example.com";
$pass = "secret";
$u = new User($this->drv);
\Phake::when($this->drv)->userAdd->thenThrow(new ExceptionConflict("alreadyExists"));
@@ -122,7 +123,7 @@ class TestUser extends \JKingWeb\Arsse\Test\AbstractTest {
}
public function testAddADuplicateUserWeDoNotKnow(): void {
- $user = "ohn.doe@example.com";
+ $user = "john.doe@example.com";
$pass = "secret";
$u = new User($this->drv);
\Phake::when($this->drv)->userAdd->thenThrow(new ExceptionConflict("alreadyExists"));
@@ -136,4 +137,110 @@ class TestUser extends \JKingWeb\Arsse\Test\AbstractTest {
\Phake::verify($this->drv)->userAdd($user, $pass);
}
}
+
+ public function testAddAnInvalidUser(): void {
+ $user = "john:doe@example.com";
+ $pass = "secret";
+ $u = new User($this->drv);
+ \Phake::when($this->drv)->userAdd->thenThrow(new ExceptionInput("invalidUsername"));
+ $this->assertException("invalidUsername", "User", "ExceptionInput");
+ $u->add($user, $pass);
+ }
+
+ public function testAddAUserWithARandomPassword(): void {
+ $user = "john.doe@example.com";
+ $pass = "random password";
+ $u = \Phake::partialMock(User::class, $this->drv);
+ \Phake::when($u)->generatePassword->thenReturn($pass);
+ \Phake::when($this->drv)->userAdd->thenReturn(null)->thenReturn($pass);
+ \Phake::when(Arsse::$db)->userExists->thenReturn(true);
+ $this->assertSame($pass, $u->add($user));
+ \Phake::verify($this->drv)->userAdd($user, null);
+ \Phake::verify($this->drv)->userAdd($user, $pass);
+ \Phake::verify(Arsse::$db)->userExists($user);
+ }
+
+ public function testRemoveAUser(): void {
+ $user = "john.doe@example.com";
+ $pass = "secret";
+ $u = new User($this->drv);
+ \Phake::when($this->drv)->userRemove->thenReturn(true);
+ \Phake::when(Arsse::$db)->userExists->thenReturn(true);
+ $this->assertTrue($u->remove($user));
+ \Phake::verify(Arsse::$db)->userExists($user);
+ \Phake::verify(Arsse::$db)->userRemove($user);
+ \Phake::verify($this->drv)->userRemove($user);
+ }
+
+ public function testRemoveAUserWeDoNotKnow(): void {
+ $user = "john.doe@example.com";
+ $pass = "secret";
+ $u = new User($this->drv);
+ \Phake::when($this->drv)->userRemove->thenReturn(true);
+ \Phake::when(Arsse::$db)->userExists->thenReturn(false);
+ $this->assertTrue($u->remove($user));
+ \Phake::verify(Arsse::$db)->userExists($user);
+ \Phake::verify($this->drv)->userRemove($user);
+ }
+
+ public function testRemoveAMissingUser(): void {
+ $user = "john.doe@example.com";
+ $pass = "secret";
+ $u = new User($this->drv);
+ \Phake::when($this->drv)->userRemove->thenThrow(new ExceptionConflict("doesNotExist"));
+ \Phake::when(Arsse::$db)->userExists->thenReturn(true);
+ $this->assertException("doesNotExist", "User", "ExceptionConflict");
+ try {
+ $u->remove($user);
+ } finally {
+ \Phake::verify(Arsse::$db)->userExists($user);
+ \Phake::verify(Arsse::$db)->userRemove($user);
+ \Phake::verify($this->drv)->userRemove($user);
+ }
+ }
+
+ public function testRemoveAMissingUserWeDoNotKnow(): void {
+ $user = "john.doe@example.com";
+ $pass = "secret";
+ $u = new User($this->drv);
+ \Phake::when($this->drv)->userRemove->thenThrow(new ExceptionConflict("doesNotExist"));
+ \Phake::when(Arsse::$db)->userExists->thenReturn(false);
+ $this->assertException("doesNotExist", "User", "ExceptionConflict");
+ try {
+ $u->remove($user);
+ } finally {
+ \Phake::verify(Arsse::$db)->userExists($user);
+ \Phake::verify($this->drv)->userRemove($user);
+ }
+ }
+
+ public function testSetAPassword(): void {
+ $user = "john.doe@example.com";
+ $pass = "secret";
+ $u = new User($this->drv);
+ \Phake::when($this->drv)->userPasswordSet->thenReturn($pass);
+ \Phake::when(Arsse::$db)->userPasswordSet->thenReturn($pass);
+ \Phake::when(Arsse::$db)->userExists->thenReturn(true);
+ $this->assertSame($pass, $u->passwordSet($user, $pass));
+ \Phake::verify($this->drv)->userPasswordSet($user, $pass, null);
+ \Phake::verify(Arsse::$db)->userPasswordSet($user, $pass, null);
+ \Phake::verify(Arsse::$db)->sessionDestroy($user);
+ \Phake::verify(Arsse::$db)->userExists($user);
+ }
+
+ public function testSetARandomPassword(): void {
+ $user = "john.doe@example.com";
+ $pass = "random password";
+ $u = \Phake::partialMock(User::class, $this->drv);
+ \Phake::when($u)->generatePassword->thenReturn($pass);
+ \Phake::when($this->drv)->userPasswordSet->thenReturn(null)->thenReturn($pass);
+ \Phake::when(Arsse::$db)->userPasswordSet->thenReturn($pass);
+ \Phake::when(Arsse::$db)->userExists->thenReturn(true);
+ $this->assertSame($pass, $u->passwordSet($user, null));
+ \Phake::verify($this->drv)->userPasswordSet($user, null, null);
+ \Phake::verify($this->drv)->userPasswordSet($user, $pass, null);
+ \Phake::verify(Arsse::$db)->userPasswordSet($user, $pass, null);
+ \Phake::verify(Arsse::$db)->sessionDestroy($user);
+ \Phake::verify(Arsse::$db)->userExists($user);
+ }
}
From 180b4ecc9b27c3f4c9aa5d78da361ed6b31f3fa5 Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Mon, 16 Nov 2020 10:24:06 -0500
Subject: [PATCH 042/366] More user tests
---
lib/Database.php | 2 +-
lib/User.php | 19 ++++---
tests/cases/User/TestUser.php | 101 ++++++++++++++++++++++++++++++++++
3 files changed, 112 insertions(+), 10 deletions(-)
diff --git a/lib/Database.php b/lib/Database.php
index 660fbc4a..bd0d25b0 100644
--- a/lib/Database.php
+++ b/lib/Database.php
@@ -248,7 +248,7 @@ class Database {
/** Adds a user to the database
*
* @param string $user The user to add
- * @param string|null $passwordThe user's password in cleartext. It will be stored hashed
+ * @param string|null $passwordThe user's password in cleartext. It will be stored hashed. If null is provided the user will not be able to log in
*/
public function userAdd(string $user, ?string $password): bool {
if ($this->userExists($user)) {
diff --git a/lib/User.php b/lib/User.php
index 2fec130f..1ef83176 100644
--- a/lib/User.php
+++ b/lib/User.php
@@ -108,10 +108,6 @@ class User {
Arsse::$db->userPasswordSet($user, null);
// also invalidate any current sessions for the user
Arsse::$db->sessionDestroy($user);
- } else {
- // if the user does not exist
- Arsse::$db->userAdd($user, "");
- Arsse::$db->userPasswordSet($user, null);
}
return $out;
}
@@ -119,24 +115,29 @@ class User {
public function generatePassword(): string {
return (new PassGen)->length(Arsse::$conf->userTempPasswordLength)->get();
}
-
+
public function propertiesGet(string $user): array {
$extra = $this->u->userPropertiesGet($user);
// synchronize the internal database
if (!Arsse::$db->userExists($user)) {
- Arsse::$db->userAdd($user, $this->generatePassword());
+ Arsse::$db->userAdd($user, null);
+ Arsse::$db->userPropertiesSet($user, $extra);
}
- // unconditionally retrieve from the database to get at least the user number, and anything else the driver does not provide
+ // retrieve from the database to get at least the user number, and anything else the driver does not provide
$out = Arsse::$db->userPropertiesGet($user);
// layer on the driver's data
- foreach (["lang", "tz", "admin", "sort_asc"] as $k) {
+ foreach (["tz", "admin", "sort_asc"] as $k) {
if (array_key_exists($k, $extra)) {
$out[$k] = $extra[$k] ?? $out[$k];
}
}
+ // treat language specially since it may legitimately be null
+ if (array_key_exists("lang", $extra)) {
+ $out['lang'] = $extra['lang'];
+ }
return $out;
}
-
+
public function propertiesSet(string $user, array $data): array {
$in = [];
if (array_key_exists("tz", $data)) {
diff --git a/tests/cases/User/TestUser.php b/tests/cases/User/TestUser.php
index 9e34a2e6..313a0541 100644
--- a/tests/cases/User/TestUser.php
+++ b/tests/cases/User/TestUser.php
@@ -243,4 +243,105 @@ class TestUser extends \JKingWeb\Arsse\Test\AbstractTest {
\Phake::verify(Arsse::$db)->sessionDestroy($user);
\Phake::verify(Arsse::$db)->userExists($user);
}
+
+ public function testSetAPasswordForAUserWeDoNotKnow(): void {
+ $user = "john.doe@example.com";
+ $pass = "secret";
+ $u = new User($this->drv);
+ \Phake::when($this->drv)->userPasswordSet->thenReturn($pass);
+ \Phake::when(Arsse::$db)->userPasswordSet->thenReturn($pass);
+ \Phake::when(Arsse::$db)->userExists->thenReturn(false);
+ $this->assertSame($pass, $u->passwordSet($user, $pass));
+ \Phake::verify($this->drv)->userPasswordSet($user, $pass, null);
+ \Phake::verify(Arsse::$db)->userAdd($user, $pass);
+ \Phake::verify(Arsse::$db)->userExists($user);
+ }
+
+ public function testSetARandomPasswordForAUserWeDoNotKnow(): void {
+ $user = "john.doe@example.com";
+ $pass = "random password";
+ $u = \Phake::partialMock(User::class, $this->drv);
+ \Phake::when($u)->generatePassword->thenReturn($pass);
+ \Phake::when($this->drv)->userPasswordSet->thenReturn(null)->thenReturn($pass);
+ \Phake::when(Arsse::$db)->userPasswordSet->thenReturn($pass);
+ \Phake::when(Arsse::$db)->userExists->thenReturn(false);
+ $this->assertSame($pass, $u->passwordSet($user, null));
+ \Phake::verify($this->drv)->userPasswordSet($user, null, null);
+ \Phake::verify($this->drv)->userPasswordSet($user, $pass, null);
+ \Phake::verify(Arsse::$db)->userAdd($user, $pass);
+ \Phake::verify(Arsse::$db)->userExists($user);
+ }
+
+ public function testSetARandomPasswordForAMissingUser(): void {
+ $user = "john.doe@example.com";
+ $pass = "random password";
+ $u = \Phake::partialMock(User::class, $this->drv);
+ \Phake::when($u)->generatePassword->thenReturn($pass);
+ \Phake::when($this->drv)->userPasswordSet->thenThrow(new ExceptionConflict("doesNotExist"));
+ $this->assertException("doesNotExist", "User", "ExceptionConflict");
+ try {
+ $u->passwordSet($user, null);
+ } finally {
+ \Phake::verify($this->drv)->userPasswordSet($user, null, null);
+ }
+ }
+
+ public function testUnsetAPassword(): void {
+ $user = "john.doe@example.com";
+ $u = new User($this->drv);
+ \Phake::when($this->drv)->userPasswordUnset->thenReturn(true);
+ \Phake::when(Arsse::$db)->userPasswordUnset->thenReturn(true);
+ \Phake::when(Arsse::$db)->userExists->thenReturn(true);
+ $this->assertTrue($u->passwordUnset($user));
+ \Phake::verify($this->drv)->userPasswordUnset($user, null);
+ \Phake::verify(Arsse::$db)->userPasswordSet($user, null);
+ \Phake::verify(Arsse::$db)->sessionDestroy($user);
+ \Phake::verify(Arsse::$db)->userExists($user);
+ }
+
+ public function testUnsetAPasswordForAUserWeDoNotKnow(): void {
+ $user = "john.doe@example.com";
+ $u = new User($this->drv);
+ \Phake::when($this->drv)->userPasswordUnset->thenReturn(true);
+ \Phake::when(Arsse::$db)->userPasswordUnset->thenReturn(true);
+ \Phake::when(Arsse::$db)->userExists->thenReturn(false);
+ $this->assertTrue($u->passwordUnset($user));
+ \Phake::verify($this->drv)->userPasswordUnset($user, null);
+ \Phake::verify(Arsse::$db)->userExists($user);
+ }
+
+ public function testUnsetAPasswordForAMissingUser(): void {
+ $user = "john.doe@example.com";
+ $u = new User($this->drv);
+ \Phake::when($this->drv)->userPasswordUnset->thenThrow(new ExceptionConflict("doesNotExist"));
+ $this->assertException("doesNotExist", "User", "ExceptionConflict");
+ try {
+ $u->passwordUnset($user);
+ } finally {
+ \Phake::verify($this->drv)->userPasswordUnset($user, null);
+ }
+ }
+
+ /** @dataProvider provideProperties */
+ public function testGetThePropertiesOfAUser(array $exp, array $base, array $extra): void {
+ $user = "john.doe@example.com";
+ $u = new User($this->drv);
+ \Phake::when($this->drv)->userPropertiesGet->thenReturn($extra);
+ \Phake::when(Arsse::$db)->userPropertiesGet->thenReturn($base);
+ \Phake::when(Arsse::$db)->userExists->thenReturn(true);
+ $this->assertSame($exp, $u->propertiesGet($user));
+ \Phake::verify($this->drv)->userPropertiesGet($user);
+ \Phake::verify(Arsse::$db)->userPropertiesGet($user);
+ \Phake::verify(Arsse::$db)->userExists($user);
+ }
+
+ public function provideProperties(): iterable {
+ $defaults = ['num' => 1, 'admin' => false, 'lang' => null, 'tz' => "Etc/UTC", 'sort_asc' => false];
+ return [
+ [$defaults, $defaults, []],
+ [$defaults, $defaults, ['num' => 2112, 'blah' => "bloo"]],
+ [['num' => 1, 'admin' => true, 'lang' => "fr", 'tz' => "America/Toronto", 'sort_asc' => true], $defaults, ['admin' => true, 'lang' => "fr", 'tz' => "America/Toronto", 'sort_asc' => true]],
+ [['num' => 1, 'admin' => true, 'lang' => null, 'tz' => "America/Toronto", 'sort_asc' => true], ['num' => 1, 'admin' => true, 'lang' => "fr", 'tz' => "America/Toronto", 'sort_asc' => true], ['lang' => null]],
+ ];
+ }
}
From e16df90bae6868bcb0a438c7358e779baf94fac1 Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Mon, 16 Nov 2020 10:26:14 -0500
Subject: [PATCH 043/366] Style fixes
---
lib/Database.php | 21 ++++++++++-----------
lib/Db/PostgreSQL/PDOResult.php | 2 +-
lib/Db/PostgreSQL/Result.php | 2 +-
lib/REST/Miniflux/V1.php | 5 -----
lib/User.php | 1 -
lib/User/Driver.php | 26 +++++++++++++-------------
tests/cases/Database/SeriesCleanup.php | 4 ++--
tests/cases/Database/SeriesUser.php | 18 +++++++++---------
tests/cases/Db/BaseUpdate.php | 5 +++--
tests/cases/User/TestInternal.php | 8 ++++----
tests/cases/User/TestUser.php | 11 +++++------
tests/docroot/Icon/SVG1.php | 2 +-
tests/docroot/Icon/SVG2.php | 2 +-
13 files changed, 50 insertions(+), 57 deletions(-)
diff --git a/lib/Database.php b/lib/Database.php
index bd0d25b0..8bcd8298 100644
--- a/lib/Database.php
+++ b/lib/Database.php
@@ -37,7 +37,7 @@ use JKingWeb\Arsse\Misc\URL;
* associations with articles. There has been an effort to keep public method
* names consistent throughout, but protected methods, having different
* concerns, will typically follow different conventions.
- *
+ *
* Note that operations on users should be performed with the User class rather
* than the Database class directly. This is to allow for alternate user sources.
*/
@@ -298,7 +298,7 @@ class Database {
$this->db->prepare("UPDATE arsse_users set password = ? where id = ?", "str", "str")->run($hash, $user);
return true;
}
-
+
public function userPropertiesGet(string $user): array {
$out = $this->db->prepare("SELECT num, admin, lang, tz, sort_asc from arsse_users where id = ?", "str")->run($user)->getRow();
if (!$out) {
@@ -309,7 +309,7 @@ class Database {
settype($out['sort_asc'], "bool");
return $out;
}
-
+
public function userPropertiesSet(string $user, array $data): bool {
if (!$this->userExists($user)) {
throw new User\ExceptionConflict("doesNotExist", ["action" => __FUNCTION__, "user" => $user]);
@@ -325,7 +325,6 @@ class Database {
return false;
}
return (bool) $this->db->prepare("UPDATE arsse_users set $setClause where id = ?", $setTypes, "str")->run($setValues, $user)->changes();
-
}
/** Creates a new session for the given user and returns the session identifier */
@@ -874,16 +873,16 @@ class Database {
$out = $this->db->prepare("SELECT $field from arsse_tags where id in (select tag from arsse_tag_members where subscription = ? and assigned = 1) order by $field", "int")->run($id)->getAll();
return $out ? array_column($out, $field) : [];
}
-
+
/** Retrieves detailed information about the icon for a subscription.
- *
+ *
* The returned information is:
- *
+ *
* - "id": The umeric identifier of the icon (not the subscription)
* - "url": The URL of the icon
* - "type": The Content-Type of the icon e.g. "image/png"
* - "data": The icon itself, as a binary sring; if $withData is false this will be null
- *
+ *
* @param string|null $user The user who owns the subscription being queried; using null here is supported for TT-RSS and SHOULD NOT be used elsewhere as it leaks information
* @param int $subscription The numeric identifier of the subscription
* @param bool $includeData Whether to include the binary data of the icon itself in the result
@@ -1219,14 +1218,14 @@ class Database {
}
/** Lists icons for feeds to which a user is subscribed
- *
+ *
* The returned information for each icon is:
- *
+ *
* - "id": The umeric identifier of the icon
* - "url": The URL of the icon
* - "type": The Content-Type of the icon e.g. "image/png"
* - "data": The icon itself, as a binary sring
- *
+ *
* @param string $user The user whose subscription icons are to be retrieved
*/
public function iconList(string $user): Db\Result {
diff --git a/lib/Db/PostgreSQL/PDOResult.php b/lib/Db/PostgreSQL/PDOResult.php
index 91fe4c09..4920776f 100644
--- a/lib/Db/PostgreSQL/PDOResult.php
+++ b/lib/Db/PostgreSQL/PDOResult.php
@@ -13,7 +13,7 @@ class PDOResult extends \JKingWeb\Arsse\Db\PDOResult {
public function valid() {
$this->cur = $this->set->fetch(\PDO::FETCH_ASSOC);
if ($this->cur !== false) {
- foreach($this->cur as $k => $v) {
+ foreach ($this->cur as $k => $v) {
if (is_resource($v)) {
$this->cur[$k] = stream_get_contents($v);
fclose($v);
diff --git a/lib/Db/PostgreSQL/Result.php b/lib/Db/PostgreSQL/Result.php
index 2b4d1b63..7200ac35 100644
--- a/lib/Db/PostgreSQL/Result.php
+++ b/lib/Db/PostgreSQL/Result.php
@@ -48,7 +48,7 @@ class Result extends \JKingWeb\Arsse\Db\AbstractResult {
public function valid() {
$this->cur = pg_fetch_row($this->r, null, \PGSQL_ASSOC);
if ($this->cur !== false) {
- foreach($this->blobs as $f) {
+ foreach ($this->blobs as $f) {
if ($this->cur[$f]) {
$this->cur[$f] = hex2bin(substr($this->cur[$f], 2));
}
diff --git a/lib/REST/Miniflux/V1.php b/lib/REST/Miniflux/V1.php
index 9edff158..74873afd 100644
--- a/lib/REST/Miniflux/V1.php
+++ b/lib/REST/Miniflux/V1.php
@@ -7,19 +7,14 @@ declare(strict_types=1);
namespace JKingWeb\Arsse\REST\Miniflux;
use JKingWeb\Arsse\Arsse;
-use JKingWeb\Arsse\Service;
-use JKingWeb\Arsse\Context\Context;
use JKingWeb\Arsse\Misc\ValueInfo;
use JKingWeb\Arsse\AbstractException;
-use JKingWeb\Arsse\Db\ExceptionInput;
-use JKingWeb\Arsse\Feed\Exception as FeedException;
use JKingWeb\Arsse\Misc\HTTP;
use JKingWeb\Arsse\REST\Exception;
use JKingWeb\Arsse\REST\Exception404;
use JKingWeb\Arsse\REST\Exception405;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\ResponseInterface;
-use Laminas\Diactoros\Response\JsonResponse as Response;
use Laminas\Diactoros\Response\EmptyResponse;
class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
diff --git a/lib/User.php b/lib/User.php
index 1ef83176..c44a2068 100644
--- a/lib/User.php
+++ b/lib/User.php
@@ -69,7 +69,6 @@ class User {
}
return $out;
}
-
public function remove(string $user): bool {
try {
diff --git a/lib/User/Driver.php b/lib/User/Driver.php
index dbf8ad68..5da6a0ca 100644
--- a/lib/User/Driver.php
+++ b/lib/User/Driver.php
@@ -16,12 +16,12 @@ interface Driver {
public function auth(string $user, string $password): bool;
/** Adds a new user and returns their password
- *
+ *
* When given no password the implementation may return null; the user
* manager will then generate a random password and try again with that
- * password. Alternatively the implementation may generate its own
+ * password. Alternatively the implementation may generate its own
* password if desired
- *
+ *
* @param string $user The username to create
* @param string|null $password The cleartext password to assign to the user, or null to generate a random password
*/
@@ -34,45 +34,45 @@ interface Driver {
public function userList(): array;
/** Sets a user's password
- *
+ *
* When given no password the implementation may return null; the user
* manager will then generate a random password and try again with that
- * password. Alternatively the implementation may generate its own
+ * password. Alternatively the implementation may generate its own
* password if desired
- *
+ *
* @param string $user The user for whom to change the password
* @param string|null $password The cleartext password to assign to the user, or null to generate a random password
* @param string|null $oldPassword The user's previous password, if known
*/
public function userPasswordSet(string $user, ?string $newPassword, string $oldPassword = null);
- /** Removes a user's password; this makes authentication fail unconditionally
- *
+ /** Removes a user's password; this makes authentication fail unconditionally
+ *
* @param string $user The user for whom to change the password
* @param string|null $oldPassword The user's previous password, if known
*/
public function userPasswordUnset(string $user, string $oldPassword = null): bool;
/** Retrieves metadata about a user
- *
+ *
* Any expected keys not returned by the driver are taken from the internal
* database instead; the expected keys at this time are:
- *
+ *
* - admin: A boolean denoting whether the user has administrator privileges
* - lang: A BCP 47 language tag e.g. "en", "hy-Latn-IT-arevela"
* - tz: A zoneinfo timezone e.g. "Asia/Jakarta", "America/Argentina/La_Rioja"
* - sort_asc: A boolean denoting whether the user prefers articles to be sorted oldest-first
- *
+ *
* Any other keys will be ignored.
*/
public function userPropertiesGet(string $user): array;
/** Sets metadata about a user
- *
+ *
* Output should be the same as the input, unless input is changed prior to storage
* (if it is, for instance, normalized in some way), which which case the changes
* should be reflected in the output.
- *
+ *
* @param string $user The user for which to set metadata
* @param array $data The input data; see userPropertiesGet for keys
*/
diff --git a/tests/cases/Database/SeriesCleanup.php b/tests/cases/Database/SeriesCleanup.php
index 1a0e1c7e..cdbb66a0 100644
--- a/tests/cases/Database/SeriesCleanup.php
+++ b/tests/cases/Database/SeriesCleanup.php
@@ -68,8 +68,8 @@ trait SeriesCleanup {
],
'arsse_icons' => [
'columns' => [
- 'id' => "int",
- 'url' => "str",
+ 'id' => "int",
+ 'url' => "str",
'orphaned' => "datetime",
],
'rows' => [
diff --git a/tests/cases/Database/SeriesUser.php b/tests/cases/Database/SeriesUser.php
index 350fa272..0c13012b 100644
--- a/tests/cases/Database/SeriesUser.php
+++ b/tests/cases/Database/SeriesUser.php
@@ -104,12 +104,12 @@ trait SeriesUser {
$this->assertException("doesNotExist", "User", "ExceptionConflict");
Arsse::$db->userPasswordSet("john.doe@example.org", "secret");
}
-
+
/** @dataProvider provideMetaData */
public function testGetMetadata(string $user, array $exp): void {
$this->assertSame($exp, Arsse::$db->userPropertiesGet($user));
}
-
+
public function provideMetadata(): iterable {
return [
["admin@example.net", ['num' => 1, 'admin' => true, 'lang' => "en", 'tz' => "America/Toronto", 'sort_asc' => false]],
@@ -122,12 +122,12 @@ trait SeriesUser {
$this->assertException("doesNotExist", "User", "ExceptionConflict");
Arsse::$db->userPropertiesGet("john.doe@example.org");
}
-
+
public function testSetMetadata(): void {
$in = [
- 'admin' => true,
- 'lang' => "en-ca",
- 'tz' => "Atlantic/Reykjavik",
+ 'admin' => true,
+ 'lang' => "en-ca",
+ 'tz' => "Atlantic/Reykjavik",
'sort_asc' => true,
];
$this->assertTrue(Arsse::$db->userPropertiesSet("john.doe@example.com", $in));
@@ -135,11 +135,11 @@ trait SeriesUser {
$state['arsse_users']['rows'][2] = ["john.doe@example.com", 3, 1, "en-ca", "Atlantic/Reykjavik", 1];
$this->compareExpectations(static::$drv, $state);
}
-
+
public function testSetNoMetadata(): void {
$in = [
- 'num' => 2112,
- 'blah' => "bloo"
+ 'num' => 2112,
+ 'blah' => "bloo",
];
$this->assertFalse(Arsse::$db->userPropertiesSet("john.doe@example.com", $in));
$state = $this->primeExpectations($this->data, ['arsse_users' => ['id', 'num', 'admin', 'lang', 'tz', 'sort_asc']]);
diff --git a/tests/cases/Db/BaseUpdate.php b/tests/cases/Db/BaseUpdate.php
index ba93687e..bce4dbcf 100644
--- a/tests/cases/Db/BaseUpdate.php
+++ b/tests/cases/Db/BaseUpdate.php
@@ -134,10 +134,11 @@ class BaseUpdate extends \JKingWeb\Arsse\Test\AbstractTest {
$this->drv->schemaUpdate(Database::SCHEMA_VERSION);
$this->assertTrue($this->drv->maintenance());
}
-
+
public function testUpdateTo7(): void {
$this->drv->schemaUpdate(6);
- $this->drv->exec(<<drv->exec(
+ <<assertException("doesNotExist", "User", "ExceptionConflict");
(new Driver)->userPasswordUnset("john.doe@example.com");
}
-
+
public function testGetUserProperties(): void {
\Phake::when(Arsse::$db)->userExists->thenReturn(true);
$this->assertSame([], (new Driver)->userPropertiesGet("john.doe@example.com"));
\Phake::verify(Arsse::$db)->userExists("john.doe@example.com");
\Phake::verifyNoFurtherInteraction(Arsse::$db);
}
-
+
public function testGetPropertiesForAMissingUser(): void {
\Phake::when(Arsse::$db)->userExists->thenReturn(false);
$this->assertException("doesNotExist", "User", "ExceptionConflict");
@@ -139,7 +139,7 @@ class TestInternal extends \JKingWeb\Arsse\Test\AbstractTest {
\Phake::verifyNoFurtherInteraction(Arsse::$db);
}
}
-
+
public function testSetUserProperties(): void {
$in = ['admin' => true];
\Phake::when(Arsse::$db)->userExists->thenReturn(true);
@@ -147,7 +147,7 @@ class TestInternal extends \JKingWeb\Arsse\Test\AbstractTest {
\Phake::verify(Arsse::$db)->userExists("john.doe@example.com");
\Phake::verifyNoFurtherInteraction(Arsse::$db);
}
-
+
public function testSetPropertiesForAMissingUser(): void {
\Phake::when(Arsse::$db)->userExists->thenReturn(false);
$this->assertException("doesNotExist", "User", "ExceptionConflict");
diff --git a/tests/cases/User/TestUser.php b/tests/cases/User/TestUser.php
index 313a0541..e958c557 100644
--- a/tests/cases/User/TestUser.php
+++ b/tests/cases/User/TestUser.php
@@ -9,7 +9,6 @@ namespace JKingWeb\Arsse\TestCase\User;
use JKingWeb\Arsse\Arsse;
use JKingWeb\Arsse\Database;
use JKingWeb\Arsse\User;
-use JKingWeb\Arsse\AbstractException as Exception;
use JKingWeb\Arsse\User\ExceptionConflict;
use JKingWeb\Arsse\User\ExceptionInput;
use JKingWeb\Arsse\User\Driver;
@@ -25,7 +24,7 @@ class TestUser extends \JKingWeb\Arsse\Test\AbstractTest {
// create a mock user driver
$this->drv = \Phake::mock(Driver::class);
}
-
+
public function tearDown(): void {
\Phake::verifyNoOtherInteractions($this->drv);
\Phake::verifyNoOtherInteractions(Arsse::$db);
@@ -159,7 +158,7 @@ class TestUser extends \JKingWeb\Arsse\Test\AbstractTest {
\Phake::verify($this->drv)->userAdd($user, $pass);
\Phake::verify(Arsse::$db)->userExists($user);
}
-
+
public function testRemoveAUser(): void {
$user = "john.doe@example.com";
$pass = "secret";
@@ -171,7 +170,7 @@ class TestUser extends \JKingWeb\Arsse\Test\AbstractTest {
\Phake::verify(Arsse::$db)->userRemove($user);
\Phake::verify($this->drv)->userRemove($user);
}
-
+
public function testRemoveAUserWeDoNotKnow(): void {
$user = "john.doe@example.com";
$pass = "secret";
@@ -182,7 +181,7 @@ class TestUser extends \JKingWeb\Arsse\Test\AbstractTest {
\Phake::verify(Arsse::$db)->userExists($user);
\Phake::verify($this->drv)->userRemove($user);
}
-
+
public function testRemoveAMissingUser(): void {
$user = "john.doe@example.com";
$pass = "secret";
@@ -198,7 +197,7 @@ class TestUser extends \JKingWeb\Arsse\Test\AbstractTest {
\Phake::verify($this->drv)->userRemove($user);
}
}
-
+
public function testRemoveAMissingUserWeDoNotKnow(): void {
$user = "john.doe@example.com";
$pass = "secret";
diff --git a/tests/docroot/Icon/SVG1.php b/tests/docroot/Icon/SVG1.php
index 0543d91a..5c90d375 100644
--- a/tests/docroot/Icon/SVG1.php
+++ b/tests/docroot/Icon/SVG1.php
@@ -1,4 +1,4 @@
"image/svg+xml",
- 'content' => ' '
+ 'content' => ' ',
];
diff --git a/tests/docroot/Icon/SVG2.php b/tests/docroot/Icon/SVG2.php
index 4ade7ce4..e5260cf3 100644
--- a/tests/docroot/Icon/SVG2.php
+++ b/tests/docroot/Icon/SVG2.php
@@ -1,4 +1,4 @@
"image/svg+xml",
- 'content' => ' '
+ 'content' => ' ',
];
From d3ebb1bd56cd85d055ccc042faeae4a1279e68fa Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Tue, 17 Nov 2020 16:23:36 -0500
Subject: [PATCH 044/366] Last set of tests for user management. Fixes #180
---
lib/User.php | 11 ++--
locale/en.php | 2 +
tests/cases/User/TestUser.php | 111 ++++++++++++++++++++++++++++++++--
3 files changed, 114 insertions(+), 10 deletions(-)
diff --git a/lib/User.php b/lib/User.php
index c44a2068..0a70e5d2 100644
--- a/lib/User.php
+++ b/lib/User.php
@@ -69,6 +69,7 @@ class User {
}
return $out;
}
+
public function remove(string $user): bool {
try {
@@ -141,15 +142,15 @@ class User {
$in = [];
if (array_key_exists("tz", $data)) {
if (!is_string($data['tz'])) {
- throw new User\ExceptionInput("invalidTimezone");
- } elseif (!in_array($data['tz'], \DateTimeZone::listIdentifiers())) {
- throw new User\ExceptionInput("invalidTimezone", $data['tz']);
+ throw new User\ExceptionInput("invalidTimezone", ['field' => "tz", 'value' => ""]);
+ } elseif(!@timezone_open($data['tz'])) {
+ throw new User\ExceptionInput("invalidTimezone", ['field' => "tz", 'value' => $data['tz']]);
}
$in['tz'] = $data['tz'];
}
foreach (["admin", "sort_asc"] as $k) {
if (array_key_exists($k, $data)) {
- if (($v = V::normalize($data[$k], V::T_BOOL)) === null) {
+ if (($v = V::normalize($data[$k], V::T_BOOL | V::M_DROP)) === null) {
throw new User\ExceptionInput("invalidBoolean", $k);
}
$in[$k] = $v;
@@ -161,7 +162,7 @@ class User {
$out = $this->u->userPropertiesSet($user, $in);
// synchronize the internal database
if (!Arsse::$db->userExists($user)) {
- Arsse::$db->userAdd($user, $this->generatePassword());
+ Arsse::$db->userAdd($user, null);
}
Arsse::$db->userPropertiesSet($user, $out);
return $out;
diff --git a/locale/en.php b/locale/en.php
index 2791c5b1..66a03eee 100644
--- a/locale/en.php
+++ b/locale/en.php
@@ -140,6 +140,8 @@ return [
'Exception.JKingWeb/Arsse/User/Exception.authFailed' => 'Authentication failed',
'Exception.JKingWeb/Arsse/User/ExceptionSession.invalid' => 'Session with ID {0} does not exist',
'Exception.JKingWeb/Arsse/User/ExceptionInput.invalidUsername' => 'User names may not contain the Unicode character {0}',
+ 'Exception.JKingWeb/Arsse/User/ExceptionInput.invalidBoolean' => 'User property "{0}" must be a boolean value (true or false)',
+ 'Exception.JKingWeb/Arsse/User/ExceptionInput.invalidTimezone' => 'User property "{field}" must be a valid zoneinfo timezone',
'Exception.JKingWeb/Arsse/Feed/Exception.internalError' => 'Could not download feed "{url}" because of an internal error which is probably a bug',
'Exception.JKingWeb/Arsse/Feed/Exception.invalidCertificate' => 'Could not download feed "{url}" because its server is serving an invalid SSL certificate',
'Exception.JKingWeb/Arsse/Feed/Exception.invalidUrl' => 'Feed URL "{url}" is invalid',
diff --git a/tests/cases/User/TestUser.php b/tests/cases/User/TestUser.php
index e958c557..310a0a33 100644
--- a/tests/cases/User/TestUser.php
+++ b/tests/cases/User/TestUser.php
@@ -9,6 +9,7 @@ namespace JKingWeb\Arsse\TestCase\User;
use JKingWeb\Arsse\Arsse;
use JKingWeb\Arsse\Database;
use JKingWeb\Arsse\User;
+use JKingWeb\Arsse\AbstractException as Exception;
use JKingWeb\Arsse\User\ExceptionConflict;
use JKingWeb\Arsse\User\ExceptionInput;
use JKingWeb\Arsse\User\Driver;
@@ -24,7 +25,7 @@ class TestUser extends \JKingWeb\Arsse\Test\AbstractTest {
// create a mock user driver
$this->drv = \Phake::mock(Driver::class);
}
-
+
public function tearDown(): void {
\Phake::verifyNoOtherInteractions($this->drv);
\Phake::verifyNoOtherInteractions(Arsse::$db);
@@ -42,6 +43,13 @@ class TestUser extends \JKingWeb\Arsse\Test\AbstractTest {
$u->id = null;
$this->assertSame("", (string) $u);
}
+
+ public function testGeneratePasswords(): void {
+ $u = new User($this->drv);
+ $pass1 = $u->generatePassword();
+ $pass2 = $u->generatePassword();
+ $this->assertNotEquals($pass1, $pass2);
+ }
/** @dataProvider provideAuthentication */
public function testAuthenticateAUser(bool $preAuth, string $user, string $password, bool $exp): void {
@@ -158,7 +166,7 @@ class TestUser extends \JKingWeb\Arsse\Test\AbstractTest {
\Phake::verify($this->drv)->userAdd($user, $pass);
\Phake::verify(Arsse::$db)->userExists($user);
}
-
+
public function testRemoveAUser(): void {
$user = "john.doe@example.com";
$pass = "secret";
@@ -170,7 +178,7 @@ class TestUser extends \JKingWeb\Arsse\Test\AbstractTest {
\Phake::verify(Arsse::$db)->userRemove($user);
\Phake::verify($this->drv)->userRemove($user);
}
-
+
public function testRemoveAUserWeDoNotKnow(): void {
$user = "john.doe@example.com";
$pass = "secret";
@@ -181,7 +189,7 @@ class TestUser extends \JKingWeb\Arsse\Test\AbstractTest {
\Phake::verify(Arsse::$db)->userExists($user);
\Phake::verify($this->drv)->userRemove($user);
}
-
+
public function testRemoveAMissingUser(): void {
$user = "john.doe@example.com";
$pass = "secret";
@@ -197,7 +205,7 @@ class TestUser extends \JKingWeb\Arsse\Test\AbstractTest {
\Phake::verify($this->drv)->userRemove($user);
}
}
-
+
public function testRemoveAMissingUserWeDoNotKnow(): void {
$user = "john.doe@example.com";
$pass = "secret";
@@ -343,4 +351,97 @@ class TestUser extends \JKingWeb\Arsse\Test\AbstractTest {
[['num' => 1, 'admin' => true, 'lang' => null, 'tz' => "America/Toronto", 'sort_asc' => true], ['num' => 1, 'admin' => true, 'lang' => "fr", 'tz' => "America/Toronto", 'sort_asc' => true], ['lang' => null]],
];
}
+
+ public function testGetThePropertiesOfAUserWeDoNotKnow(): void {
+ $user = "john.doe@example.com";
+ $extra = ['tz' => "Europe/Istanbul"];
+ $base = ['num' => 47, 'admin' => false, 'lang' => null, 'tz' => "Etc/UTC", 'sort_asc' => false];
+ $exp = ['num' => 47, 'admin' => false, 'lang' => null, 'tz' => "Europe/Istanbul", 'sort_asc' => false];
+ $u = new User($this->drv);
+ \Phake::when($this->drv)->userPropertiesGet->thenReturn($extra);
+ \Phake::when(Arsse::$db)->userPropertiesGet->thenReturn($base);
+ \Phake::when(Arsse::$db)->userAdd->thenReturn(true);
+ \Phake::when(Arsse::$db)->userExists->thenReturn(false);
+ $this->assertSame($exp, $u->propertiesGet($user));
+ \Phake::verify($this->drv)->userPropertiesGet($user);
+ \Phake::verify(Arsse::$db)->userPropertiesGet($user);
+ \Phake::verify(Arsse::$db)->userPropertiesSet($user, $extra);
+ \Phake::verify(Arsse::$db)->userAdd($user, null);
+ \Phake::verify(Arsse::$db)->userExists($user);
+ }
+
+ public function testGetThePropertiesOfAMissingUser(): void {
+ $user = "john.doe@example.com";
+ $u = new User($this->drv);
+ \Phake::when($this->drv)->userPropertiesGet->thenThrow(new ExceptionConflict("doesNotExist"));
+ $this->assertException("doesNotExist", "User", "ExceptionConflict");
+ try {
+ $u->propertiesGet($user);
+ } finally {
+ \Phake::verify($this->drv)->userPropertiesGet($user);
+ }
+ }
+
+ /** @dataProvider providePropertyChanges */
+ public function testSetThePropertiesOfAUser(array $in, $out): void {
+ $user = "john.doe@example.com";
+ $u = new User($this->drv);
+ if ($out instanceof \Exception) {
+ $this->assertException($out);
+ $u->propertiesSet($user, $in);
+ } else {
+ \Phake::when(Arsse::$db)->userExists->thenReturn(true);
+ \Phake::when($this->drv)->userPropertiesSet->thenReturn($out);
+ \Phake::when(Arsse::$db)->userPropertiesSet->thenReturn(true);
+ $this->assertSame($out, $u->propertiesSet($user, $in));
+ \Phake::verify($this->drv)->userPropertiesSet($user, $in);
+ \Phake::verify(Arsse::$db)->userPropertiesSet($user, $out);
+ \Phake::verify(Arsse::$db)->userExists($user);
+ }
+ }
+
+ /** @dataProvider providePropertyChanges */
+ public function testSetThePropertiesOfAUserWeDoNotKnow(array $in, $out): void {
+ $user = "john.doe@example.com";
+ $u = new User($this->drv);
+ if ($out instanceof \Exception) {
+ $this->assertException($out);
+ $u->propertiesSet($user, $in);
+ } else {
+ \Phake::when(Arsse::$db)->userExists->thenReturn(false);
+ \Phake::when($this->drv)->userPropertiesSet->thenReturn($out);
+ \Phake::when(Arsse::$db)->userPropertiesSet->thenReturn(true);
+ $this->assertSame($out, $u->propertiesSet($user, $in));
+ \Phake::verify($this->drv)->userPropertiesSet($user, $in);
+ \Phake::verify(Arsse::$db)->userPropertiesSet($user, $out);
+ \Phake::verify(Arsse::$db)->userExists($user);
+ \Phake::verify(Arsse::$db)->userAdd($user, null);
+ }
+ }
+
+ public function providePropertyChanges(): iterable {
+ return [
+ [['admin' => true], ['admin' => true]],
+ [['admin' => 2], new ExceptionInput("invalidBoolean")],
+ [['sort_asc' => 2], new ExceptionInput("invalidBoolean")],
+ [['tz' => "Etc/UTC"], ['tz' => "Etc/UTC"]],
+ [['tz' => "Etc/blah"], new ExceptionInput("invalidTimezone")],
+ [['tz' => false], new ExceptionInput("invalidTimezone")],
+ [['lang' => "en-ca"], ['lang' => "en-CA"]],
+ [['lang' => null], ['lang' => null]],
+ ];
+ }
+
+ public function testSetThePropertiesOfAMissingUser(): void {
+ $user = "john.doe@example.com";
+ $in = ['admin' => true];
+ $u = new User($this->drv);
+ \Phake::when($this->drv)->userPropertiesSet->thenThrow(new ExceptionConflict("doesNotExist"));
+ $this->assertException("doesNotExist", "User", "ExceptionConflict");
+ try {
+ $u->propertiesSet($user, $in);
+ } finally {
+ \Phake::verify($this->drv)->userPropertiesSet($user, $in);
+ }
+ }
}
From d4bcdcdaddec14ed5bbc8e64d59091765953ef4f Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Wed, 18 Nov 2020 10:01:20 -0500
Subject: [PATCH 045/366] Fix TTRSS coverage
---
tests/cases/REST/TinyTinyRSS/TestIcon.php | 3 +++
1 file changed, 3 insertions(+)
diff --git a/tests/cases/REST/TinyTinyRSS/TestIcon.php b/tests/cases/REST/TinyTinyRSS/TestIcon.php
index 5341238f..18735af7 100644
--- a/tests/cases/REST/TinyTinyRSS/TestIcon.php
+++ b/tests/cases/REST/TinyTinyRSS/TestIcon.php
@@ -9,6 +9,7 @@ namespace JKingWeb\Arsse\TestCase\REST\TinyTinyRSS;
use JKingWeb\Arsse\Arsse;
use JKingWeb\Arsse\User;
use JKingWeb\Arsse\Database;
+use JKingWeb\Arsse\Db\ExceptionInput;
use JKingWeb\Arsse\REST\TinyTinyRSS\Icon;
use Psr\Http\Message\ResponseInterface;
use Laminas\Diactoros\Response\EmptyResponse as Response;
@@ -49,6 +50,7 @@ class TestIcon extends \JKingWeb\Arsse\Test\AbstractTest {
public function testRetrieveFavion(): void {
\Phake::when(Arsse::$db)->subscriptionIcon->thenReturn(['url' => null]);
+ \Phake::when(Arsse::$db)->subscriptionIcon($this->anything(), 1123, false)->thenThrow(new ExceptionInput("subjectMissing"));
\Phake::when(Arsse::$db)->subscriptionIcon($this->anything(), 42, false)->thenReturn(['url' => "http://example.com/favicon.ico"]);
\Phake::when(Arsse::$db)->subscriptionIcon($this->anything(), 2112, false)->thenReturn(['url' => "http://example.net/logo.png"]);
\Phake::when(Arsse::$db)->subscriptionIcon($this->anything(), 1337, false)->thenReturn(['url' => "http://example.org/icon.gif\r\nLocation: http://bad.example.com/"]);
@@ -65,6 +67,7 @@ class TestIcon extends \JKingWeb\Arsse\Test\AbstractTest {
$this->assertMessage($exp, $this->req("ook"));
$this->assertMessage($exp, $this->req("47.ico"));
$this->assertMessage($exp, $this->req("2112.png"));
+ $this->assertMessage($exp, $this->req("1123.ico"));
// only GET is allowed
$exp = new Response(405, ['Allow' => "GET"]);
$this->assertMessage($exp, $this->req("2112.ico", "PUT"));
From f6cd2b87ce3a01f319eb01418f95d4a9de3b2410 Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Wed, 18 Nov 2020 11:25:28 -0500
Subject: [PATCH 046/366] Port token data from Microsub branch
---
lib/Database.php | 7 ++++---
1 file changed, 4 insertions(+), 3 deletions(-)
diff --git a/lib/Database.php b/lib/Database.php
index 8bcd8298..ace75a83 100644
--- a/lib/Database.php
+++ b/lib/Database.php
@@ -398,15 +398,16 @@ class Database {
* @param string $class The class of the token e.g. the protocol name
* @param string|null $id The value of the token; if none is provided a UUID will be generated
* @param \DateTimeInterface|null $expires An optional expiry date and time for the token
+ * @param string $data Application-specific data associated with a token
*/
- public function tokenCreate(string $user, string $class, string $id = null, \DateTimeInterface $expires = null): string {
+ public function tokenCreate(string $user, string $class, string $id = null, \DateTimeInterface $expires = null, string $data = null): string {
if (!$this->userExists($user)) {
throw new User\ExceptionConflict("doesNotExist", ["action" => __FUNCTION__, "user" => $user]);
}
// generate a token if it's not provided
$id = $id ?? UUID::mint()->hex;
// save the token to the database
- $this->db->prepare("INSERT INTO arsse_tokens(id,class,\"user\",expires) values(?,?,?,?)", "str", "str", "str", "datetime")->run($id, $class, $user, $expires);
+ $this->db->prepare("INSERT INTO arsse_tokens(id,class,\"user\",expires,data) values(?,?,?,?,?)", "str", "str", "str", "datetime", "str")->run($id, $class, $user, $expires, $data);
// return the ID
return $id;
}
@@ -428,7 +429,7 @@ class Database {
/** Look up data associated with a token */
public function tokenLookup(string $class, string $id): array {
- $out = $this->db->prepare("SELECT id,class,\"user\",created,expires from arsse_tokens where class = ? and id = ? and (expires is null or expires > CURRENT_TIMESTAMP)", "str", "str")->run($class, $id)->getRow();
+ $out = $this->db->prepare("SELECT id,class,\"user\",created,expires,data from arsse_tokens where class = ? and id = ? and (expires is null or expires > CURRENT_TIMESTAMP)", "str", "str")->run($class, $id)->getRow();
if (!$out) {
throw new Db\ExceptionInput("subjectMissing", ["action" => __FUNCTION__, "field" => "token", 'id' => $id]);
}
From 06dee77bac15a3fb0b91976d3b234c0e6af63eea Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Mon, 23 Nov 2020 09:31:50 -0500
Subject: [PATCH 047/366] First tests for Miniflux
---
lib/Database.php | 9 +-
lib/REST.php | 12 +-
lib/REST/Miniflux/ErrorResponse.php | 19 +++
lib/REST/Miniflux/Status.php | 37 ++++++
lib/REST/Miniflux/V1.php | 56 +++++++--
lib/User.php | 3 +-
locale/en.php | 3 +
tests/cases/Database/SeriesToken.php | 21 +++-
.../cases/REST/Miniflux/TestErrorResponse.php | 22 ++++
tests/cases/REST/Miniflux/TestStatus.php | 34 +++++
tests/cases/REST/Miniflux/TestV1.php | 118 ++++++++++++++++++
tests/cases/User/TestUser.php | 21 ++--
tests/phpunit.dist.xml | 6 +-
13 files changed, 328 insertions(+), 33 deletions(-)
create mode 100644 lib/REST/Miniflux/ErrorResponse.php
create mode 100644 lib/REST/Miniflux/Status.php
create mode 100644 tests/cases/REST/Miniflux/TestErrorResponse.php
create mode 100644 tests/cases/REST/Miniflux/TestStatus.php
create mode 100644 tests/cases/REST/Miniflux/TestV1.php
diff --git a/lib/Database.php b/lib/Database.php
index ace75a83..760a0de4 100644
--- a/lib/Database.php
+++ b/lib/Database.php
@@ -400,7 +400,7 @@ class Database {
* @param \DateTimeInterface|null $expires An optional expiry date and time for the token
* @param string $data Application-specific data associated with a token
*/
- public function tokenCreate(string $user, string $class, string $id = null, \DateTimeInterface $expires = null, string $data = null): string {
+ public function tokenCreate(string $user, string $class, string $id = null, ?\DateTimeInterface $expires = null, string $data = null): string {
if (!$this->userExists($user)) {
throw new User\ExceptionConflict("doesNotExist", ["action" => __FUNCTION__, "user" => $user]);
}
@@ -418,7 +418,7 @@ class Database {
* @param string $class The class of the token e.g. the protocol name
* @param string|null $id The ID of a specific token, or null for all tokens in the class
*/
- public function tokenRevoke(string $user, string $class, string $id = null): bool {
+ public function tokenRevoke(string $user, string $class, ?string $id = null): bool {
if (is_null($id)) {
$out = $this->db->prepare("DELETE FROM arsse_tokens where \"user\" = ? and class = ?", "str", "str")->run($user, $class)->changes();
} else {
@@ -436,6 +436,11 @@ class Database {
return $out;
}
+ /** List tokens associated with a user */
+ public function tokenList(string $user, string $class): Db\Result {
+ return $this->db->prepare("SELECT id,created,expires,data from arsse_tokens where class = ? and user = ? and (expires is null or expires > CURRENT_TIMESTAMP)", "str", "str")->run($class, $user);
+ }
+
/** Deletes expires tokens from the database, returning the number of deleted tokens */
public function tokenCleanup(): int {
return $this->db->query("DELETE FROM arsse_tokens where expires < CURRENT_TIMESTAMP")->changes();
diff --git a/lib/REST.php b/lib/REST.php
index 011d27df..4f1f4bdd 100644
--- a/lib/REST.php
+++ b/lib/REST.php
@@ -42,9 +42,19 @@ class REST {
],
'miniflux' => [ // Miniflux https://miniflux.app/docs/api.html
'match' => '/v1/',
- 'strip' => '/v1',
+ 'strip' => '',
'class' => REST\Miniflux\V1::class,
],
+ 'miniflux-version' => [ // Miniflux version report
+ 'match' => '/version',
+ 'strip' => '',
+ 'class' => REST\Miniflux\Status::class,
+ ],
+ 'miniflux-healthcheck' => [ // Miniflux health check
+ 'match' => '/healthcheck',
+ 'strip' => '',
+ 'class' => REST\Miniflux\Status::class,
+ ],
// Other candidates:
// Microsub https://indieweb.org/Microsub
// Google Reader http://feedhq.readthedocs.io/en/latest/api/index.html
diff --git a/lib/REST/Miniflux/ErrorResponse.php b/lib/REST/Miniflux/ErrorResponse.php
new file mode 100644
index 00000000..1cf467ee
--- /dev/null
+++ b/lib/REST/Miniflux/ErrorResponse.php
@@ -0,0 +1,19 @@
+ Arsse::$lang->msg("API.Miniflux.Error.".$msg, $data)];
+ parent::__construct($data, $status, $headers, $encodingOptions);
+ }
+}
diff --git a/lib/REST/Miniflux/Status.php b/lib/REST/Miniflux/Status.php
new file mode 100644
index 00000000..367a7a65
--- /dev/null
+++ b/lib/REST/Miniflux/Status.php
@@ -0,0 +1,37 @@
+getRequestTarget())['path'] ?? "";
+ if (!in_array($target, ["/version", "/healthcheck"])) {
+ return new EmptyResponse(404);
+ }
+ $method = $req->getMethod();
+ if ($method === "OPTIONS") {
+ return new EmptyResponse(204, ['Allow' => "HEAD, GET"]);
+ } elseif ($method !== "GET") {
+ return new EmptyResponse(405, ['Allow' => "HEAD, GET"]);
+ }
+ $out = "";
+ if ($target === "/version") {
+ $out = V1::VERSION;
+ } elseif ($target === "/healthcheck") {
+ $out = "OK";
+ }
+ return new TextResponse($out);
+ }
+}
diff --git a/lib/REST/Miniflux/V1.php b/lib/REST/Miniflux/V1.php
index 74873afd..45d61914 100644
--- a/lib/REST/Miniflux/V1.php
+++ b/lib/REST/Miniflux/V1.php
@@ -13,6 +13,7 @@ use JKingWeb\Arsse\Misc\HTTP;
use JKingWeb\Arsse\REST\Exception;
use JKingWeb\Arsse\REST\Exception404;
use JKingWeb\Arsse\REST\Exception405;
+use JKingWeb\Arsse\User\ExceptionConflict as UserException;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\ResponseInterface;
use Laminas\Diactoros\Response\EmptyResponse;
@@ -20,6 +21,8 @@ use Laminas\Diactoros\Response\EmptyResponse;
class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
protected const ACCEPTED_TYPES_OPML = ["text/xml", "application/xml", "text/x-opml"];
protected const ACCEPTED_TYPES_JSON = ["application/json", "text/json"];
+ public const VERSION = "2.0.25";
+
protected $paths = [
'/categories' => ['GET' => "getCategories", 'POST' => "createCategory"],
'/categories/1' => ['PUT' => "updateCategory", 'DELETE' => "deleteCategory"],
@@ -35,25 +38,41 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
'/feeds/1/icon' => ['GET' => "getFeedIcon"],
'/feeds/1/refresh' => ['PUT' => "refreshFeed"],
'/feeds/refresh' => ['PUT' => "refreshAllFeeds"],
- '/healthcheck' => ['GET' => "healthCheck"],
'/import' => ['POST' => "opmlImport"],
'/me' => ['GET' => "getCurrentUser"],
'/users' => ['GET' => "getUsers", 'POST' => "createUser"],
'/users/1' => ['GET' => "getUser", 'PUT' => "updateUser", 'DELETE' => "deleteUser"],
'/users/*' => ['GET' => "getUser"],
- '/version' => ['GET' => "getVersion"],
];
public function __construct() {
}
- public function dispatch(ServerRequestInterface $req): ResponseInterface {
- // try to authenticate
+ protected function authenticate(ServerRequestInterface $req): bool {
+ // first check any tokens; this is what Miniflux does
+ foreach ($req->getHeader("X-Auth-Token") as $t) {
+ if (strlen($t)) {
+ // a non-empty header is authoritative, so we'll stop here one way or the other
+ try {
+ $d = Arsse::$db->tokenLookup("miniflux.login", $t);
+ } catch (ExceptionInput $e) {
+ return false;
+ }
+ Arsse::$user->id = $d->user;
+ return true;
+ }
+ }
+ // next check HTTP auth
if ($req->getAttribute("authenticated", false)) {
Arsse::$user->id = $req->getAttribute("authenticatedUser");
- } else {
- // TODO: Handle X-Auth-Token authentication
- return new EmptyResponse(401);
+ }
+ return false;
+ }
+
+ public function dispatch(ServerRequestInterface $req): ResponseInterface {
+ // try to authenticate
+ if (!$this->authenticate($req)) {
+ return new ErrorResponse("401", 401);
}
// get the request path only; this is assumed to already be normalized
$target = parse_url($req->getRequestTarget())['path'] ?? "";
@@ -65,17 +84,14 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
$func = $this->chooseCall($target, $method);
if ($func === "opmlImport") {
if (!HTTP::matchType($req, "", ...[self::ACCEPTED_TYPES_OPML])) {
- return new EmptyResponse(415, ['Accept' => implode(", ", self::ACCEPTED_TYPES_OPML)]);
+ return new ErrorResponse(415, ['Accept' => implode(", ", self::ACCEPTED_TYPES_OPML)]);
}
$data = (string) $req->getBody();
} elseif ($method === "POST" || $method === "PUT") {
- if (!HTTP::matchType($req, "", ...[self::ACCEPTED_TYPES_JSON])) {
- return new EmptyResponse(415, ['Accept' => implode(", ", self::ACCEPTED_TYPES_JSON)]);
- }
$data = @json_decode($data, true);
if (json_last_error() !== \JSON_ERROR_NONE) {
// if the body could not be parsed as JSON, return "400 Bad Request"
- return new EmptyResponse(400);
+ return new ErrorResponse(["invalidBodyJSON", json_last_error_msg()], 400);
}
} else {
$data = null;
@@ -154,4 +170,20 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
throw new Exception404();
}
}
+
+ public static function tokenGenerate(string $user, string $label): string {
+ $t = base64_encode(random_bytes(24));
+ return Arsse::$db->tokenCreate($user, "miniflux.login", $t, null, $label);
+ }
+
+ public static function tokenList(string $user): array {
+ if (!Arsse::$db->userExists($user)) {
+ throw new UserException("doesNotExist", ["action" => __FUNCTION__, "user" => $user]);
+ }
+ $out = [];
+ foreach (Arsse::$db->tokenList($user, "miniflux.login") as $r) {
+ $out[] = ['label' => $r['data'], 'id' => $r['id']];
+ }
+ return $out;
+ }
}
diff --git a/lib/User.php b/lib/User.php
index 0a70e5d2..e8359bc0 100644
--- a/lib/User.php
+++ b/lib/User.php
@@ -69,7 +69,6 @@ class User {
}
return $out;
}
-
public function remove(string $user): bool {
try {
@@ -143,7 +142,7 @@ class User {
if (array_key_exists("tz", $data)) {
if (!is_string($data['tz'])) {
throw new User\ExceptionInput("invalidTimezone", ['field' => "tz", 'value' => ""]);
- } elseif(!@timezone_open($data['tz'])) {
+ } elseif (!@timezone_open($data['tz'])) {
throw new User\ExceptionInput("invalidTimezone", ['field' => "tz", 'value' => $data['tz']]);
}
$in['tz'] = $data['tz'];
diff --git a/locale/en.php b/locale/en.php
index 66a03eee..c0dea555 100644
--- a/locale/en.php
+++ b/locale/en.php
@@ -7,6 +7,9 @@ return [
'CLI.Auth.Success' => 'Authentication successful',
'CLI.Auth.Failure' => 'Authentication failed',
+ 'API.Miniflux.Error.401' => 'Access Unauthorized',
+ 'API.Miniflux.Error.invalidBodyJSON' => 'Invalid JSON payload: {0}',
+
'API.TTRSS.Category.Uncategorized' => 'Uncategorized',
'API.TTRSS.Category.Special' => 'Special',
'API.TTRSS.Category.Labels' => 'Labels',
diff --git a/tests/cases/Database/SeriesToken.php b/tests/cases/Database/SeriesToken.php
index 3f766aa3..7a14ed0d 100644
--- a/tests/cases/Database/SeriesToken.php
+++ b/tests/cases/Database/SeriesToken.php
@@ -33,12 +33,16 @@ trait SeriesToken {
'class' => "str",
'user' => "str",
'expires' => "datetime",
+ 'data' => "str",
],
'rows' => [
- ["80fa94c1a11f11e78667001e673b2560", "fever.login", "jane.doe@example.com", $faroff],
- ["27c6de8da13311e78667001e673b2560", "fever.login", "jane.doe@example.com", $past], // expired
- ["ab3b3eb8a13311e78667001e673b2560", "class.class", "jane.doe@example.com", null],
- ["da772f8fa13c11e78667001e673b2560", "class.class", "john.doe@example.com", $future],
+ ["80fa94c1a11f11e78667001e673b2560", "fever.login", "jane.doe@example.com", $faroff, null],
+ ["27c6de8da13311e78667001e673b2560", "fever.login", "jane.doe@example.com", $past, null], // expired
+ ["ab3b3eb8a13311e78667001e673b2560", "class.class", "jane.doe@example.com", null, null],
+ ["da772f8fa13c11e78667001e673b2560", "class.class", "john.doe@example.com", $future, null],
+ ["A", "miniflux.login", "jane.doe@example.com", null, "Label 1"],
+ ["B", "miniflux.login", "jane.doe@example.com", null, "Label 2"],
+ ["C", "miniflux.login", "john.doe@example.com", null, "Label 1"],
],
],
];
@@ -127,4 +131,13 @@ trait SeriesToken {
// revoking tokens which do not exist is not an error
$this->assertFalse(Arsse::$db->tokenRevoke($user, "unknown.class"));
}
+
+ public function testListTokens(): void {
+ $user = "jane.doe@example.com";
+ $exp = [
+ ['id' => "A", 'data' => "Label 1"],
+ ['id' => "B", 'data' => "Label 2"],
+ ];
+ $this->assertResult($exp, Arsse::$db->tokenList($user, "miniflux.login"));
+ }
}
diff --git a/tests/cases/REST/Miniflux/TestErrorResponse.php b/tests/cases/REST/Miniflux/TestErrorResponse.php
new file mode 100644
index 00000000..23d6e286
--- /dev/null
+++ b/tests/cases/REST/Miniflux/TestErrorResponse.php
@@ -0,0 +1,22 @@
+assertSame('{"error_message":"Access Unauthorized"}', (string) $act->getBody());
+ }
+
+ public function testCreateVariableResponse(): void {
+ $act = new ErrorResponse(["invalidBodyJSON", "Doh!"], 401);
+ $this->assertSame('{"error_message":"Invalid JSON payload: Doh!"}', (string) $act->getBody());
+ }
+}
diff --git a/tests/cases/REST/Miniflux/TestStatus.php b/tests/cases/REST/Miniflux/TestStatus.php
new file mode 100644
index 00000000..bcf81d18
--- /dev/null
+++ b/tests/cases/REST/Miniflux/TestStatus.php
@@ -0,0 +1,34 @@
+dispatch($this->serverRequest($method, $url, ""));
+ $this->assertMessage($exp, $act);
+ }
+
+ public function provideRequests(): iterable {
+ return [
+ ["/version", "GET", new TextResponse(V1::VERSION)],
+ ["/version", "POST", new EmptyResponse(405, ['Allow' => "HEAD, GET"])],
+ ["/version", "OPTIONS", new EmptyResponse(204, ['Allow' => "HEAD, GET"])],
+ ["/healthcheck", "GET", new TextResponse("OK")],
+ ["/healthcheck", "POST", new EmptyResponse(405, ['Allow' => "HEAD, GET"])],
+ ["/healthcheck", "OPTIONS", new EmptyResponse(204, ['Allow' => "HEAD, GET"])],
+ ["/version/", "GET", new EmptyResponse(404)],
+ ];
+ }
+}
diff --git a/tests/cases/REST/Miniflux/TestV1.php b/tests/cases/REST/Miniflux/TestV1.php
new file mode 100644
index 00000000..7d20a0a8
--- /dev/null
+++ b/tests/cases/REST/Miniflux/TestV1.php
@@ -0,0 +1,118 @@
+ */
+class TestV1 extends \JKingWeb\Arsse\Test\AbstractTest {
+ protected $h;
+ protected $transaction;
+
+ protected function req(string $method, string $target, $data = "", array $headers = [], bool $authenticated = true, bool $body = true): ResponseInterface {
+ $prefix = "/v1";
+ $url = $prefix.$target;
+ if ($body) {
+ $params = [];
+ } else {
+ $params = $data;
+ $data = [];
+ }
+ $req = $this->serverRequest($method, $url, $prefix, $headers, [], $data, "application/json", $params, $authenticated ? "john.doe@example.com" : "");
+ return $this->h->dispatch($req);
+ }
+
+ public function setUp(): void {
+ self::clearData();
+ self::setConf();
+ // create a mock user manager
+ Arsse::$user = \Phake::mock(User::class);
+ Arsse::$user->id = "john.doe@example.com";
+ // create a mock database interface
+ Arsse::$db = \Phake::mock(Database::class);
+ $this->transaction = \Phake::mock(Transaction::class);
+ \Phake::when(Arsse::$db)->begin->thenReturn($this->transaction);
+ //initialize a handler
+ $this->h = new V1();
+ }
+
+ public function tearDown(): void {
+ self::clearData();
+ }
+
+ protected function v($value) {
+ return $value;
+ }
+
+ public function testSendAuthenticationChallenge(): void {
+ $exp = new EmptyResponse(401);
+ $this->assertMessage($exp, $this->req("GET", "/", "", [], false));
+ }
+
+ /** @dataProvider provideInvalidPaths */
+ public function testRespondToInvalidPaths($path, $method, $code, $allow = null): void {
+ $exp = new EmptyResponse($code, $allow ? ['Allow' => $allow] : []);
+ $this->assertMessage($exp, $this->req($method, $path));
+ }
+
+ public function provideInvalidPaths(): array {
+ return [
+ ["/", "GET", 404],
+ ["/", "POST", 404],
+ ["/", "PUT", 404],
+ ["/", "DELETE", 404],
+ ["/", "OPTIONS", 404],
+ ["/version/invalid", "GET", 404],
+ ["/version/invalid", "POST", 404],
+ ["/version/invalid", "PUT", 404],
+ ["/version/invalid", "DELETE", 404],
+ ["/version/invalid", "OPTIONS", 404],
+ ["/folders/1/invalid", "GET", 404],
+ ["/folders/1/invalid", "POST", 404],
+ ["/folders/1/invalid", "PUT", 404],
+ ["/folders/1/invalid", "DELETE", 404],
+ ["/folders/1/invalid", "OPTIONS", 404],
+ ["/version", "POST", 405, "GET"],
+ ["/version", "PUT", 405, "GET"],
+ ["/version", "DELETE", 405, "GET"],
+ ["/folders", "PUT", 405, "GET, POST"],
+ ["/folders", "DELETE", 405, "GET, POST"],
+ ["/folders/1", "GET", 405, "PUT, DELETE"],
+ ["/folders/1", "POST", 405, "PUT, DELETE"],
+ ];
+ }
+
+ public function testRespondToInvalidInputTypes(): void {
+ $exp = new EmptyResponse(415, ['Accept' => "application/json"]);
+ $this->assertMessage($exp, $this->req("PUT", "/folders/1", ' ', ['Content-Type' => "application/xml"]));
+ $exp = new EmptyResponse(400);
+ $this->assertMessage($exp, $this->req("PUT", "/folders/1", ' '));
+ $this->assertMessage($exp, $this->req("PUT", "/folders/1", ' ', ['Content-Type' => null]));
+ }
+
+ /** @dataProvider provideOptionsRequests */
+ public function testRespondToOptionsRequests(string $url, string $allow, string $accept): void {
+ $exp = new EmptyResponse(204, [
+ 'Allow' => $allow,
+ 'Accept' => $accept,
+ ]);
+ $this->assertMessage($exp, $this->req("OPTIONS", $url));
+ }
+
+ public function provideOptionsRequests(): array {
+ return [
+ ["/feeds", "HEAD,GET,POST", "application/json"],
+ ["/feeds/2112", "DELETE", "application/json"],
+ ["/user", "HEAD,GET", "application/json"],
+ ];
+ }
+}
diff --git a/tests/cases/User/TestUser.php b/tests/cases/User/TestUser.php
index 310a0a33..8863d5fc 100644
--- a/tests/cases/User/TestUser.php
+++ b/tests/cases/User/TestUser.php
@@ -9,7 +9,6 @@ namespace JKingWeb\Arsse\TestCase\User;
use JKingWeb\Arsse\Arsse;
use JKingWeb\Arsse\Database;
use JKingWeb\Arsse\User;
-use JKingWeb\Arsse\AbstractException as Exception;
use JKingWeb\Arsse\User\ExceptionConflict;
use JKingWeb\Arsse\User\ExceptionInput;
use JKingWeb\Arsse\User\Driver;
@@ -25,7 +24,7 @@ class TestUser extends \JKingWeb\Arsse\Test\AbstractTest {
// create a mock user driver
$this->drv = \Phake::mock(Driver::class);
}
-
+
public function tearDown(): void {
\Phake::verifyNoOtherInteractions($this->drv);
\Phake::verifyNoOtherInteractions(Arsse::$db);
@@ -43,7 +42,7 @@ class TestUser extends \JKingWeb\Arsse\Test\AbstractTest {
$u->id = null;
$this->assertSame("", (string) $u);
}
-
+
public function testGeneratePasswords(): void {
$u = new User($this->drv);
$pass1 = $u->generatePassword();
@@ -166,7 +165,7 @@ class TestUser extends \JKingWeb\Arsse\Test\AbstractTest {
\Phake::verify($this->drv)->userAdd($user, $pass);
\Phake::verify(Arsse::$db)->userExists($user);
}
-
+
public function testRemoveAUser(): void {
$user = "john.doe@example.com";
$pass = "secret";
@@ -178,7 +177,7 @@ class TestUser extends \JKingWeb\Arsse\Test\AbstractTest {
\Phake::verify(Arsse::$db)->userRemove($user);
\Phake::verify($this->drv)->userRemove($user);
}
-
+
public function testRemoveAUserWeDoNotKnow(): void {
$user = "john.doe@example.com";
$pass = "secret";
@@ -189,7 +188,7 @@ class TestUser extends \JKingWeb\Arsse\Test\AbstractTest {
\Phake::verify(Arsse::$db)->userExists($user);
\Phake::verify($this->drv)->userRemove($user);
}
-
+
public function testRemoveAMissingUser(): void {
$user = "john.doe@example.com";
$pass = "secret";
@@ -205,7 +204,7 @@ class TestUser extends \JKingWeb\Arsse\Test\AbstractTest {
\Phake::verify($this->drv)->userRemove($user);
}
}
-
+
public function testRemoveAMissingUserWeDoNotKnow(): void {
$user = "john.doe@example.com";
$pass = "secret";
@@ -381,7 +380,7 @@ class TestUser extends \JKingWeb\Arsse\Test\AbstractTest {
\Phake::verify($this->drv)->userPropertiesGet($user);
}
}
-
+
/** @dataProvider providePropertyChanges */
public function testSetThePropertiesOfAUser(array $in, $out): void {
$user = "john.doe@example.com";
@@ -399,7 +398,7 @@ class TestUser extends \JKingWeb\Arsse\Test\AbstractTest {
\Phake::verify(Arsse::$db)->userExists($user);
}
}
-
+
/** @dataProvider providePropertyChanges */
public function testSetThePropertiesOfAUserWeDoNotKnow(array $in, $out): void {
$user = "john.doe@example.com";
@@ -418,7 +417,7 @@ class TestUser extends \JKingWeb\Arsse\Test\AbstractTest {
\Phake::verify(Arsse::$db)->userAdd($user, null);
}
}
-
+
public function providePropertyChanges(): iterable {
return [
[['admin' => true], ['admin' => true]],
@@ -431,7 +430,7 @@ class TestUser extends \JKingWeb\Arsse\Test\AbstractTest {
[['lang' => null], ['lang' => null]],
];
}
-
+
public function testSetThePropertiesOfAMissingUser(): void {
$user = "john.doe@example.com";
$in = ['admin' => true];
diff --git a/tests/phpunit.dist.xml b/tests/phpunit.dist.xml
index 0c6f8a76..a46fe77d 100644
--- a/tests/phpunit.dist.xml
+++ b/tests/phpunit.dist.xml
@@ -112,10 +112,14 @@
cases/REST/TestREST.php
+
+ cases/REST/Miniflux/TestErrorResponse.php
+ cases/REST/Miniflux/TestStatus.php
+
cases/REST/NextcloudNews/TestVersions.php
cases/REST/NextcloudNews/TestV1_2.php
- cases/REST/NextcloudNews/PDO/TestV1_2.php
+ cases/REST/NextcloudNews/PDO/TestV1_2.php
cases/REST/TinyTinyRSS/TestSearch.php
From 90117b5cd72d42141e83601a65136eda4f75ffbd Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Thu, 26 Nov 2020 08:42:35 -0500
Subject: [PATCH 048/366] Fix Miniflux strip value
---
lib/REST.php | 2 +-
tests/cases/REST/Miniflux/TestV1.php | 23 ++---------------------
2 files changed, 3 insertions(+), 22 deletions(-)
diff --git a/lib/REST.php b/lib/REST.php
index 4f1f4bdd..eea62746 100644
--- a/lib/REST.php
+++ b/lib/REST.php
@@ -42,7 +42,7 @@ class REST {
],
'miniflux' => [ // Miniflux https://miniflux.app/docs/api.html
'match' => '/v1/',
- 'strip' => '',
+ 'strip' => '/v1',
'class' => REST\Miniflux\V1::class,
],
'miniflux-version' => [ // Miniflux version report
diff --git a/tests/cases/REST/Miniflux/TestV1.php b/tests/cases/REST/Miniflux/TestV1.php
index 7d20a0a8..db8c34d9 100644
--- a/tests/cases/REST/Miniflux/TestV1.php
+++ b/tests/cases/REST/Miniflux/TestV1.php
@@ -53,7 +53,8 @@ class TestV1 extends \JKingWeb\Arsse\Test\AbstractTest {
return $value;
}
- public function testSendAuthenticationChallenge(): void {
+ /** @dataProvider provideAuthResponses */
+ public function testAuthenticateAUser(): void {
$exp = new EmptyResponse(401);
$this->assertMessage($exp, $this->req("GET", "/", "", [], false));
}
@@ -67,27 +68,7 @@ class TestV1 extends \JKingWeb\Arsse\Test\AbstractTest {
public function provideInvalidPaths(): array {
return [
["/", "GET", 404],
- ["/", "POST", 404],
- ["/", "PUT", 404],
- ["/", "DELETE", 404],
- ["/", "OPTIONS", 404],
- ["/version/invalid", "GET", 404],
- ["/version/invalid", "POST", 404],
- ["/version/invalid", "PUT", 404],
- ["/version/invalid", "DELETE", 404],
- ["/version/invalid", "OPTIONS", 404],
- ["/folders/1/invalid", "GET", 404],
- ["/folders/1/invalid", "POST", 404],
- ["/folders/1/invalid", "PUT", 404],
- ["/folders/1/invalid", "DELETE", 404],
- ["/folders/1/invalid", "OPTIONS", 404],
["/version", "POST", 405, "GET"],
- ["/version", "PUT", 405, "GET"],
- ["/version", "DELETE", 405, "GET"],
- ["/folders", "PUT", 405, "GET, POST"],
- ["/folders", "DELETE", 405, "GET, POST"],
- ["/folders/1", "GET", 405, "PUT, DELETE"],
- ["/folders/1", "POST", 405, "PUT, DELETE"],
];
}
From 8c059773bb1fe64f4a2461c7d3eceb5f34c487ee Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Mon, 30 Nov 2020 10:51:39 -0500
Subject: [PATCH 049/366] Update tooling
---
composer.lock | 93 ++++++++-
vendor-bin/csfixer/composer.lock | 279 ++++++++++++++++-----------
vendor-bin/daux/composer.lock | 321 ++++++++++++++++++++++---------
vendor-bin/phpunit/composer.lock | 217 +++++++++++++++++----
vendor-bin/robo/composer.lock | 205 +++++++++++++-------
5 files changed, 807 insertions(+), 308 deletions(-)
diff --git a/composer.lock b/composer.lock
index 77d43df5..f69d4dbb 100644
--- a/composer.lock
+++ b/composer.lock
@@ -50,6 +50,10 @@
"cli",
"docs"
],
+ "support": {
+ "issues": "https://github.com/docopt/docopt.php/issues",
+ "source": "https://github.com/docopt/docopt.php/tree/1.0.4"
+ },
"time": "2019-12-03T02:48:46+00:00"
},
{
@@ -117,6 +121,10 @@
"rest",
"web service"
],
+ "support": {
+ "issues": "https://github.com/guzzle/guzzle/issues",
+ "source": "https://github.com/guzzle/guzzle/tree/6.5"
+ },
"time": "2020-06-16T21:01:06+00:00"
},
{
@@ -168,6 +176,10 @@
"keywords": [
"promise"
],
+ "support": {
+ "issues": "https://github.com/guzzle/promises/issues",
+ "source": "https://github.com/guzzle/promises/tree/1.4.0"
+ },
"time": "2020-09-30T07:37:28+00:00"
},
{
@@ -239,6 +251,10 @@
"uri",
"url"
],
+ "support": {
+ "issues": "https://github.com/guzzle/psr7/issues",
+ "source": "https://github.com/guzzle/psr7/tree/1.7.0"
+ },
"time": "2020-09-30T07:37:11+00:00"
},
{
@@ -279,6 +295,10 @@
}
],
"description": "Password generator for generating policy-compliant passwords.",
+ "support": {
+ "issues": "https://github.com/hosteurope/password-generator/issues",
+ "source": "https://github.com/hosteurope/password-generator/tree/master"
+ },
"time": "2016-12-08T09:32:12+00:00"
},
{
@@ -324,6 +344,10 @@
"keywords": [
"uuid"
],
+ "support": {
+ "issues": "https://github.com/JKingweb/DrUUID/issues",
+ "source": "https://github.com/JKingweb/DrUUID/tree/3.0.0"
+ },
"time": "2017-02-09T14:17:01+00:00"
},
{
@@ -399,6 +423,10 @@
"rfc7234",
"validation"
],
+ "support": {
+ "issues": "https://github.com/Kevinrob/guzzle-cache-middleware/issues",
+ "source": "https://github.com/Kevinrob/guzzle-cache-middleware/tree/master"
+ },
"time": "2017-08-17T12:23:43+00:00"
},
{
@@ -484,6 +512,14 @@
"psr-17",
"psr-7"
],
+ "support": {
+ "chat": "https://laminas.dev/chat",
+ "docs": "https://docs.laminas.dev/laminas-diactoros/",
+ "forum": "https://discourse.laminas.dev",
+ "issues": "https://github.com/laminas/laminas-diactoros/issues",
+ "rss": "https://github.com/laminas/laminas-diactoros/releases.atom",
+ "source": "https://github.com/laminas/laminas-diactoros"
+ },
"funding": [
{
"url": "https://funding.communitybridge.org/projects/laminas-project",
@@ -549,6 +585,14 @@
"psr-15",
"psr-7"
],
+ "support": {
+ "chat": "https://laminas.dev/chat",
+ "docs": "https://docs.laminas.dev/laminas-httphandlerrunner/",
+ "forum": "https://discourse.laminas.dev",
+ "issues": "https://github.com/laminas/laminas-httphandlerrunner/issues",
+ "rss": "https://github.com/laminas/laminas-httphandlerrunner/releases.atom",
+ "source": "https://github.com/laminas/laminas-httphandlerrunner"
+ },
"funding": [
{
"url": "https://funding.communitybridge.org/projects/laminas-project",
@@ -605,6 +649,14 @@
"security",
"xml"
],
+ "support": {
+ "chat": "https://laminas.dev/chat",
+ "docs": "https://docs.laminas.dev/laminas-xml/",
+ "forum": "https://discourse.laminas.dev",
+ "issues": "https://github.com/laminas/laminas-xml/issues",
+ "rss": "https://github.com/laminas/laminas-xml/releases.atom",
+ "source": "https://github.com/laminas/laminas-xml"
+ },
"time": "2019-12-31T18:05:42+00:00"
},
{
@@ -653,6 +705,12 @@
"laminas",
"zf"
],
+ "support": {
+ "forum": "https://discourse.laminas.dev/",
+ "issues": "https://github.com/laminas/laminas-zendframework-bridge/issues",
+ "rss": "https://github.com/laminas/laminas-zendframework-bridge/releases.atom",
+ "source": "https://github.com/laminas/laminas-zendframework-bridge"
+ },
"funding": [
{
"url": "https://funding.communitybridge.org/projects/laminas-project",
@@ -721,6 +779,9 @@
],
"description": "RSS/Atom parsing library",
"homepage": "https://github.com/nicolus/picoFeed",
+ "support": {
+ "source": "https://github.com/nicolus/picoFeed/tree/0.1.43"
+ },
"time": "2020-09-15T07:28:23+00:00"
},
{
@@ -773,6 +834,9 @@
"request",
"response"
],
+ "support": {
+ "source": "https://github.com/php-fig/http-factory/tree/master"
+ },
"time": "2019-04-30T12:38:16+00:00"
},
{
@@ -823,6 +887,9 @@
"request",
"response"
],
+ "support": {
+ "source": "https://github.com/php-fig/http-message/tree/master"
+ },
"time": "2016-08-06T14:39:51+00:00"
},
{
@@ -876,6 +943,10 @@
"response",
"server"
],
+ "support": {
+ "issues": "https://github.com/php-fig/http-server-handler/issues",
+ "source": "https://github.com/php-fig/http-server-handler/tree/master"
+ },
"time": "2018-10-30T16:46:14+00:00"
},
{
@@ -923,6 +994,9 @@
"psr",
"psr-3"
],
+ "support": {
+ "source": "https://github.com/php-fig/log/tree/1.1.3"
+ },
"time": "2020-03-23T09:12:05+00:00"
},
{
@@ -963,6 +1037,10 @@
}
],
"description": "A polyfill for getallheaders.",
+ "support": {
+ "issues": "https://github.com/ralouphie/getallheaders/issues",
+ "source": "https://github.com/ralouphie/getallheaders/tree/develop"
+ },
"time": "2019-03-08T08:55:37+00:00"
},
{
@@ -1033,6 +1111,9 @@
"portable",
"shim"
],
+ "support": {
+ "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.20.0"
+ },
"funding": [
{
"url": "https://symfony.com/sponsor",
@@ -1114,6 +1195,9 @@
"portable",
"shim"
],
+ "support": {
+ "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.20.0"
+ },
"funding": [
{
"url": "https://symfony.com/sponsor",
@@ -1187,6 +1271,9 @@
"portable",
"shim"
],
+ "support": {
+ "source": "https://github.com/symfony/polyfill-php72/tree/v1.20.0"
+ },
"funding": [
{
"url": "https://symfony.com/sponsor",
@@ -1249,6 +1336,10 @@
"isolation",
"tool"
],
+ "support": {
+ "issues": "https://github.com/bamarni/composer-bin-plugin/issues",
+ "source": "https://github.com/bamarni/composer-bin-plugin/tree/master"
+ },
"time": "2020-05-03T08:27:20+00:00"
}
],
@@ -1268,5 +1359,5 @@
"platform-overrides": {
"php": "7.1.33"
},
- "plugin-api-version": "1.1.0"
+ "plugin-api-version": "2.0.0"
}
diff --git a/vendor-bin/csfixer/composer.lock b/vendor-bin/csfixer/composer.lock
index dc5ddc50..f3fbaff0 100644
--- a/vendor-bin/csfixer/composer.lock
+++ b/vendor-bin/csfixer/composer.lock
@@ -9,28 +9,29 @@
"packages-dev": [
{
"name": "composer/semver",
- "version": "1.7.1",
+ "version": "3.2.4",
"source": {
"type": "git",
"url": "https://github.com/composer/semver.git",
- "reference": "38276325bd896f90dfcfe30029aa5db40df387a7"
+ "reference": "a02fdf930a3c1c3ed3a49b5f63859c0c20e10464"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/composer/semver/zipball/38276325bd896f90dfcfe30029aa5db40df387a7",
- "reference": "38276325bd896f90dfcfe30029aa5db40df387a7",
+ "url": "https://api.github.com/repos/composer/semver/zipball/a02fdf930a3c1c3ed3a49b5f63859c0c20e10464",
+ "reference": "a02fdf930a3c1c3ed3a49b5f63859c0c20e10464",
"shasum": ""
},
"require": {
- "php": "^5.3.2 || ^7.0"
+ "php": "^5.3.2 || ^7.0 || ^8.0"
},
"require-dev": {
- "phpunit/phpunit": "^4.5 || ^5.0.5"
+ "phpstan/phpstan": "^0.12.54",
+ "symfony/phpunit-bridge": "^4.2 || ^5"
},
"type": "library",
"extra": {
"branch-alias": {
- "dev-master": "1.x-dev"
+ "dev-main": "3.x-dev"
}
},
"autoload": {
@@ -66,6 +67,11 @@
"validation",
"versioning"
],
+ "support": {
+ "irc": "irc://irc.freenode.org/composer",
+ "issues": "https://github.com/composer/semver/issues",
+ "source": "https://github.com/composer/semver/tree/3.2.4"
+ },
"funding": [
{
"url": "https://packagist.com",
@@ -80,20 +86,20 @@
"type": "tidelift"
}
],
- "time": "2020-09-27T13:13:07+00:00"
+ "time": "2020-11-13T08:59:24+00:00"
},
{
"name": "composer/xdebug-handler",
- "version": "1.4.4",
+ "version": "1.4.5",
"source": {
"type": "git",
"url": "https://github.com/composer/xdebug-handler.git",
- "reference": "6e076a124f7ee146f2487554a94b6a19a74887ba"
+ "reference": "f28d44c286812c714741478d968104c5e604a1d4"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/composer/xdebug-handler/zipball/6e076a124f7ee146f2487554a94b6a19a74887ba",
- "reference": "6e076a124f7ee146f2487554a94b6a19a74887ba",
+ "url": "https://api.github.com/repos/composer/xdebug-handler/zipball/f28d44c286812c714741478d968104c5e604a1d4",
+ "reference": "f28d44c286812c714741478d968104c5e604a1d4",
"shasum": ""
},
"require": {
@@ -124,6 +130,11 @@
"Xdebug",
"performance"
],
+ "support": {
+ "irc": "irc://irc.freenode.org/composer",
+ "issues": "https://github.com/composer/xdebug-handler/issues",
+ "source": "https://github.com/composer/xdebug-handler/tree/1.4.5"
+ },
"funding": [
{
"url": "https://packagist.com",
@@ -138,20 +149,20 @@
"type": "tidelift"
}
],
- "time": "2020-10-24T12:39:10+00:00"
+ "time": "2020-11-13T08:04:11+00:00"
},
{
"name": "doctrine/annotations",
- "version": "1.11.0",
+ "version": "1.11.1",
"source": {
"type": "git",
"url": "https://github.com/doctrine/annotations.git",
- "reference": "88fb6fb1dae011de24dd6b632811c1ff5c2928f5"
+ "reference": "ce77a7ba1770462cd705a91a151b6c3746f9c6ad"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/doctrine/annotations/zipball/88fb6fb1dae011de24dd6b632811c1ff5c2928f5",
- "reference": "88fb6fb1dae011de24dd6b632811c1ff5c2928f5",
+ "url": "https://api.github.com/repos/doctrine/annotations/zipball/ce77a7ba1770462cd705a91a151b6c3746f9c6ad",
+ "reference": "ce77a7ba1770462cd705a91a151b6c3746f9c6ad",
"shasum": ""
},
"require": {
@@ -209,7 +220,11 @@
"docblock",
"parser"
],
- "time": "2020-10-17T22:05:33+00:00"
+ "support": {
+ "issues": "https://github.com/doctrine/annotations/issues",
+ "source": "https://github.com/doctrine/annotations/tree/1.11.1"
+ },
+ "time": "2020-10-26T10:28:16+00:00"
},
{
"name": "doctrine/lexer",
@@ -271,6 +286,10 @@
"parser",
"php"
],
+ "support": {
+ "issues": "https://github.com/doctrine/lexer/issues",
+ "source": "https://github.com/doctrine/lexer/tree/1.2.1"
+ },
"funding": [
{
"url": "https://www.doctrine-project.org/sponsorship.html",
@@ -289,27 +308,27 @@
},
{
"name": "friendsofphp/php-cs-fixer",
- "version": "v2.16.4",
+ "version": "v2.16.7",
"source": {
"type": "git",
"url": "https://github.com/FriendsOfPHP/PHP-CS-Fixer.git",
- "reference": "1023c3458137ab052f6ff1e09621a721bfdeca13"
+ "reference": "4e35806a6d7d8510d6842ae932e8832363d22c87"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/FriendsOfPHP/PHP-CS-Fixer/zipball/1023c3458137ab052f6ff1e09621a721bfdeca13",
- "reference": "1023c3458137ab052f6ff1e09621a721bfdeca13",
+ "url": "https://api.github.com/repos/FriendsOfPHP/PHP-CS-Fixer/zipball/4e35806a6d7d8510d6842ae932e8832363d22c87",
+ "reference": "4e35806a6d7d8510d6842ae932e8832363d22c87",
"shasum": ""
},
"require": {
- "composer/semver": "^1.4",
+ "composer/semver": "^1.4 || ^2.0 || ^3.0",
"composer/xdebug-handler": "^1.2",
"doctrine/annotations": "^1.2",
"ext-json": "*",
"ext-tokenizer": "*",
- "php": "^5.6 || ^7.0",
+ "php": "^7.1",
"php-cs-fixer/diff": "^1.3",
- "symfony/console": "^3.4.17 || ^4.1.6 || ^5.0",
+ "symfony/console": "^3.4.43 || ^4.1.6 || ^5.0",
"symfony/event-dispatcher": "^3.0 || ^4.0 || ^5.0",
"symfony/filesystem": "^3.0 || ^4.0 || ^5.0",
"symfony/finder": "^3.0 || ^4.0 || ^5.0",
@@ -322,14 +341,14 @@
"require-dev": {
"johnkary/phpunit-speedtrap": "^1.1 || ^2.0 || ^3.0",
"justinrainbow/json-schema": "^5.0",
- "keradus/cli-executor": "^1.2",
+ "keradus/cli-executor": "^1.4",
"mikey179/vfsstream": "^1.6",
- "php-coveralls/php-coveralls": "^2.1",
+ "php-coveralls/php-coveralls": "^2.4.1",
"php-cs-fixer/accessible-object": "^1.0",
- "php-cs-fixer/phpunit-constraint-isidenticalstring": "^1.1",
- "php-cs-fixer/phpunit-constraint-xmlmatchesxsd": "^1.1",
+ "php-cs-fixer/phpunit-constraint-isidenticalstring": "^1.2",
+ "php-cs-fixer/phpunit-constraint-xmlmatchesxsd": "^1.2.1",
"phpunit/phpunit": "^5.7.27 || ^6.5.14 || ^7.1",
- "phpunitgoodpractices/traits": "^1.8",
+ "phpunitgoodpractices/traits": "^1.9.1",
"symfony/phpunit-bridge": "^5.1",
"symfony/yaml": "^3.0 || ^4.0 || ^5.0"
},
@@ -376,13 +395,17 @@
}
],
"description": "A tool to automatically fix PHP code style",
+ "support": {
+ "issues": "https://github.com/FriendsOfPHP/PHP-CS-Fixer/issues",
+ "source": "https://github.com/FriendsOfPHP/PHP-CS-Fixer/tree/v2.16.7"
+ },
"funding": [
{
"url": "https://github.com/keradus",
"type": "github"
}
],
- "time": "2020-06-27T23:57:46+00:00"
+ "time": "2020-10-27T22:44:27+00:00"
},
{
"name": "php-cs-fixer/diff",
@@ -433,6 +456,10 @@
"keywords": [
"diff"
],
+ "support": {
+ "issues": "https://github.com/PHP-CS-Fixer/diff/issues",
+ "source": "https://github.com/PHP-CS-Fixer/diff/tree/v1.3.1"
+ },
"time": "2020-10-14T08:39:05+00:00"
},
{
@@ -482,6 +509,10 @@
"container-interop",
"psr"
],
+ "support": {
+ "issues": "https://github.com/php-fig/container/issues",
+ "source": "https://github.com/php-fig/container/tree/master"
+ },
"time": "2017-02-14T16:28:37+00:00"
},
{
@@ -528,6 +559,10 @@
"psr",
"psr-14"
],
+ "support": {
+ "issues": "https://github.com/php-fig/event-dispatcher/issues",
+ "source": "https://github.com/php-fig/event-dispatcher/tree/1.0.0"
+ },
"time": "2019-01-08T18:20:26+00:00"
},
{
@@ -575,20 +610,23 @@
"psr",
"psr-3"
],
+ "support": {
+ "source": "https://github.com/php-fig/log/tree/1.1.3"
+ },
"time": "2020-03-23T09:12:05+00:00"
},
{
"name": "symfony/console",
- "version": "v5.1.7",
+ "version": "v5.1.8",
"source": {
"type": "git",
"url": "https://github.com/symfony/console.git",
- "reference": "ae789a8a2ad189ce7e8216942cdb9b77319f5eb8"
+ "reference": "e0b2c29c0fa6a69089209bbe8fcff4df2a313d0e"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/console/zipball/ae789a8a2ad189ce7e8216942cdb9b77319f5eb8",
- "reference": "ae789a8a2ad189ce7e8216942cdb9b77319f5eb8",
+ "url": "https://api.github.com/repos/symfony/console/zipball/e0b2c29c0fa6a69089209bbe8fcff4df2a313d0e",
+ "reference": "e0b2c29c0fa6a69089209bbe8fcff4df2a313d0e",
"shasum": ""
},
"require": {
@@ -625,11 +663,6 @@
"symfony/process": ""
},
"type": "library",
- "extra": {
- "branch-alias": {
- "dev-master": "5.1-dev"
- }
- },
"autoload": {
"psr-4": {
"Symfony\\Component\\Console\\": ""
@@ -654,6 +687,9 @@
],
"description": "Symfony Console Component",
"homepage": "https://symfony.com",
+ "support": {
+ "source": "https://github.com/symfony/console/tree/v5.1.8"
+ },
"funding": [
{
"url": "https://symfony.com/sponsor",
@@ -668,7 +704,7 @@
"type": "tidelift"
}
],
- "time": "2020-10-07T15:23:00+00:00"
+ "time": "2020-10-24T12:01:57+00:00"
},
{
"name": "symfony/deprecation-contracts",
@@ -718,6 +754,9 @@
],
"description": "A generic function and convention to trigger deprecation notices",
"homepage": "https://symfony.com",
+ "support": {
+ "source": "https://github.com/symfony/deprecation-contracts/tree/master"
+ },
"funding": [
{
"url": "https://symfony.com/sponsor",
@@ -736,16 +775,16 @@
},
{
"name": "symfony/event-dispatcher",
- "version": "v5.1.7",
+ "version": "v5.1.8",
"source": {
"type": "git",
"url": "https://github.com/symfony/event-dispatcher.git",
- "reference": "d5de97d6af175a9e8131c546db054ca32842dd0f"
+ "reference": "26f4edae48c913fc183a3da0553fe63bdfbd361a"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/d5de97d6af175a9e8131c546db054ca32842dd0f",
- "reference": "d5de97d6af175a9e8131c546db054ca32842dd0f",
+ "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/26f4edae48c913fc183a3da0553fe63bdfbd361a",
+ "reference": "26f4edae48c913fc183a3da0553fe63bdfbd361a",
"shasum": ""
},
"require": {
@@ -776,11 +815,6 @@
"symfony/http-kernel": ""
},
"type": "library",
- "extra": {
- "branch-alias": {
- "dev-master": "5.1-dev"
- }
- },
"autoload": {
"psr-4": {
"Symfony\\Component\\EventDispatcher\\": ""
@@ -805,6 +839,9 @@
],
"description": "Symfony EventDispatcher Component",
"homepage": "https://symfony.com",
+ "support": {
+ "source": "https://github.com/symfony/event-dispatcher/tree/v5.1.8"
+ },
"funding": [
{
"url": "https://symfony.com/sponsor",
@@ -819,7 +856,7 @@
"type": "tidelift"
}
],
- "time": "2020-09-18T14:27:32+00:00"
+ "time": "2020-10-24T12:01:57+00:00"
},
{
"name": "symfony/event-dispatcher-contracts",
@@ -881,6 +918,9 @@
"interoperability",
"standards"
],
+ "support": {
+ "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v2.2.0"
+ },
"funding": [
{
"url": "https://symfony.com/sponsor",
@@ -899,16 +939,16 @@
},
{
"name": "symfony/filesystem",
- "version": "v5.1.7",
+ "version": "v5.1.8",
"source": {
"type": "git",
"url": "https://github.com/symfony/filesystem.git",
- "reference": "1a8697545a8d87b9f2f6b1d32414199cc5e20aae"
+ "reference": "df08650ea7aee2d925380069c131a66124d79177"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/filesystem/zipball/1a8697545a8d87b9f2f6b1d32414199cc5e20aae",
- "reference": "1a8697545a8d87b9f2f6b1d32414199cc5e20aae",
+ "url": "https://api.github.com/repos/symfony/filesystem/zipball/df08650ea7aee2d925380069c131a66124d79177",
+ "reference": "df08650ea7aee2d925380069c131a66124d79177",
"shasum": ""
},
"require": {
@@ -916,11 +956,6 @@
"symfony/polyfill-ctype": "~1.8"
},
"type": "library",
- "extra": {
- "branch-alias": {
- "dev-master": "5.1-dev"
- }
- },
"autoload": {
"psr-4": {
"Symfony\\Component\\Filesystem\\": ""
@@ -945,6 +980,9 @@
],
"description": "Symfony Filesystem Component",
"homepage": "https://symfony.com",
+ "support": {
+ "source": "https://github.com/symfony/filesystem/tree/v5.1.8"
+ },
"funding": [
{
"url": "https://symfony.com/sponsor",
@@ -959,31 +997,26 @@
"type": "tidelift"
}
],
- "time": "2020-09-27T14:02:37+00:00"
+ "time": "2020-10-24T12:01:57+00:00"
},
{
"name": "symfony/finder",
- "version": "v5.1.7",
+ "version": "v5.1.8",
"source": {
"type": "git",
"url": "https://github.com/symfony/finder.git",
- "reference": "2c3ba7ad6884e6c4451ce2340e2dc23f6fa3e0d8"
+ "reference": "e70eb5a69c2ff61ea135a13d2266e8914a67b3a0"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/finder/zipball/2c3ba7ad6884e6c4451ce2340e2dc23f6fa3e0d8",
- "reference": "2c3ba7ad6884e6c4451ce2340e2dc23f6fa3e0d8",
+ "url": "https://api.github.com/repos/symfony/finder/zipball/e70eb5a69c2ff61ea135a13d2266e8914a67b3a0",
+ "reference": "e70eb5a69c2ff61ea135a13d2266e8914a67b3a0",
"shasum": ""
},
"require": {
"php": ">=7.2.5"
},
"type": "library",
- "extra": {
- "branch-alias": {
- "dev-master": "5.1-dev"
- }
- },
"autoload": {
"psr-4": {
"Symfony\\Component\\Finder\\": ""
@@ -1008,6 +1041,9 @@
],
"description": "Symfony Finder Component",
"homepage": "https://symfony.com",
+ "support": {
+ "source": "https://github.com/symfony/finder/tree/v5.1.8"
+ },
"funding": [
{
"url": "https://symfony.com/sponsor",
@@ -1022,20 +1058,20 @@
"type": "tidelift"
}
],
- "time": "2020-09-02T16:23:27+00:00"
+ "time": "2020-10-24T12:01:57+00:00"
},
{
"name": "symfony/options-resolver",
- "version": "v5.1.7",
+ "version": "v5.1.8",
"source": {
"type": "git",
"url": "https://github.com/symfony/options-resolver.git",
- "reference": "4c7e155bf7d93ea4ba3824d5a14476694a5278dd"
+ "reference": "c6a02905e4ffc7a1498e8ee019db2b477cd1cc02"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/options-resolver/zipball/4c7e155bf7d93ea4ba3824d5a14476694a5278dd",
- "reference": "4c7e155bf7d93ea4ba3824d5a14476694a5278dd",
+ "url": "https://api.github.com/repos/symfony/options-resolver/zipball/c6a02905e4ffc7a1498e8ee019db2b477cd1cc02",
+ "reference": "c6a02905e4ffc7a1498e8ee019db2b477cd1cc02",
"shasum": ""
},
"require": {
@@ -1044,11 +1080,6 @@
"symfony/polyfill-php80": "^1.15"
},
"type": "library",
- "extra": {
- "branch-alias": {
- "dev-master": "5.1-dev"
- }
- },
"autoload": {
"psr-4": {
"Symfony\\Component\\OptionsResolver\\": ""
@@ -1078,6 +1109,9 @@
"configuration",
"options"
],
+ "support": {
+ "source": "https://github.com/symfony/options-resolver/tree/v5.1.8"
+ },
"funding": [
{
"url": "https://symfony.com/sponsor",
@@ -1092,7 +1126,7 @@
"type": "tidelift"
}
],
- "time": "2020-09-27T03:44:28+00:00"
+ "time": "2020-10-24T12:01:57+00:00"
},
{
"name": "symfony/polyfill-ctype",
@@ -1154,6 +1188,9 @@
"polyfill",
"portable"
],
+ "support": {
+ "source": "https://github.com/symfony/polyfill-ctype/tree/v1.20.0"
+ },
"funding": [
{
"url": "https://symfony.com/sponsor",
@@ -1232,6 +1269,9 @@
"portable",
"shim"
],
+ "support": {
+ "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.20.0"
+ },
"funding": [
{
"url": "https://symfony.com/sponsor",
@@ -1313,6 +1353,9 @@
"portable",
"shim"
],
+ "support": {
+ "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.20.0"
+ },
"funding": [
{
"url": "https://symfony.com/sponsor",
@@ -1390,6 +1433,9 @@
"portable",
"shim"
],
+ "support": {
+ "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.20.0"
+ },
"funding": [
{
"url": "https://symfony.com/sponsor",
@@ -1455,6 +1501,9 @@
"portable",
"shim"
],
+ "support": {
+ "source": "https://github.com/symfony/polyfill-php70/tree/v1.20.0"
+ },
"funding": [
{
"url": "https://symfony.com/sponsor",
@@ -1528,6 +1577,9 @@
"portable",
"shim"
],
+ "support": {
+ "source": "https://github.com/symfony/polyfill-php72/tree/v1.20.0"
+ },
"funding": [
{
"url": "https://symfony.com/sponsor",
@@ -1604,6 +1656,9 @@
"portable",
"shim"
],
+ "support": {
+ "source": "https://github.com/symfony/polyfill-php73/tree/v1.20.0"
+ },
"funding": [
{
"url": "https://symfony.com/sponsor",
@@ -1684,6 +1739,9 @@
"portable",
"shim"
],
+ "support": {
+ "source": "https://github.com/symfony/polyfill-php80/tree/v1.20.0"
+ },
"funding": [
{
"url": "https://symfony.com/sponsor",
@@ -1702,16 +1760,16 @@
},
{
"name": "symfony/process",
- "version": "v5.1.7",
+ "version": "v5.1.8",
"source": {
"type": "git",
"url": "https://github.com/symfony/process.git",
- "reference": "d3a2e64866169586502f0cd9cab69135ad12cee9"
+ "reference": "f00872c3f6804150d6a0f73b4151daab96248101"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/process/zipball/d3a2e64866169586502f0cd9cab69135ad12cee9",
- "reference": "d3a2e64866169586502f0cd9cab69135ad12cee9",
+ "url": "https://api.github.com/repos/symfony/process/zipball/f00872c3f6804150d6a0f73b4151daab96248101",
+ "reference": "f00872c3f6804150d6a0f73b4151daab96248101",
"shasum": ""
},
"require": {
@@ -1719,11 +1777,6 @@
"symfony/polyfill-php80": "^1.15"
},
"type": "library",
- "extra": {
- "branch-alias": {
- "dev-master": "5.1-dev"
- }
- },
"autoload": {
"psr-4": {
"Symfony\\Component\\Process\\": ""
@@ -1748,6 +1801,9 @@
],
"description": "Symfony Process Component",
"homepage": "https://symfony.com",
+ "support": {
+ "source": "https://github.com/symfony/process/tree/v5.1.8"
+ },
"funding": [
{
"url": "https://symfony.com/sponsor",
@@ -1762,7 +1818,7 @@
"type": "tidelift"
}
],
- "time": "2020-09-02T16:23:27+00:00"
+ "time": "2020-10-24T12:01:57+00:00"
},
{
"name": "symfony/service-contracts",
@@ -1824,6 +1880,9 @@
"interoperability",
"standards"
],
+ "support": {
+ "source": "https://github.com/symfony/service-contracts/tree/master"
+ },
"funding": [
{
"url": "https://symfony.com/sponsor",
@@ -1842,16 +1901,16 @@
},
{
"name": "symfony/stopwatch",
- "version": "v5.1.7",
+ "version": "v5.1.8",
"source": {
"type": "git",
"url": "https://github.com/symfony/stopwatch.git",
- "reference": "0f7c58cf81dbb5dd67d423a89d577524a2ec0323"
+ "reference": "3d9f57c89011f0266e6b1d469e5c0110513859d5"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/stopwatch/zipball/0f7c58cf81dbb5dd67d423a89d577524a2ec0323",
- "reference": "0f7c58cf81dbb5dd67d423a89d577524a2ec0323",
+ "url": "https://api.github.com/repos/symfony/stopwatch/zipball/3d9f57c89011f0266e6b1d469e5c0110513859d5",
+ "reference": "3d9f57c89011f0266e6b1d469e5c0110513859d5",
"shasum": ""
},
"require": {
@@ -1859,11 +1918,6 @@
"symfony/service-contracts": "^1.0|^2"
},
"type": "library",
- "extra": {
- "branch-alias": {
- "dev-master": "5.1-dev"
- }
- },
"autoload": {
"psr-4": {
"Symfony\\Component\\Stopwatch\\": ""
@@ -1888,6 +1942,9 @@
],
"description": "Symfony Stopwatch Component",
"homepage": "https://symfony.com",
+ "support": {
+ "source": "https://github.com/symfony/stopwatch/tree/v5.1.8"
+ },
"funding": [
{
"url": "https://symfony.com/sponsor",
@@ -1902,20 +1959,20 @@
"type": "tidelift"
}
],
- "time": "2020-05-20T17:43:50+00:00"
+ "time": "2020-10-24T12:01:57+00:00"
},
{
"name": "symfony/string",
- "version": "v5.1.7",
+ "version": "v5.1.8",
"source": {
"type": "git",
"url": "https://github.com/symfony/string.git",
- "reference": "4a9afe9d07bac506f75bcee8ed3ce76da5a9343e"
+ "reference": "a97573e960303db71be0dd8fda9be3bca5e0feea"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/string/zipball/4a9afe9d07bac506f75bcee8ed3ce76da5a9343e",
- "reference": "4a9afe9d07bac506f75bcee8ed3ce76da5a9343e",
+ "url": "https://api.github.com/repos/symfony/string/zipball/a97573e960303db71be0dd8fda9be3bca5e0feea",
+ "reference": "a97573e960303db71be0dd8fda9be3bca5e0feea",
"shasum": ""
},
"require": {
@@ -1933,11 +1990,6 @@
"symfony/var-exporter": "^4.4|^5.0"
},
"type": "library",
- "extra": {
- "branch-alias": {
- "dev-master": "5.1-dev"
- }
- },
"autoload": {
"psr-4": {
"Symfony\\Component\\String\\": ""
@@ -1973,6 +2025,9 @@
"utf-8",
"utf8"
],
+ "support": {
+ "source": "https://github.com/symfony/string/tree/v5.1.8"
+ },
"funding": [
{
"url": "https://symfony.com/sponsor",
@@ -1987,7 +2042,7 @@
"type": "tidelift"
}
],
- "time": "2020-09-15T12:23:47+00:00"
+ "time": "2020-10-24T12:01:57+00:00"
}
],
"aliases": [],
@@ -1997,5 +2052,5 @@
"prefer-lowest": false,
"platform": [],
"platform-dev": [],
- "plugin-api-version": "1.1.0"
+ "plugin-api-version": "2.0.0"
}
diff --git a/vendor-bin/daux/composer.lock b/vendor-bin/daux/composer.lock
index 14ef3c4f..8e08bd22 100644
--- a/vendor-bin/daux/composer.lock
+++ b/vendor-bin/daux/composer.lock
@@ -76,6 +76,10 @@
"markdown",
"md"
],
+ "support": {
+ "issues": "https://github.com/dauxio/daux.io/issues",
+ "source": "https://github.com/dauxio/daux.io/tree/master"
+ },
"time": "2019-09-23T20:10:07+00:00"
},
{
@@ -143,6 +147,10 @@
"rest",
"web service"
],
+ "support": {
+ "issues": "https://github.com/guzzle/guzzle/issues",
+ "source": "https://github.com/guzzle/guzzle/tree/6.5"
+ },
"time": "2020-06-16T21:01:06+00:00"
},
{
@@ -194,6 +202,10 @@
"keywords": [
"promise"
],
+ "support": {
+ "issues": "https://github.com/guzzle/promises/issues",
+ "source": "https://github.com/guzzle/promises/tree/1.4.0"
+ },
"time": "2020-09-30T07:37:28+00:00"
},
{
@@ -265,6 +277,10 @@
"uri",
"url"
],
+ "support": {
+ "issues": "https://github.com/guzzle/psr7/issues",
+ "source": "https://github.com/guzzle/psr7/tree/1.7.0"
+ },
"time": "2020-09-30T07:37:11+00:00"
},
{
@@ -334,6 +350,12 @@
"markdown",
"parser"
],
+ "support": {
+ "docs": "https://commonmark.thephpleague.com/",
+ "issues": "https://github.com/thephpleague/commonmark/issues",
+ "rss": "https://github.com/thephpleague/commonmark/releases.atom",
+ "source": "https://github.com/thephpleague/commonmark"
+ },
"time": "2019-03-28T13:52:31+00:00"
},
{
@@ -389,20 +411,24 @@
"templating",
"views"
],
+ "support": {
+ "issues": "https://github.com/thephpleague/plates/issues",
+ "source": "https://github.com/thephpleague/plates/tree/master"
+ },
"time": "2016-12-28T00:14:17+00:00"
},
{
"name": "myclabs/deep-copy",
- "version": "1.10.1",
+ "version": "1.10.2",
"source": {
"type": "git",
"url": "https://github.com/myclabs/DeepCopy.git",
- "reference": "969b211f9a51aa1f6c01d1d2aef56d3bd91598e5"
+ "reference": "776f831124e9c62e1a2c601ecc52e776d8bb7220"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/969b211f9a51aa1f6c01d1d2aef56d3bd91598e5",
- "reference": "969b211f9a51aa1f6c01d1d2aef56d3bd91598e5",
+ "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/776f831124e9c62e1a2c601ecc52e776d8bb7220",
+ "reference": "776f831124e9c62e1a2c601ecc52e776d8bb7220",
"shasum": ""
},
"require": {
@@ -437,13 +463,17 @@
"object",
"object graph"
],
+ "support": {
+ "issues": "https://github.com/myclabs/DeepCopy/issues",
+ "source": "https://github.com/myclabs/DeepCopy/tree/1.10.2"
+ },
"funding": [
{
"url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy",
"type": "tidelift"
}
],
- "time": "2020-06-29T13:22:24+00:00"
+ "time": "2020-11-13T09:40:50+00:00"
},
{
"name": "psr/container",
@@ -492,6 +522,10 @@
"container-interop",
"psr"
],
+ "support": {
+ "issues": "https://github.com/php-fig/container/issues",
+ "source": "https://github.com/php-fig/container/tree/master"
+ },
"time": "2017-02-14T16:28:37+00:00"
},
{
@@ -542,6 +576,9 @@
"request",
"response"
],
+ "support": {
+ "source": "https://github.com/php-fig/http-message/tree/master"
+ },
"time": "2016-08-06T14:39:51+00:00"
},
{
@@ -582,20 +619,24 @@
}
],
"description": "A polyfill for getallheaders.",
+ "support": {
+ "issues": "https://github.com/ralouphie/getallheaders/issues",
+ "source": "https://github.com/ralouphie/getallheaders/tree/develop"
+ },
"time": "2019-03-08T08:55:37+00:00"
},
{
"name": "scrivo/highlight.php",
- "version": "v9.18.1.3",
+ "version": "v9.18.1.5",
"source": {
"type": "git",
"url": "https://github.com/scrivo/highlight.php.git",
- "reference": "6a1699707b099081f20a488ac1f92d682181018c"
+ "reference": "fa75a865928a4a5d49e5e77faca6bd2f2410baaf"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/scrivo/highlight.php/zipball/6a1699707b099081f20a488ac1f92d682181018c",
- "reference": "6a1699707b099081f20a488ac1f92d682181018c",
+ "url": "https://api.github.com/repos/scrivo/highlight.php/zipball/fa75a865928a4a5d49e5e77faca6bd2f2410baaf",
+ "reference": "fa75a865928a4a5d49e5e77faca6bd2f2410baaf",
"shasum": ""
},
"require": {
@@ -651,26 +692,30 @@
"highlight.php",
"syntax"
],
+ "support": {
+ "issues": "https://github.com/scrivo/highlight.php/issues",
+ "source": "https://github.com/scrivo/highlight.php"
+ },
"funding": [
{
"url": "https://github.com/allejo",
"type": "github"
}
],
- "time": "2020-10-16T07:43:22+00:00"
+ "time": "2020-11-22T06:07:40+00:00"
},
{
"name": "symfony/console",
- "version": "v4.4.15",
+ "version": "v4.4.16",
"source": {
"type": "git",
"url": "https://github.com/symfony/console.git",
- "reference": "90933b39c7b312fc3ceaa1ddeac7eb48cb953124"
+ "reference": "20f73dd143a5815d475e0838ff867bce1eebd9d5"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/console/zipball/90933b39c7b312fc3ceaa1ddeac7eb48cb953124",
- "reference": "90933b39c7b312fc3ceaa1ddeac7eb48cb953124",
+ "url": "https://api.github.com/repos/symfony/console/zipball/20f73dd143a5815d475e0838ff867bce1eebd9d5",
+ "reference": "20f73dd143a5815d475e0838ff867bce1eebd9d5",
"shasum": ""
},
"require": {
@@ -705,11 +750,6 @@
"symfony/process": ""
},
"type": "library",
- "extra": {
- "branch-alias": {
- "dev-master": "4.4-dev"
- }
- },
"autoload": {
"psr-4": {
"Symfony\\Component\\Console\\": ""
@@ -734,6 +774,9 @@
],
"description": "Symfony Console Component",
"homepage": "https://symfony.com",
+ "support": {
+ "source": "https://github.com/symfony/console/tree/v4.4.16"
+ },
"funding": [
{
"url": "https://symfony.com/sponsor",
@@ -748,20 +791,87 @@
"type": "tidelift"
}
],
- "time": "2020-09-15T07:58:55+00:00"
+ "time": "2020-10-24T11:50:19+00:00"
},
{
- "name": "symfony/http-foundation",
- "version": "v4.4.15",
+ "name": "symfony/deprecation-contracts",
+ "version": "v2.2.0",
"source": {
"type": "git",
- "url": "https://github.com/symfony/http-foundation.git",
- "reference": "10683b407c3b6087c64619ebc97a87e36ea62c92"
+ "url": "https://github.com/symfony/deprecation-contracts.git",
+ "reference": "5fa56b4074d1ae755beb55617ddafe6f5d78f665"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/http-foundation/zipball/10683b407c3b6087c64619ebc97a87e36ea62c92",
- "reference": "10683b407c3b6087c64619ebc97a87e36ea62c92",
+ "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/5fa56b4074d1ae755beb55617ddafe6f5d78f665",
+ "reference": "5fa56b4074d1ae755beb55617ddafe6f5d78f665",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.1"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "2.2-dev"
+ },
+ "thanks": {
+ "name": "symfony/contracts",
+ "url": "https://github.com/symfony/contracts"
+ }
+ },
+ "autoload": {
+ "files": [
+ "function.php"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Nicolas Grekas",
+ "email": "p@tchwork.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "A generic function and convention to trigger deprecation notices",
+ "homepage": "https://symfony.com",
+ "support": {
+ "source": "https://github.com/symfony/deprecation-contracts/tree/master"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2020-09-07T11:33:47+00:00"
+ },
+ {
+ "name": "symfony/http-foundation",
+ "version": "v4.4.16",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/http-foundation.git",
+ "reference": "827a00811ef699e809a201ceafac0b2b246bf38a"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/http-foundation/zipball/827a00811ef699e809a201ceafac0b2b246bf38a",
+ "reference": "827a00811ef699e809a201ceafac0b2b246bf38a",
"shasum": ""
},
"require": {
@@ -774,11 +884,6 @@
"symfony/expression-language": "^3.4|^4.0|^5.0"
},
"type": "library",
- "extra": {
- "branch-alias": {
- "dev-master": "4.4-dev"
- }
- },
"autoload": {
"psr-4": {
"Symfony\\Component\\HttpFoundation\\": ""
@@ -803,6 +908,9 @@
],
"description": "Symfony HttpFoundation Component",
"homepage": "https://symfony.com",
+ "support": {
+ "source": "https://github.com/symfony/http-foundation/tree/v4.4.16"
+ },
"funding": [
{
"url": "https://symfony.com/sponsor",
@@ -817,20 +925,20 @@
"type": "tidelift"
}
],
- "time": "2020-09-27T14:14:06+00:00"
+ "time": "2020-10-24T11:50:19+00:00"
},
{
"name": "symfony/intl",
- "version": "v5.1.7",
+ "version": "v5.1.8",
"source": {
"type": "git",
"url": "https://github.com/symfony/intl.git",
- "reference": "9381fd69ce6407041185aa6f1bafbf7d65f0e66a"
+ "reference": "e353c6c37afa1ff90739b3941f60ff9fa650eec3"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/intl/zipball/9381fd69ce6407041185aa6f1bafbf7d65f0e66a",
- "reference": "9381fd69ce6407041185aa6f1bafbf7d65f0e66a",
+ "url": "https://api.github.com/repos/symfony/intl/zipball/e353c6c37afa1ff90739b3941f60ff9fa650eec3",
+ "reference": "e353c6c37afa1ff90739b3941f60ff9fa650eec3",
"shasum": ""
},
"require": {
@@ -845,11 +953,6 @@
"ext-intl": "to use the component with locales other than \"en\""
},
"type": "library",
- "extra": {
- "branch-alias": {
- "dev-master": "5.1-dev"
- }
- },
"autoload": {
"psr-4": {
"Symfony\\Component\\Intl\\": ""
@@ -893,6 +996,9 @@
"l10n",
"localization"
],
+ "support": {
+ "source": "https://github.com/symfony/intl/tree/v5.1.8"
+ },
"funding": [
{
"url": "https://symfony.com/sponsor",
@@ -907,20 +1013,20 @@
"type": "tidelift"
}
],
- "time": "2020-09-27T03:44:28+00:00"
+ "time": "2020-10-24T12:01:57+00:00"
},
{
"name": "symfony/mime",
- "version": "v5.1.7",
+ "version": "v5.1.8",
"source": {
"type": "git",
"url": "https://github.com/symfony/mime.git",
- "reference": "4404d6545125863561721514ad9388db2661eec5"
+ "reference": "f5485a92c24d4bcfc2f3fc648744fb398482ff1b"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/mime/zipball/4404d6545125863561721514ad9388db2661eec5",
- "reference": "4404d6545125863561721514ad9388db2661eec5",
+ "url": "https://api.github.com/repos/symfony/mime/zipball/f5485a92c24d4bcfc2f3fc648744fb398482ff1b",
+ "reference": "f5485a92c24d4bcfc2f3fc648744fb398482ff1b",
"shasum": ""
},
"require": {
@@ -937,11 +1043,6 @@
"symfony/dependency-injection": "^4.4|^5.0"
},
"type": "library",
- "extra": {
- "branch-alias": {
- "dev-master": "5.1-dev"
- }
- },
"autoload": {
"psr-4": {
"Symfony\\Component\\Mime\\": ""
@@ -970,6 +1071,9 @@
"mime",
"mime-type"
],
+ "support": {
+ "source": "https://github.com/symfony/mime/tree/v5.1.8"
+ },
"funding": [
{
"url": "https://symfony.com/sponsor",
@@ -984,7 +1088,7 @@
"type": "tidelift"
}
],
- "time": "2020-09-02T16:23:27+00:00"
+ "time": "2020-10-24T12:01:57+00:00"
},
{
"name": "symfony/polyfill-ctype",
@@ -1046,6 +1150,9 @@
"polyfill",
"portable"
],
+ "support": {
+ "source": "https://github.com/symfony/polyfill-ctype/tree/v1.20.0"
+ },
"funding": [
{
"url": "https://symfony.com/sponsor",
@@ -1122,6 +1229,9 @@
"portable",
"shim"
],
+ "support": {
+ "source": "https://github.com/symfony/polyfill-intl-icu/tree/v1.20.0"
+ },
"funding": [
{
"url": "https://symfony.com/sponsor",
@@ -1206,6 +1316,9 @@
"portable",
"shim"
],
+ "support": {
+ "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.20.0"
+ },
"funding": [
{
"url": "https://symfony.com/sponsor",
@@ -1287,6 +1400,9 @@
"portable",
"shim"
],
+ "support": {
+ "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.20.0"
+ },
"funding": [
{
"url": "https://symfony.com/sponsor",
@@ -1364,6 +1480,9 @@
"portable",
"shim"
],
+ "support": {
+ "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.20.0"
+ },
"funding": [
{
"url": "https://symfony.com/sponsor",
@@ -1437,6 +1556,9 @@
"portable",
"shim"
],
+ "support": {
+ "source": "https://github.com/symfony/polyfill-php72/tree/v1.20.0"
+ },
"funding": [
{
"url": "https://symfony.com/sponsor",
@@ -1513,6 +1635,9 @@
"portable",
"shim"
],
+ "support": {
+ "source": "https://github.com/symfony/polyfill-php73/tree/v1.20.0"
+ },
"funding": [
{
"url": "https://symfony.com/sponsor",
@@ -1593,6 +1718,9 @@
"portable",
"shim"
],
+ "support": {
+ "source": "https://github.com/symfony/polyfill-php80/tree/v1.20.0"
+ },
"funding": [
{
"url": "https://symfony.com/sponsor",
@@ -1611,27 +1739,22 @@
},
{
"name": "symfony/process",
- "version": "v4.4.15",
+ "version": "v4.4.16",
"source": {
"type": "git",
"url": "https://github.com/symfony/process.git",
- "reference": "9b887acc522935f77555ae8813495958c7771ba7"
+ "reference": "2f4b049fb80ca5e9874615a2a85dc2a502090f05"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/process/zipball/9b887acc522935f77555ae8813495958c7771ba7",
- "reference": "9b887acc522935f77555ae8813495958c7771ba7",
+ "url": "https://api.github.com/repos/symfony/process/zipball/2f4b049fb80ca5e9874615a2a85dc2a502090f05",
+ "reference": "2f4b049fb80ca5e9874615a2a85dc2a502090f05",
"shasum": ""
},
"require": {
"php": ">=7.1.3"
},
"type": "library",
- "extra": {
- "branch-alias": {
- "dev-master": "4.4-dev"
- }
- },
"autoload": {
"psr-4": {
"Symfony\\Component\\Process\\": ""
@@ -1656,6 +1779,9 @@
],
"description": "Symfony Process Component",
"homepage": "https://symfony.com",
+ "support": {
+ "source": "https://github.com/symfony/process/tree/v4.4.16"
+ },
"funding": [
{
"url": "https://symfony.com/sponsor",
@@ -1670,7 +1796,7 @@
"type": "tidelift"
}
],
- "time": "2020-09-02T16:08:58+00:00"
+ "time": "2020-10-24T11:50:19+00:00"
},
{
"name": "symfony/service-contracts",
@@ -1732,6 +1858,9 @@
"interoperability",
"standards"
],
+ "support": {
+ "source": "https://github.com/symfony/service-contracts/tree/master"
+ },
"funding": [
{
"url": "https://symfony.com/sponsor",
@@ -1750,37 +1879,36 @@
},
{
"name": "symfony/yaml",
- "version": "v4.4.15",
+ "version": "v5.1.8",
"source": {
"type": "git",
"url": "https://github.com/symfony/yaml.git",
- "reference": "c7885964b1eceb70b0981556d0a9b01d2d97c8d1"
+ "reference": "f284e032c3cefefb9943792132251b79a6127ca6"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/yaml/zipball/c7885964b1eceb70b0981556d0a9b01d2d97c8d1",
- "reference": "c7885964b1eceb70b0981556d0a9b01d2d97c8d1",
+ "url": "https://api.github.com/repos/symfony/yaml/zipball/f284e032c3cefefb9943792132251b79a6127ca6",
+ "reference": "f284e032c3cefefb9943792132251b79a6127ca6",
"shasum": ""
},
"require": {
- "php": ">=7.1.3",
+ "php": ">=7.2.5",
+ "symfony/deprecation-contracts": "^2.1",
"symfony/polyfill-ctype": "~1.8"
},
"conflict": {
- "symfony/console": "<3.4"
+ "symfony/console": "<4.4"
},
"require-dev": {
- "symfony/console": "^3.4|^4.0|^5.0"
+ "symfony/console": "^4.4|^5.0"
},
"suggest": {
"symfony/console": "For validating YAML files using the lint command"
},
+ "bin": [
+ "Resources/bin/yaml-lint"
+ ],
"type": "library",
- "extra": {
- "branch-alias": {
- "dev-master": "4.4-dev"
- }
- },
"autoload": {
"psr-4": {
"Symfony\\Component\\Yaml\\": ""
@@ -1805,6 +1933,9 @@
],
"description": "Symfony Yaml Component",
"homepage": "https://symfony.com",
+ "support": {
+ "source": "https://github.com/symfony/yaml/tree/v5.1.8"
+ },
"funding": [
{
"url": "https://symfony.com/sponsor",
@@ -1819,7 +1950,7 @@
"type": "tidelift"
}
],
- "time": "2020-09-27T03:36:23+00:00"
+ "time": "2020-10-24T12:03:25+00:00"
},
{
"name": "webuni/commonmark-table-extension",
@@ -1877,36 +2008,38 @@
"markdown",
"table"
],
+ "support": {
+ "issues": "https://github.com/webuni/commonmark-table-extension/issues",
+ "source": "https://github.com/webuni/commonmark-table-extension/tree/0.9.0"
+ },
"abandoned": "league/commonmark",
"time": "2018-11-28T11:29:11+00:00"
},
{
"name": "webuni/front-matter",
- "version": "1.1.0",
+ "version": "1.2.0",
"source": {
"type": "git",
"url": "https://github.com/webuni/front-matter.git",
- "reference": "c7d1c51f9864ff015365ce515374e63bcd3b558e"
+ "reference": "dd2f623ac169b52e4eb261f3aaf4973cd2b0c368"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/webuni/front-matter/zipball/c7d1c51f9864ff015365ce515374e63bcd3b558e",
- "reference": "c7d1c51f9864ff015365ce515374e63bcd3b558e",
+ "url": "https://api.github.com/repos/webuni/front-matter/zipball/dd2f623ac169b52e4eb261f3aaf4973cd2b0c368",
+ "reference": "dd2f623ac169b52e4eb261f3aaf4973cd2b0c368",
"shasum": ""
},
"require": {
- "php": "^5.6|^7.0",
- "symfony/yaml": "^2.3|^3.0|^4.0"
+ "php": "^7.2 || ^8.0",
+ "symfony/yaml": "^3.0 || ^4.0 || ^5.0"
},
"require-dev": {
- "friendsofphp/php-cs-fixer": "^2.9",
+ "ext-json": "*",
+ "league/commonmark": "^1.4",
"mthaml/mthaml": "^1.3",
- "nette/neon": "^2.2",
- "phpunit/phpunit": "^5.7|^6.0|^7.0",
- "symfony/var-dumper": "^3.0|^4.0",
- "twig/twig": "^1.27|^2.0",
- "vimeo/psalm": "^1.0",
- "yosymfony/toml": "~0.3|^1.0"
+ "nette/neon": "^2.2 || ^3.0",
+ "twig/twig": "^3.0",
+ "yosymfony/toml": "^1.0"
},
"suggest": {
"nette/neon": "If you want to use NEON as front matter",
@@ -1930,7 +2063,8 @@
"authors": [
{
"name": "Martin Hasoň",
- "email": "martin.hason@gmail.com"
+ "email": "martin.hason@gmail.com",
+ "homepage": "https://www.martinhason.cz"
},
{
"name": "Webuni s.r.o.",
@@ -1940,13 +2074,18 @@
"description": "Front matter parser and dumper for PHP",
"homepage": "https://github.com/webuni/front-matter",
"keywords": [
+ "commonmark",
"front-matter",
"json",
"neon",
"toml",
"yaml"
],
- "time": "2018-03-20T13:36:33+00:00"
+ "support": {
+ "issues": "https://github.com/webuni/front-matter/issues",
+ "source": "https://github.com/webuni/front-matter/tree/1.2.0"
+ },
+ "time": "2020-11-04T21:04:28+00:00"
}
],
"aliases": [],
@@ -1956,5 +2095,5 @@
"prefer-lowest": false,
"platform": [],
"platform-dev": [],
- "plugin-api-version": "1.1.0"
+ "plugin-api-version": "2.0.0"
}
diff --git a/vendor-bin/phpunit/composer.lock b/vendor-bin/phpunit/composer.lock
index 938fc8f3..e963b995 100644
--- a/vendor-bin/phpunit/composer.lock
+++ b/vendor-bin/phpunit/composer.lock
@@ -55,6 +55,10 @@
"parse",
"split"
],
+ "support": {
+ "issues": "https://github.com/clue/php-arguments/issues",
+ "source": "https://github.com/clue/php-arguments/tree/v2.0.0"
+ },
"time": "2016-12-18T14:37:39+00:00"
},
{
@@ -96,40 +100,39 @@
}
],
"description": "This package provides Array Subset and related asserts once depracated in PHPunit 8",
+ "support": {
+ "issues": "https://github.com/rdohms/phpunit-arraysubset-asserts/issues",
+ "source": "https://github.com/rdohms/phpunit-arraysubset-asserts/tree/compat/phpunit8"
+ },
"time": "2020-02-18T21:20:04+00:00"
},
{
"name": "doctrine/instantiator",
- "version": "1.3.1",
+ "version": "1.4.0",
"source": {
"type": "git",
"url": "https://github.com/doctrine/instantiator.git",
- "reference": "f350df0268e904597e3bd9c4685c53e0e333feea"
+ "reference": "d56bf6102915de5702778fe20f2de3b2fe570b5b"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/doctrine/instantiator/zipball/f350df0268e904597e3bd9c4685c53e0e333feea",
- "reference": "f350df0268e904597e3bd9c4685c53e0e333feea",
+ "url": "https://api.github.com/repos/doctrine/instantiator/zipball/d56bf6102915de5702778fe20f2de3b2fe570b5b",
+ "reference": "d56bf6102915de5702778fe20f2de3b2fe570b5b",
"shasum": ""
},
"require": {
"php": "^7.1 || ^8.0"
},
"require-dev": {
- "doctrine/coding-standard": "^6.0",
+ "doctrine/coding-standard": "^8.0",
"ext-pdo": "*",
"ext-phar": "*",
- "phpbench/phpbench": "^0.13",
- "phpstan/phpstan-phpunit": "^0.11",
- "phpstan/phpstan-shim": "^0.11",
- "phpunit/phpunit": "^7.0"
+ "phpbench/phpbench": "^0.13 || 1.0.0-alpha2",
+ "phpstan/phpstan": "^0.12",
+ "phpstan/phpstan-phpunit": "^0.12",
+ "phpunit/phpunit": "^7.0 || ^8.0 || ^9.0"
},
"type": "library",
- "extra": {
- "branch-alias": {
- "dev-master": "1.2.x-dev"
- }
- },
"autoload": {
"psr-4": {
"Doctrine\\Instantiator\\": "src/Doctrine/Instantiator/"
@@ -143,7 +146,7 @@
{
"name": "Marco Pivetta",
"email": "ocramius@gmail.com",
- "homepage": "http://ocramius.github.com/"
+ "homepage": "https://ocramius.github.io/"
}
],
"description": "A small, lightweight utility to instantiate objects in PHP without invoking their constructors",
@@ -152,6 +155,10 @@
"constructor",
"instantiate"
],
+ "support": {
+ "issues": "https://github.com/doctrine/instantiator/issues",
+ "source": "https://github.com/doctrine/instantiator/tree/1.4.0"
+ },
"funding": [
{
"url": "https://www.doctrine-project.org/sponsorship.html",
@@ -166,7 +173,7 @@
"type": "tidelift"
}
],
- "time": "2020-05-29T17:27:14+00:00"
+ "time": "2020-11-10T18:47:58+00:00"
},
{
"name": "mikey179/vfsstream",
@@ -212,20 +219,25 @@
],
"description": "Virtual file system to mock the real file system in unit tests.",
"homepage": "http://vfs.bovigo.org/",
+ "support": {
+ "issues": "https://github.com/bovigo/vfsStream/issues",
+ "source": "https://github.com/bovigo/vfsStream/tree/master",
+ "wiki": "https://github.com/bovigo/vfsStream/wiki"
+ },
"time": "2019-10-30T15:31:00+00:00"
},
{
"name": "myclabs/deep-copy",
- "version": "1.10.1",
+ "version": "1.10.2",
"source": {
"type": "git",
"url": "https://github.com/myclabs/DeepCopy.git",
- "reference": "969b211f9a51aa1f6c01d1d2aef56d3bd91598e5"
+ "reference": "776f831124e9c62e1a2c601ecc52e776d8bb7220"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/969b211f9a51aa1f6c01d1d2aef56d3bd91598e5",
- "reference": "969b211f9a51aa1f6c01d1d2aef56d3bd91598e5",
+ "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/776f831124e9c62e1a2c601ecc52e776d8bb7220",
+ "reference": "776f831124e9c62e1a2c601ecc52e776d8bb7220",
"shasum": ""
},
"require": {
@@ -260,13 +272,17 @@
"object",
"object graph"
],
+ "support": {
+ "issues": "https://github.com/myclabs/DeepCopy/issues",
+ "source": "https://github.com/myclabs/DeepCopy/tree/1.10.2"
+ },
"funding": [
{
"url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy",
"type": "tidelift"
}
],
- "time": "2020-06-29T13:22:24+00:00"
+ "time": "2020-11-13T09:40:50+00:00"
},
{
"name": "phake/phake",
@@ -324,6 +340,10 @@
"mock",
"testing"
],
+ "support": {
+ "issues": "https://github.com/mlively/Phake/issues",
+ "source": "https://github.com/mlively/Phake/tree/v3.1.8"
+ },
"time": "2020-05-11T18:43:26+00:00"
},
{
@@ -379,6 +399,10 @@
}
],
"description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)",
+ "support": {
+ "issues": "https://github.com/phar-io/manifest/issues",
+ "source": "https://github.com/phar-io/manifest/tree/master"
+ },
"time": "2018-07-08T19:23:20+00:00"
},
{
@@ -426,6 +450,10 @@
}
],
"description": "Library for handling version information and constraints",
+ "support": {
+ "issues": "https://github.com/phar-io/version/issues",
+ "source": "https://github.com/phar-io/version/tree/master"
+ },
"time": "2018-07-08T19:19:57+00:00"
},
{
@@ -475,6 +503,10 @@
"reflection",
"static analysis"
],
+ "support": {
+ "issues": "https://github.com/phpDocumentor/ReflectionCommon/issues",
+ "source": "https://github.com/phpDocumentor/ReflectionCommon/tree/2.x"
+ },
"time": "2020-06-27T09:03:43+00:00"
},
{
@@ -527,6 +559,10 @@
}
],
"description": "With this component, a library can provide support for annotations via DocBlocks or otherwise retrieve information that is embedded in a DocBlock.",
+ "support": {
+ "issues": "https://github.com/phpDocumentor/ReflectionDocBlock/issues",
+ "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/master"
+ },
"time": "2020-09-03T19:13:55+00:00"
},
{
@@ -572,6 +608,10 @@
}
],
"description": "A PSR-5 based resolver of Class names, Types and Structural Element Names",
+ "support": {
+ "issues": "https://github.com/phpDocumentor/TypeResolver/issues",
+ "source": "https://github.com/phpDocumentor/TypeResolver/tree/1.4.0"
+ },
"time": "2020-09-17T18:55:26+00:00"
},
{
@@ -635,20 +675,24 @@
"spy",
"stub"
],
+ "support": {
+ "issues": "https://github.com/phpspec/prophecy/issues",
+ "source": "https://github.com/phpspec/prophecy/tree/1.12.1"
+ },
"time": "2020-09-29T09:10:42+00:00"
},
{
"name": "phpunit/php-code-coverage",
- "version": "7.0.10",
+ "version": "7.0.12",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/php-code-coverage.git",
- "reference": "f1884187926fbb755a9aaf0b3836ad3165b478bf"
+ "reference": "52f55786aa2e52c26cd9e2db20aff2981e0f7399"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/f1884187926fbb755a9aaf0b3836ad3165b478bf",
- "reference": "f1884187926fbb755a9aaf0b3836ad3165b478bf",
+ "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/52f55786aa2e52c26cd9e2db20aff2981e0f7399",
+ "reference": "52f55786aa2e52c26cd9e2db20aff2981e0f7399",
"shasum": ""
},
"require": {
@@ -698,7 +742,17 @@
"testing",
"xunit"
],
- "time": "2019-11-20T13:55:58+00:00"
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues",
+ "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/7.0.12"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2020-11-27T06:08:35+00:00"
},
{
"name": "phpunit/php-file-iterator",
@@ -748,6 +802,10 @@
"filesystem",
"iterator"
],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues",
+ "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/2.0.2"
+ },
"time": "2018-09-13T20:33:42+00:00"
},
{
@@ -789,6 +847,10 @@
"keywords": [
"template"
],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/php-text-template/issues",
+ "source": "https://github.com/sebastianbergmann/php-text-template/tree/1.2.1"
+ },
"time": "2015-06-21T13:50:34+00:00"
},
{
@@ -838,6 +900,10 @@
"keywords": [
"timer"
],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/php-timer/issues",
+ "source": "https://github.com/sebastianbergmann/php-timer/tree/master"
+ },
"time": "2019-06-07T04:22:29+00:00"
},
{
@@ -887,44 +953,48 @@
"keywords": [
"tokenizer"
],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/php-token-stream/issues",
+ "source": "https://github.com/sebastianbergmann/php-token-stream/tree/3.1.1"
+ },
"abandoned": true,
"time": "2019-09-17T06:23:10+00:00"
},
{
"name": "phpunit/phpunit",
- "version": "8.5.8",
+ "version": "8.5.11",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/phpunit.git",
- "reference": "34c18baa6a44f1d1fbf0338907139e9dce95b997"
+ "reference": "3123601e3b29339b20129acc3f989cfec3274566"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/34c18baa6a44f1d1fbf0338907139e9dce95b997",
- "reference": "34c18baa6a44f1d1fbf0338907139e9dce95b997",
+ "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/3123601e3b29339b20129acc3f989cfec3274566",
+ "reference": "3123601e3b29339b20129acc3f989cfec3274566",
"shasum": ""
},
"require": {
- "doctrine/instantiator": "^1.2.0",
+ "doctrine/instantiator": "^1.3.1",
"ext-dom": "*",
"ext-json": "*",
"ext-libxml": "*",
"ext-mbstring": "*",
"ext-xml": "*",
"ext-xmlwriter": "*",
- "myclabs/deep-copy": "^1.9.1",
+ "myclabs/deep-copy": "^1.10.0",
"phar-io/manifest": "^1.0.3",
"phar-io/version": "^2.0.1",
"php": "^7.2",
- "phpspec/prophecy": "^1.8.1",
- "phpunit/php-code-coverage": "^7.0.7",
+ "phpspec/prophecy": "^1.10.3",
+ "phpunit/php-code-coverage": "^7.0.12",
"phpunit/php-file-iterator": "^2.0.2",
"phpunit/php-text-template": "^1.2.1",
"phpunit/php-timer": "^2.1.2",
"sebastian/comparator": "^3.0.2",
"sebastian/diff": "^3.0.2",
- "sebastian/environment": "^4.2.2",
- "sebastian/exporter": "^3.1.1",
+ "sebastian/environment": "^4.2.3",
+ "sebastian/exporter": "^3.1.2",
"sebastian/global-state": "^3.0.0",
"sebastian/object-enumerator": "^3.0.3",
"sebastian/resource-operations": "^2.0.1",
@@ -971,6 +1041,10 @@
"testing",
"xunit"
],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/phpunit/issues",
+ "source": "https://github.com/sebastianbergmann/phpunit/tree/8.5.11"
+ },
"funding": [
{
"url": "https://phpunit.de/donate.html",
@@ -981,7 +1055,7 @@
"type": "github"
}
],
- "time": "2020-06-22T07:06:58+00:00"
+ "time": "2020-11-27T12:46:45+00:00"
},
{
"name": "sebastian/code-unit-reverse-lookup",
@@ -1026,6 +1100,10 @@
],
"description": "Looks up which function or method a line of code belongs to",
"homepage": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/",
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/issues",
+ "source": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/tree/master"
+ },
"time": "2017-03-04T06:30:41+00:00"
},
{
@@ -1090,6 +1168,10 @@
"compare",
"equality"
],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/comparator/issues",
+ "source": "https://github.com/sebastianbergmann/comparator/tree/master"
+ },
"time": "2018-07-12T15:12:46+00:00"
},
{
@@ -1146,6 +1228,10 @@
"unidiff",
"unified diff"
],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/diff/issues",
+ "source": "https://github.com/sebastianbergmann/diff/tree/master"
+ },
"time": "2019-02-04T06:01:07+00:00"
},
{
@@ -1199,6 +1285,10 @@
"environment",
"hhvm"
],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/environment/issues",
+ "source": "https://github.com/sebastianbergmann/environment/tree/4.2.3"
+ },
"time": "2019-11-20T08:46:58+00:00"
},
{
@@ -1266,6 +1356,10 @@
"export",
"exporter"
],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/exporter/issues",
+ "source": "https://github.com/sebastianbergmann/exporter/tree/master"
+ },
"time": "2019-09-14T09:02:43+00:00"
},
{
@@ -1320,6 +1414,10 @@
"keywords": [
"global state"
],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/global-state/issues",
+ "source": "https://github.com/sebastianbergmann/global-state/tree/master"
+ },
"time": "2019-02-01T05:30:01+00:00"
},
{
@@ -1367,6 +1465,10 @@
],
"description": "Traverses array structures and object graphs to enumerate all referenced objects",
"homepage": "https://github.com/sebastianbergmann/object-enumerator/",
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/object-enumerator/issues",
+ "source": "https://github.com/sebastianbergmann/object-enumerator/tree/master"
+ },
"time": "2017-08-03T12:35:26+00:00"
},
{
@@ -1412,6 +1514,10 @@
],
"description": "Allows reflection of object attributes, including inherited and non-public ones",
"homepage": "https://github.com/sebastianbergmann/object-reflector/",
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/object-reflector/issues",
+ "source": "https://github.com/sebastianbergmann/object-reflector/tree/master"
+ },
"time": "2017-03-29T09:07:27+00:00"
},
{
@@ -1465,6 +1571,10 @@
],
"description": "Provides functionality to recursively process PHP variables",
"homepage": "http://www.github.com/sebastianbergmann/recursion-context",
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/recursion-context/issues",
+ "source": "https://github.com/sebastianbergmann/recursion-context/tree/master"
+ },
"time": "2017-03-03T06:23:57+00:00"
},
{
@@ -1507,6 +1617,10 @@
],
"description": "Provides a list of PHP built-in functions that operate on resources",
"homepage": "https://www.github.com/sebastianbergmann/resource-operations",
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/resource-operations/issues",
+ "source": "https://github.com/sebastianbergmann/resource-operations/tree/master"
+ },
"time": "2018-10-04T04:07:39+00:00"
},
{
@@ -1553,6 +1667,10 @@
],
"description": "Collection of value objects that represent the types of the PHP type system",
"homepage": "https://github.com/sebastianbergmann/type",
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/type/issues",
+ "source": "https://github.com/sebastianbergmann/type/tree/master"
+ },
"time": "2019-07-02T08:10:15+00:00"
},
{
@@ -1596,6 +1714,10 @@
],
"description": "Library that helps with managing the version number of Git-hosted PHP projects",
"homepage": "https://github.com/sebastianbergmann/version",
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/version/issues",
+ "source": "https://github.com/sebastianbergmann/version/tree/master"
+ },
"time": "2016-10-03T07:35:21+00:00"
},
{
@@ -1658,6 +1780,9 @@
"polyfill",
"portable"
],
+ "support": {
+ "source": "https://github.com/symfony/polyfill-ctype/tree/v1.20.0"
+ },
"funding": [
{
"url": "https://symfony.com/sponsor",
@@ -1712,6 +1837,10 @@
}
],
"description": "A small library for converting tokenized PHP source code into XML and potentially other formats",
+ "support": {
+ "issues": "https://github.com/theseer/tokenizer/issues",
+ "source": "https://github.com/theseer/tokenizer/tree/master"
+ },
"funding": [
{
"url": "https://github.com/theseer",
@@ -1767,6 +1896,10 @@
"check",
"validate"
],
+ "support": {
+ "issues": "https://github.com/webmozart/assert/issues",
+ "source": "https://github.com/webmozart/assert/tree/master"
+ },
"time": "2020-07-08T17:02:28+00:00"
},
{
@@ -1814,6 +1947,10 @@
}
],
"description": "A PHP implementation of Ant's glob.",
+ "support": {
+ "issues": "https://github.com/webmozart/glob/issues",
+ "source": "https://github.com/webmozart/glob/tree/master"
+ },
"time": "2015-12-29T11:14:33+00:00"
},
{
@@ -1860,6 +1997,10 @@
}
],
"description": "A robust cross-platform utility for normalizing, comparing and modifying file paths.",
+ "support": {
+ "issues": "https://github.com/webmozart/path-util/issues",
+ "source": "https://github.com/webmozart/path-util/tree/2.3.0"
+ },
"time": "2015-12-17T08:42:14+00:00"
}
],
@@ -1870,5 +2011,5 @@
"prefer-lowest": false,
"platform": [],
"platform-dev": [],
- "plugin-api-version": "1.1.0"
+ "plugin-api-version": "2.0.0"
}
diff --git a/vendor-bin/robo/composer.lock b/vendor-bin/robo/composer.lock
index 5fbe6804..c558c227 100644
--- a/vendor-bin/robo/composer.lock
+++ b/vendor-bin/robo/composer.lock
@@ -69,6 +69,10 @@
}
],
"description": "Initialize Symfony Console commands from annotated command class methods.",
+ "support": {
+ "issues": "https://github.com/consolidation/annotated-command/issues",
+ "source": "https://github.com/consolidation/annotated-command/tree/4.2.3"
+ },
"time": "2020-10-03T14:28:42+00:00"
},
{
@@ -150,6 +154,10 @@
}
],
"description": "Provide configuration services for a commandline tool.",
+ "support": {
+ "issues": "https://github.com/consolidation/config/issues",
+ "source": "https://github.com/consolidation/config/tree/master"
+ },
"time": "2019-03-03T19:37:04+00:00"
},
{
@@ -211,6 +219,10 @@
}
],
"description": "Improved Psr-3 / Psr\\Log logger based on Symfony Console components.",
+ "support": {
+ "issues": "https://github.com/consolidation/log/issues",
+ "source": "https://github.com/consolidation/log/tree/2.0.1"
+ },
"time": "2020-05-27T17:06:13+00:00"
},
{
@@ -278,6 +290,10 @@
}
],
"description": "Format text by applying transformations provided by plug-in formatters.",
+ "support": {
+ "issues": "https://github.com/consolidation/output-formatters/issues",
+ "source": "https://github.com/consolidation/output-formatters/tree/4.1.1"
+ },
"time": "2020-05-27T20:51:17+00:00"
},
{
@@ -393,6 +409,10 @@
}
],
"description": "Modern task runner",
+ "support": {
+ "issues": "https://github.com/consolidation/Robo/issues",
+ "source": "https://github.com/consolidation/Robo/tree/1.4.13"
+ },
"time": "2020-10-11T04:51:34+00:00"
},
{
@@ -443,6 +463,10 @@
}
],
"description": "Provides a self:update command for Symfony Console applications.",
+ "support": {
+ "issues": "https://github.com/consolidation/self-update/issues",
+ "source": "https://github.com/consolidation/self-update/tree/1.2.0"
+ },
"time": "2020-04-13T02:49:20+00:00"
},
{
@@ -474,6 +498,10 @@
],
"description": "Promoting the interoperability of container objects (DIC, SL, etc.)",
"homepage": "https://github.com/container-interop/container-interop",
+ "support": {
+ "issues": "https://github.com/container-interop/container-interop/issues",
+ "source": "https://github.com/container-interop/container-interop/tree/master"
+ },
"abandoned": "psr/container",
"time": "2017-02-14T19:40:03+00:00"
},
@@ -534,6 +562,10 @@
"dot",
"notation"
],
+ "support": {
+ "issues": "https://github.com/dflydev/dflydev-dot-access-data/issues",
+ "source": "https://github.com/dflydev/dflydev-dot-access-data/tree/master"
+ },
"time": "2017-01-20T21:14:22+00:00"
},
{
@@ -581,6 +613,10 @@
}
],
"description": "Expands internal property references in PHP arrays file.",
+ "support": {
+ "issues": "https://github.com/grasmash/expander/issues",
+ "source": "https://github.com/grasmash/expander/tree/master"
+ },
"time": "2017-12-21T22:14:55+00:00"
},
{
@@ -629,6 +665,10 @@
}
],
"description": "Expands internal property references in a yaml file.",
+ "support": {
+ "issues": "https://github.com/grasmash/yaml-expander/issues",
+ "source": "https://github.com/grasmash/yaml-expander/tree/master"
+ },
"time": "2017-12-16T16:06:03+00:00"
},
{
@@ -694,20 +734,24 @@
"provider",
"service"
],
+ "support": {
+ "issues": "https://github.com/thephpleague/container/issues",
+ "source": "https://github.com/thephpleague/container/tree/2.x"
+ },
"time": "2017-05-10T09:20:27+00:00"
},
{
"name": "pear/archive_tar",
- "version": "1.4.10",
+ "version": "1.4.11",
"source": {
"type": "git",
"url": "https://github.com/pear/Archive_Tar.git",
- "reference": "bbb4f10f71a1da2715ec6d9a683f4f23c507a49b"
+ "reference": "17d355cb7d3c4ff08e5729f29cd7660145208d9d"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/pear/Archive_Tar/zipball/bbb4f10f71a1da2715ec6d9a683f4f23c507a49b",
- "reference": "bbb4f10f71a1da2715ec6d9a683f4f23c507a49b",
+ "url": "https://api.github.com/repos/pear/Archive_Tar/zipball/17d355cb7d3c4ff08e5729f29cd7660145208d9d",
+ "reference": "17d355cb7d3c4ff08e5729f29cd7660145208d9d",
"shasum": ""
},
"require": {
@@ -760,7 +804,11 @@
"archive",
"tar"
],
- "time": "2020-09-15T14:13:23+00:00"
+ "support": {
+ "issues": "http://pear.php.net/bugs/search.php?cmd=display&package_name[]=Archive_Tar",
+ "source": "https://github.com/pear/Archive_Tar"
+ },
+ "time": "2020-11-19T22:10:24+00:00"
},
{
"name": "pear/console_getopt",
@@ -807,6 +855,10 @@
}
],
"description": "More info available on: http://pear.php.net/package/Console_Getopt",
+ "support": {
+ "issues": "http://pear.php.net/bugs/search.php?cmd=display&package_name[]=Console_Getopt",
+ "source": "https://github.com/pear/Console_Getopt"
+ },
"time": "2019-11-20T18:27:48+00:00"
},
{
@@ -851,6 +903,10 @@
}
],
"description": "Minimal set of PEAR core files to be used as composer dependency",
+ "support": {
+ "issues": "http://pear.php.net/bugs/search.php?cmd=display&package_name[]=PEAR",
+ "source": "https://github.com/pear/pear-core-minimal"
+ },
"time": "2019-11-19T19:00:24+00:00"
},
{
@@ -906,6 +962,10 @@
"keywords": [
"exception"
],
+ "support": {
+ "issues": "http://pear.php.net/bugs/search.php?cmd=display&package_name[]=PEAR_Exception",
+ "source": "https://github.com/pear/PEAR_Exception"
+ },
"time": "2019-12-10T10:24:42+00:00"
},
{
@@ -955,6 +1015,10 @@
"container-interop",
"psr"
],
+ "support": {
+ "issues": "https://github.com/php-fig/container/issues",
+ "source": "https://github.com/php-fig/container/tree/master"
+ },
"time": "2017-02-14T16:28:37+00:00"
},
{
@@ -1002,20 +1066,23 @@
"psr",
"psr-3"
],
+ "support": {
+ "source": "https://github.com/php-fig/log/tree/1.1.3"
+ },
"time": "2020-03-23T09:12:05+00:00"
},
{
"name": "symfony/console",
- "version": "v4.4.15",
+ "version": "v4.4.16",
"source": {
"type": "git",
"url": "https://github.com/symfony/console.git",
- "reference": "90933b39c7b312fc3ceaa1ddeac7eb48cb953124"
+ "reference": "20f73dd143a5815d475e0838ff867bce1eebd9d5"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/console/zipball/90933b39c7b312fc3ceaa1ddeac7eb48cb953124",
- "reference": "90933b39c7b312fc3ceaa1ddeac7eb48cb953124",
+ "url": "https://api.github.com/repos/symfony/console/zipball/20f73dd143a5815d475e0838ff867bce1eebd9d5",
+ "reference": "20f73dd143a5815d475e0838ff867bce1eebd9d5",
"shasum": ""
},
"require": {
@@ -1050,11 +1117,6 @@
"symfony/process": ""
},
"type": "library",
- "extra": {
- "branch-alias": {
- "dev-master": "4.4-dev"
- }
- },
"autoload": {
"psr-4": {
"Symfony\\Component\\Console\\": ""
@@ -1079,6 +1141,9 @@
],
"description": "Symfony Console Component",
"homepage": "https://symfony.com",
+ "support": {
+ "source": "https://github.com/symfony/console/tree/v4.4.16"
+ },
"funding": [
{
"url": "https://symfony.com/sponsor",
@@ -1093,20 +1158,20 @@
"type": "tidelift"
}
],
- "time": "2020-09-15T07:58:55+00:00"
+ "time": "2020-10-24T11:50:19+00:00"
},
{
"name": "symfony/event-dispatcher",
- "version": "v4.4.15",
+ "version": "v4.4.16",
"source": {
"type": "git",
"url": "https://github.com/symfony/event-dispatcher.git",
- "reference": "e17bb5e0663dc725f7cdcafc932132735b4725cd"
+ "reference": "4204f13d2d0b7ad09454f221bb2195fccdf1fe98"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/e17bb5e0663dc725f7cdcafc932132735b4725cd",
- "reference": "e17bb5e0663dc725f7cdcafc932132735b4725cd",
+ "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/4204f13d2d0b7ad09454f221bb2195fccdf1fe98",
+ "reference": "4204f13d2d0b7ad09454f221bb2195fccdf1fe98",
"shasum": ""
},
"require": {
@@ -1135,11 +1200,6 @@
"symfony/http-kernel": ""
},
"type": "library",
- "extra": {
- "branch-alias": {
- "dev-master": "4.4-dev"
- }
- },
"autoload": {
"psr-4": {
"Symfony\\Component\\EventDispatcher\\": ""
@@ -1164,6 +1224,9 @@
],
"description": "Symfony EventDispatcher Component",
"homepage": "https://symfony.com",
+ "support": {
+ "source": "https://github.com/symfony/event-dispatcher/tree/v4.4.16"
+ },
"funding": [
{
"url": "https://symfony.com/sponsor",
@@ -1178,7 +1241,7 @@
"type": "tidelift"
}
],
- "time": "2020-09-18T14:07:46+00:00"
+ "time": "2020-10-24T11:50:19+00:00"
},
{
"name": "symfony/event-dispatcher-contracts",
@@ -1240,6 +1303,9 @@
"interoperability",
"standards"
],
+ "support": {
+ "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v1.1.9"
+ },
"funding": [
{
"url": "https://symfony.com/sponsor",
@@ -1258,16 +1324,16 @@
},
{
"name": "symfony/filesystem",
- "version": "v4.4.15",
+ "version": "v4.4.16",
"source": {
"type": "git",
"url": "https://github.com/symfony/filesystem.git",
- "reference": "ebc51494739d3b081ea543ed7c462fa73a4f74db"
+ "reference": "e74b873395b7213d44d1397bd4a605cd1632a68a"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/filesystem/zipball/ebc51494739d3b081ea543ed7c462fa73a4f74db",
- "reference": "ebc51494739d3b081ea543ed7c462fa73a4f74db",
+ "url": "https://api.github.com/repos/symfony/filesystem/zipball/e74b873395b7213d44d1397bd4a605cd1632a68a",
+ "reference": "e74b873395b7213d44d1397bd4a605cd1632a68a",
"shasum": ""
},
"require": {
@@ -1275,11 +1341,6 @@
"symfony/polyfill-ctype": "~1.8"
},
"type": "library",
- "extra": {
- "branch-alias": {
- "dev-master": "4.4-dev"
- }
- },
"autoload": {
"psr-4": {
"Symfony\\Component\\Filesystem\\": ""
@@ -1304,6 +1365,9 @@
],
"description": "Symfony Filesystem Component",
"homepage": "https://symfony.com",
+ "support": {
+ "source": "https://github.com/symfony/filesystem/tree/v4.4.16"
+ },
"funding": [
{
"url": "https://symfony.com/sponsor",
@@ -1318,31 +1382,26 @@
"type": "tidelift"
}
],
- "time": "2020-09-27T13:54:16+00:00"
+ "time": "2020-10-24T11:50:19+00:00"
},
{
"name": "symfony/finder",
- "version": "v5.1.7",
+ "version": "v5.1.8",
"source": {
"type": "git",
"url": "https://github.com/symfony/finder.git",
- "reference": "2c3ba7ad6884e6c4451ce2340e2dc23f6fa3e0d8"
+ "reference": "e70eb5a69c2ff61ea135a13d2266e8914a67b3a0"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/finder/zipball/2c3ba7ad6884e6c4451ce2340e2dc23f6fa3e0d8",
- "reference": "2c3ba7ad6884e6c4451ce2340e2dc23f6fa3e0d8",
+ "url": "https://api.github.com/repos/symfony/finder/zipball/e70eb5a69c2ff61ea135a13d2266e8914a67b3a0",
+ "reference": "e70eb5a69c2ff61ea135a13d2266e8914a67b3a0",
"shasum": ""
},
"require": {
"php": ">=7.2.5"
},
"type": "library",
- "extra": {
- "branch-alias": {
- "dev-master": "5.1-dev"
- }
- },
"autoload": {
"psr-4": {
"Symfony\\Component\\Finder\\": ""
@@ -1367,6 +1426,9 @@
],
"description": "Symfony Finder Component",
"homepage": "https://symfony.com",
+ "support": {
+ "source": "https://github.com/symfony/finder/tree/v5.1.8"
+ },
"funding": [
{
"url": "https://symfony.com/sponsor",
@@ -1381,7 +1443,7 @@
"type": "tidelift"
}
],
- "time": "2020-09-02T16:23:27+00:00"
+ "time": "2020-10-24T12:01:57+00:00"
},
{
"name": "symfony/polyfill-ctype",
@@ -1443,6 +1505,9 @@
"polyfill",
"portable"
],
+ "support": {
+ "source": "https://github.com/symfony/polyfill-ctype/tree/v1.20.0"
+ },
"funding": [
{
"url": "https://symfony.com/sponsor",
@@ -1520,6 +1585,9 @@
"portable",
"shim"
],
+ "support": {
+ "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.20.0"
+ },
"funding": [
{
"url": "https://symfony.com/sponsor",
@@ -1596,6 +1664,9 @@
"portable",
"shim"
],
+ "support": {
+ "source": "https://github.com/symfony/polyfill-php73/tree/v1.20.0"
+ },
"funding": [
{
"url": "https://symfony.com/sponsor",
@@ -1676,6 +1747,9 @@
"portable",
"shim"
],
+ "support": {
+ "source": "https://github.com/symfony/polyfill-php80/tree/v1.20.0"
+ },
"funding": [
{
"url": "https://symfony.com/sponsor",
@@ -1694,27 +1768,22 @@
},
{
"name": "symfony/process",
- "version": "v3.4.45",
+ "version": "v3.4.47",
"source": {
"type": "git",
"url": "https://github.com/symfony/process.git",
- "reference": "46a862d0f334e51c1ed831b49cbe12863ffd5475"
+ "reference": "b8648cf1d5af12a44a51d07ef9bf980921f15fca"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/process/zipball/46a862d0f334e51c1ed831b49cbe12863ffd5475",
- "reference": "46a862d0f334e51c1ed831b49cbe12863ffd5475",
+ "url": "https://api.github.com/repos/symfony/process/zipball/b8648cf1d5af12a44a51d07ef9bf980921f15fca",
+ "reference": "b8648cf1d5af12a44a51d07ef9bf980921f15fca",
"shasum": ""
},
"require": {
"php": "^5.5.9|>=7.0.8"
},
"type": "library",
- "extra": {
- "branch-alias": {
- "dev-master": "3.4-dev"
- }
- },
"autoload": {
"psr-4": {
"Symfony\\Component\\Process\\": ""
@@ -1739,6 +1808,9 @@
],
"description": "Symfony Process Component",
"homepage": "https://symfony.com",
+ "support": {
+ "source": "https://github.com/symfony/process/tree/v3.4.47"
+ },
"funding": [
{
"url": "https://symfony.com/sponsor",
@@ -1753,7 +1825,7 @@
"type": "tidelift"
}
],
- "time": "2020-09-02T16:06:40+00:00"
+ "time": "2020-10-24T10:57:07+00:00"
},
{
"name": "symfony/service-contracts",
@@ -1815,6 +1887,9 @@
"interoperability",
"standards"
],
+ "support": {
+ "source": "https://github.com/symfony/service-contracts/tree/master"
+ },
"funding": [
{
"url": "https://symfony.com/sponsor",
@@ -1833,16 +1908,16 @@
},
{
"name": "symfony/yaml",
- "version": "v4.4.15",
+ "version": "v4.4.16",
"source": {
"type": "git",
"url": "https://github.com/symfony/yaml.git",
- "reference": "c7885964b1eceb70b0981556d0a9b01d2d97c8d1"
+ "reference": "543cb4dbd45ed803f08a9a65f27fb149b5dd20c2"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/yaml/zipball/c7885964b1eceb70b0981556d0a9b01d2d97c8d1",
- "reference": "c7885964b1eceb70b0981556d0a9b01d2d97c8d1",
+ "url": "https://api.github.com/repos/symfony/yaml/zipball/543cb4dbd45ed803f08a9a65f27fb149b5dd20c2",
+ "reference": "543cb4dbd45ed803f08a9a65f27fb149b5dd20c2",
"shasum": ""
},
"require": {
@@ -1859,11 +1934,6 @@
"symfony/console": "For validating YAML files using the lint command"
},
"type": "library",
- "extra": {
- "branch-alias": {
- "dev-master": "4.4-dev"
- }
- },
"autoload": {
"psr-4": {
"Symfony\\Component\\Yaml\\": ""
@@ -1888,6 +1958,9 @@
],
"description": "Symfony Yaml Component",
"homepage": "https://symfony.com",
+ "support": {
+ "source": "https://github.com/symfony/yaml/tree/v4.4.16"
+ },
"funding": [
{
"url": "https://symfony.com/sponsor",
@@ -1902,7 +1975,7 @@
"type": "tidelift"
}
],
- "time": "2020-09-27T03:36:23+00:00"
+ "time": "2020-10-24T11:50:19+00:00"
}
],
"aliases": [],
@@ -1912,5 +1985,5 @@
"prefer-lowest": false,
"platform": [],
"platform-dev": [],
- "plugin-api-version": "1.1.0"
+ "plugin-api-version": "2.0.0"
}
From def07bb1ad2a16178e3915ba3132d30321dc3bd3 Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Mon, 30 Nov 2020 10:52:32 -0500
Subject: [PATCH 050/366] Tests for Miniflux authentication
This appears to match Miniflux's behaviour
---
lib/REST/Miniflux/V1.php | 21 ++++++-----
tests/cases/REST/Miniflux/TestV1.php | 52 ++++++++++++++++++++++++----
tests/phpunit.dist.xml | 1 +
3 files changed, 60 insertions(+), 14 deletions(-)
diff --git a/lib/REST/Miniflux/V1.php b/lib/REST/Miniflux/V1.php
index 45d61914..3860c20b 100644
--- a/lib/REST/Miniflux/V1.php
+++ b/lib/REST/Miniflux/V1.php
@@ -7,12 +7,14 @@ declare(strict_types=1);
namespace JKingWeb\Arsse\REST\Miniflux;
use JKingWeb\Arsse\Arsse;
-use JKingWeb\Arsse\Misc\ValueInfo;
use JKingWeb\Arsse\AbstractException;
+use JKingWeb\Arsse\Db\ExceptionInput;
use JKingWeb\Arsse\Misc\HTTP;
+use JKingWeb\Arsse\Misc\ValueInfo;
use JKingWeb\Arsse\REST\Exception;
use JKingWeb\Arsse\REST\Exception404;
use JKingWeb\Arsse\REST\Exception405;
+use JKingWeb\Arsse\REST\Exception501;
use JKingWeb\Arsse\User\ExceptionConflict as UserException;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\ResponseInterface;
@@ -21,6 +23,7 @@ use Laminas\Diactoros\Response\EmptyResponse;
class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
protected const ACCEPTED_TYPES_OPML = ["text/xml", "application/xml", "text/x-opml"];
protected const ACCEPTED_TYPES_JSON = ["application/json", "text/json"];
+ protected const TOKEN_LENGTH = 32;
public const VERSION = "2.0.25";
protected $paths = [
@@ -50,21 +53,22 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
protected function authenticate(ServerRequestInterface $req): bool {
// first check any tokens; this is what Miniflux does
- foreach ($req->getHeader("X-Auth-Token") as $t) {
- if (strlen($t)) {
- // a non-empty header is authoritative, so we'll stop here one way or the other
+ if ($req->hasHeader("X-Auth-Token")) {
+ $t = $req->getHeader("X-Auth-Token")[0]; // consider only the first token
+ if (strlen($t)) { // and only if it is not blank
try {
$d = Arsse::$db->tokenLookup("miniflux.login", $t);
} catch (ExceptionInput $e) {
return false;
}
- Arsse::$user->id = $d->user;
+ Arsse::$user->id = $d['user'];
return true;
}
}
// next check HTTP auth
if ($req->getAttribute("authenticated", false)) {
Arsse::$user->id = $req->getAttribute("authenticatedUser");
+ return true;
}
return false;
}
@@ -84,11 +88,11 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
$func = $this->chooseCall($target, $method);
if ($func === "opmlImport") {
if (!HTTP::matchType($req, "", ...[self::ACCEPTED_TYPES_OPML])) {
- return new ErrorResponse(415, ['Accept' => implode(", ", self::ACCEPTED_TYPES_OPML)]);
+ return new ErrorResponse("", 415, ['Accept' => implode(", ", self::ACCEPTED_TYPES_OPML)]);
}
$data = (string) $req->getBody();
} elseif ($method === "POST" || $method === "PUT") {
- $data = @json_decode($data, true);
+ $data = @json_decode((string) $req->getBody(), true);
if (json_last_error() !== \JSON_ERROR_NONE) {
// if the body could not be parsed as JSON, return "400 Bad Request"
return new ErrorResponse(["invalidBodyJSON", json_last_error_msg()], 400);
@@ -172,7 +176,8 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
}
public static function tokenGenerate(string $user, string $label): string {
- $t = base64_encode(random_bytes(24));
+ // Miniflux produces tokens in base64url alphabet
+ $t = str_replace(["+", "/"], ["-", "_"], base64_encode(random_bytes(self::TOKEN_LENGTH)));
return Arsse::$db->tokenCreate($user, "miniflux.login", $t, null, $label);
}
diff --git a/tests/cases/REST/Miniflux/TestV1.php b/tests/cases/REST/Miniflux/TestV1.php
index db8c34d9..de35c276 100644
--- a/tests/cases/REST/Miniflux/TestV1.php
+++ b/tests/cases/REST/Miniflux/TestV1.php
@@ -10,13 +10,19 @@ use JKingWeb\Arsse\Arsse;
use JKingWeb\Arsse\User;
use JKingWeb\Arsse\Database;
use JKingWeb\Arsse\Db\Transaction;
+use JKingWeb\Arsse\Db\ExceptionInput;
+use JKingWeb\Arsse\REST\Exception404;
use JKingWeb\Arsse\REST\Miniflux\V1;
+use JKingWeb\Arsse\REST\Miniflux\ErrorResponse;
use Psr\Http\Message\ResponseInterface;
+use Laminas\Diactoros\Response\JsonResponse as Response;
+use Laminas\Diactoros\Response\EmptyResponse;
/** @covers \JKingWeb\Arsse\REST\Miniflux\V1 */
class TestV1 extends \JKingWeb\Arsse\Test\AbstractTest {
protected $h;
protected $transaction;
+ protected $token = "Tk2o9YubmZIL2fm2w8Z4KlDEQJz532fNSOcTG0s2_xc=";
protected function req(string $method, string $target, $data = "", array $headers = [], bool $authenticated = true, bool $body = true): ResponseInterface {
$prefix = "/v1";
@@ -54,13 +60,47 @@ class TestV1 extends \JKingWeb\Arsse\Test\AbstractTest {
}
/** @dataProvider provideAuthResponses */
- public function testAuthenticateAUser(): void {
- $exp = new EmptyResponse(401);
- $this->assertMessage($exp, $this->req("GET", "/", "", [], false));
+ public function testAuthenticateAUser($token, bool $auth, bool $success): void {
+ $exp = new ErrorResponse("401", 401);
+ $user = "john.doe@example.com";
+ if ($token !== null) {
+ $headers = ['X-Auth-Token' => $token];
+ } else {
+ $headers = [];
+ }
+ Arsse::$user->id = null;
+ \Phake::when(Arsse::$db)->tokenLookup->thenThrow(new ExceptionInput("subjectMissing"));
+ \Phake::when(Arsse::$db)->tokenLookup("miniflux.login", $this->token)->thenReturn(['user' => $user]);
+ if ($success) {
+ $this->expectExceptionObject(new Exception404);
+ try {
+ $this->req("GET", "/", "", $headers, $auth);
+ } finally {
+ $this->assertSame($user, Arsse::$user->id);
+ }
+ } else {
+ $this->assertMessage($exp, $this->req("GET", "/", "", $headers, $auth));
+ $this->assertNull(Arsse::$user->id);
+ }
+ }
+
+ public function provideAuthResponses(): iterable {
+ return [
+ [null, false, false],
+ [null, true, true],
+ [$this->token, false, true],
+ [[$this->token, "BOGUS"], false, true],
+ ["", true, true],
+ [["", "BOGUS"], true, true],
+ ["NOT A TOKEN", false, false],
+ ["NOT A TOKEN", true, false],
+ [["BOGUS", $this->token], false, false],
+ [["", $this->token], false, false],
+ ];
}
/** @dataProvider provideInvalidPaths */
- public function testRespondToInvalidPaths($path, $method, $code, $allow = null): void {
+ public function xtestRespondToInvalidPaths($path, $method, $code, $allow = null): void {
$exp = new EmptyResponse($code, $allow ? ['Allow' => $allow] : []);
$this->assertMessage($exp, $this->req($method, $path));
}
@@ -72,7 +112,7 @@ class TestV1 extends \JKingWeb\Arsse\Test\AbstractTest {
];
}
- public function testRespondToInvalidInputTypes(): void {
+ public function xtestRespondToInvalidInputTypes(): void {
$exp = new EmptyResponse(415, ['Accept' => "application/json"]);
$this->assertMessage($exp, $this->req("PUT", "/folders/1", ' ', ['Content-Type' => "application/xml"]));
$exp = new EmptyResponse(400);
@@ -81,7 +121,7 @@ class TestV1 extends \JKingWeb\Arsse\Test\AbstractTest {
}
/** @dataProvider provideOptionsRequests */
- public function testRespondToOptionsRequests(string $url, string $allow, string $accept): void {
+ public function xtestRespondToOptionsRequests(string $url, string $allow, string $accept): void {
$exp = new EmptyResponse(204, [
'Allow' => $allow,
'Accept' => $accept,
diff --git a/tests/phpunit.dist.xml b/tests/phpunit.dist.xml
index a46fe77d..18486652 100644
--- a/tests/phpunit.dist.xml
+++ b/tests/phpunit.dist.xml
@@ -115,6 +115,7 @@
cases/REST/Miniflux/TestErrorResponse.php
cases/REST/Miniflux/TestStatus.php
+ cases/REST/Miniflux/TestV1.php
cases/REST/NextcloudNews/TestVersions.php
From 7fa5523a7d1743a8563e828970950727e2842c8d Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Tue, 1 Dec 2020 11:06:29 -0500
Subject: [PATCH 051/366] Simplify handling of invalid paths and methods
---
lib/REST/Exception404.php | 10 ----------
lib/REST/Exception405.php | 10 ----------
lib/REST/Miniflux/V1.php | 19 ++++++++-----------
lib/REST/NextcloudNews/V1_2.php | 25 +++++++++----------------
tests/cases/REST/Miniflux/TestV1.php | 20 +++++---------------
5 files changed, 22 insertions(+), 62 deletions(-)
delete mode 100644 lib/REST/Exception404.php
delete mode 100644 lib/REST/Exception405.php
diff --git a/lib/REST/Exception404.php b/lib/REST/Exception404.php
deleted file mode 100644
index 8bee1922..00000000
--- a/lib/REST/Exception404.php
+++ /dev/null
@@ -1,10 +0,0 @@
-handleHTTPOptions($target);
}
$func = $this->chooseCall($target, $method);
+ if ($func instanceof ResponseInterface) {
+ return $func;
+ }
if ($func === "opmlImport") {
if (!HTTP::matchType($req, "", ...[self::ACCEPTED_TYPES_OPML])) {
return new ErrorResponse("", 415, ['Accept' => implode(", ", self::ACCEPTED_TYPES_OPML)]);
@@ -149,7 +149,7 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
}
}
- protected function chooseCall(string $url, string $method): string {
+ protected function chooseCall(string $url, string $method) {
// // normalize the URL path: change any IDs to 1 for easier comparison
$url = $this->normalizePathIds($url);
// normalize the HTTP method to uppercase
@@ -160,18 +160,15 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
// if the path is supported, make sure the method is allowed
if (isset($this->paths[$url][$method])) {
// if it is allowed, return the object method to run, assuming the method exists
- if (method_exists($this, $this->paths[$url][$method])) {
- return $this->paths[$url][$method];
- } else {
- throw new Exception501(); // @codeCoverageIgnore
- }
+ assert(method_exists($this, $this->paths[$url][$method]), new \Exception("Method is not implemented"));
+ return $this->paths[$url][$method];
} else {
// otherwise return 405
- throw new Exception405(implode(", ", array_keys($this->paths[$url])));
+ return new EmptyResponse(405, ['Allow' => implode(", ", array_keys($this->paths[$url]))]);
}
} else {
// if the path is not supported, return 404
- throw new Exception404();
+ return new EmptyResponse(404);
}
}
diff --git a/lib/REST/NextcloudNews/V1_2.php b/lib/REST/NextcloudNews/V1_2.php
index 4741e839..7cefe13e 100644
--- a/lib/REST/NextcloudNews/V1_2.php
+++ b/lib/REST/NextcloudNews/V1_2.php
@@ -15,9 +15,6 @@ use JKingWeb\Arsse\Db\ExceptionInput;
use JKingWeb\Arsse\Feed\Exception as FeedException;
use JKingWeb\Arsse\Misc\HTTP;
use JKingWeb\Arsse\REST\Exception;
-use JKingWeb\Arsse\REST\Exception404;
-use JKingWeb\Arsse\REST\Exception405;
-use JKingWeb\Arsse\REST\Exception501;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\ResponseInterface;
use Laminas\Diactoros\Response\JsonResponse as Response;
@@ -110,15 +107,14 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler {
// merge GET and POST data, and normalize it. POST parameters are preferred over GET parameters
$data = $this->normalizeInput(array_merge($req->getQueryParams(), $data), $this->validInput, "unix");
// check to make sure the requested function is implemented
+ $func = $this->chooseCall($target, $req->getMethod());
+ if ($func instanceof ResponseInterface) {
+ return $func;
+ }
// dispatch
try {
- $func = $this->chooseCall($target, $req->getMethod());
$path = explode("/", ltrim($target, "/"));
return $this->$func($path, $data);
- } catch (Exception404 $e) {
- return new EmptyResponse(404);
- } catch (Exception405 $e) {
- return new EmptyResponse(405, ['Allow' => $e->getMessage()]);
// @codeCoverageIgnoreStart
} catch (Exception $e) {
// if there was a REST exception return 400
@@ -141,7 +137,7 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler {
return implode("/", $path);
}
- protected function chooseCall(string $url, string $method): string {
+ protected function chooseCall(string $url, string $method) {
// // normalize the URL path: change any IDs to 1 for easier comparison
$url = $this->normalizePathIds($url);
// normalize the HTTP method to uppercase
@@ -152,18 +148,15 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler {
// if the path is supported, make sure the method is allowed
if (isset($this->paths[$url][$method])) {
// if it is allowed, return the object method to run, assuming the method exists
- if (method_exists($this, $this->paths[$url][$method])) {
- return $this->paths[$url][$method];
- } else {
- throw new Exception501(); // @codeCoverageIgnore
- }
+ assert(method_exists($this, $this->paths[$url][$method]), new \Exception("Method is not implemented"));
+ return $this->paths[$url][$method];
} else {
// otherwise return 405
- throw new Exception405(implode(", ", array_keys($this->paths[$url])));
+ return new EmptyResponse(405, ['Allow' => implode(", ", array_keys($this->paths[$url]))]);
}
} else {
// if the path is not supported, return 404
- throw new Exception404();
+ return new EmptyResponse(404);
}
}
diff --git a/tests/cases/REST/Miniflux/TestV1.php b/tests/cases/REST/Miniflux/TestV1.php
index de35c276..c62f0c03 100644
--- a/tests/cases/REST/Miniflux/TestV1.php
+++ b/tests/cases/REST/Miniflux/TestV1.php
@@ -11,7 +11,6 @@ use JKingWeb\Arsse\User;
use JKingWeb\Arsse\Database;
use JKingWeb\Arsse\Db\Transaction;
use JKingWeb\Arsse\Db\ExceptionInput;
-use JKingWeb\Arsse\REST\Exception404;
use JKingWeb\Arsse\REST\Miniflux\V1;
use JKingWeb\Arsse\REST\Miniflux\ErrorResponse;
use Psr\Http\Message\ResponseInterface;
@@ -61,7 +60,7 @@ class TestV1 extends \JKingWeb\Arsse\Test\AbstractTest {
/** @dataProvider provideAuthResponses */
public function testAuthenticateAUser($token, bool $auth, bool $success): void {
- $exp = new ErrorResponse("401", 401);
+ $exp = $success ? new EmptyResponse(404) : new ErrorResponse("401", 401);
$user = "john.doe@example.com";
if ($token !== null) {
$headers = ['X-Auth-Token' => $token];
@@ -71,17 +70,8 @@ class TestV1 extends \JKingWeb\Arsse\Test\AbstractTest {
Arsse::$user->id = null;
\Phake::when(Arsse::$db)->tokenLookup->thenThrow(new ExceptionInput("subjectMissing"));
\Phake::when(Arsse::$db)->tokenLookup("miniflux.login", $this->token)->thenReturn(['user' => $user]);
- if ($success) {
- $this->expectExceptionObject(new Exception404);
- try {
- $this->req("GET", "/", "", $headers, $auth);
- } finally {
- $this->assertSame($user, Arsse::$user->id);
- }
- } else {
- $this->assertMessage($exp, $this->req("GET", "/", "", $headers, $auth));
- $this->assertNull(Arsse::$user->id);
- }
+ $this->assertMessage($exp, $this->req("GET", "/", "", $headers, $auth));
+ $this->assertSame($success ? $user : null, Arsse::$user->id);
}
public function provideAuthResponses(): iterable {
@@ -100,7 +90,7 @@ class TestV1 extends \JKingWeb\Arsse\Test\AbstractTest {
}
/** @dataProvider provideInvalidPaths */
- public function xtestRespondToInvalidPaths($path, $method, $code, $allow = null): void {
+ public function testRespondToInvalidPaths($path, $method, $code, $allow = null): void {
$exp = new EmptyResponse($code, $allow ? ['Allow' => $allow] : []);
$this->assertMessage($exp, $this->req($method, $path));
}
@@ -108,7 +98,7 @@ class TestV1 extends \JKingWeb\Arsse\Test\AbstractTest {
public function provideInvalidPaths(): array {
return [
["/", "GET", 404],
- ["/version", "POST", 405, "GET"],
+ ["/me", "POST", 405, "GET"],
];
}
From 2a0d6e659996997a6798b96d4f8089eca4d500e1 Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Tue, 1 Dec 2020 12:08:45 -0500
Subject: [PATCH 052/366] OPTIONS tests
---
lib/REST/Miniflux/V1.php | 6 +++---
tests/cases/REST/Miniflux/TestV1.php | 22 +++++++++-------------
2 files changed, 12 insertions(+), 16 deletions(-)
diff --git a/lib/REST/Miniflux/V1.php b/lib/REST/Miniflux/V1.php
index 887d61f7..c2e84dd7 100644
--- a/lib/REST/Miniflux/V1.php
+++ b/lib/REST/Miniflux/V1.php
@@ -18,8 +18,8 @@ use Psr\Http\Message\ResponseInterface;
use Laminas\Diactoros\Response\EmptyResponse;
class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
- protected const ACCEPTED_TYPES_OPML = ["text/xml", "application/xml", "text/x-opml"];
- protected const ACCEPTED_TYPES_JSON = ["application/json", "text/json"];
+ protected const ACCEPTED_TYPES_OPML = ["application/xml", "text/xml", "text/x-opml"];
+ protected const ACCEPTED_TYPES_JSON = ["application/json"];
protected const TOKEN_LENGTH = 32;
public const VERSION = "2.0.25";
@@ -140,7 +140,7 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
array_unshift($allowed, "HEAD");
}
return new EmptyResponse(204, [
- 'Allow' => implode(",", $allowed),
+ 'Allow' => implode(", ", $allowed),
'Accept' => implode(", ", $url === "/import" ? self::ACCEPTED_TYPES_OPML : self::ACCEPTED_TYPES_JSON),
]);
} else {
diff --git a/tests/cases/REST/Miniflux/TestV1.php b/tests/cases/REST/Miniflux/TestV1.php
index c62f0c03..a66a8900 100644
--- a/tests/cases/REST/Miniflux/TestV1.php
+++ b/tests/cases/REST/Miniflux/TestV1.php
@@ -4,7 +4,7 @@
* See LICENSE and AUTHORS files for details */
declare(strict_types=1);
-namespace JKingWeb\Arsse\TestCase\REST\NextcloudNews;
+namespace JKingWeb\Arsse\TestCase\REST\Miniflux;
use JKingWeb\Arsse\Arsse;
use JKingWeb\Arsse\User;
@@ -98,20 +98,14 @@ class TestV1 extends \JKingWeb\Arsse\Test\AbstractTest {
public function provideInvalidPaths(): array {
return [
["/", "GET", 404],
+ ["/", "OPTIONS", 404],
["/me", "POST", 405, "GET"],
+ ["/me/", "GET", 404],
];
}
- public function xtestRespondToInvalidInputTypes(): void {
- $exp = new EmptyResponse(415, ['Accept' => "application/json"]);
- $this->assertMessage($exp, $this->req("PUT", "/folders/1", ' ', ['Content-Type' => "application/xml"]));
- $exp = new EmptyResponse(400);
- $this->assertMessage($exp, $this->req("PUT", "/folders/1", ' '));
- $this->assertMessage($exp, $this->req("PUT", "/folders/1", ' ', ['Content-Type' => null]));
- }
-
/** @dataProvider provideOptionsRequests */
- public function xtestRespondToOptionsRequests(string $url, string $allow, string $accept): void {
+ public function testRespondToOptionsRequests(string $url, string $allow, string $accept): void {
$exp = new EmptyResponse(204, [
'Allow' => $allow,
'Accept' => $accept,
@@ -121,9 +115,11 @@ class TestV1 extends \JKingWeb\Arsse\Test\AbstractTest {
public function provideOptionsRequests(): array {
return [
- ["/feeds", "HEAD,GET,POST", "application/json"],
- ["/feeds/2112", "DELETE", "application/json"],
- ["/user", "HEAD,GET", "application/json"],
+ ["/feeds", "HEAD, GET, POST", "application/json"],
+ ["/feeds/2112", "HEAD, GET, PUT, DELETE", "application/json"],
+ ["/me", "HEAD, GET", "application/json"],
+ ["/users/someone", "HEAD, GET", "application/json"],
+ ["/import", "POST", "application/xml, text/xml, text/x-opml"],
];
}
}
From 669e17a1f67c044a7c6e475f81c6c7c86058b667 Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Tue, 1 Dec 2020 17:12:19 -0500
Subject: [PATCH 053/366] Add ability to discover multiple feeds
---
lib/Feed.php | 11 +++++++++++
tests/cases/Feed/TestFeed.php | 21 +++++++++++++++++++++
tests/docroot/Feed/Discovery/Missing.php | 3 +++
tests/docroot/Feed/Discovery/Valid.php | 1 +
4 files changed, 36 insertions(+)
create mode 100644 tests/docroot/Feed/Discovery/Missing.php
diff --git a/lib/Feed.php b/lib/Feed.php
index dffbccb9..81256a63 100644
--- a/lib/Feed.php
+++ b/lib/Feed.php
@@ -47,6 +47,17 @@ class Feed {
return $out;
}
+ public static function discoverAll(string $url, string $username = '', string $password = ''): array {
+ // fetch the candidate feed
+ $f = self::download($url, "", "", $username, $password);
+ if ($f->reader->detectFormat($f->getContent())) {
+ // if the prospective URL is a feed, use it
+ return [$url];
+ } else {
+ return $f->reader->find($f->getUrl(), $f->getContent());
+ }
+ }
+
public function __construct(int $feedID = null, string $url, string $lastModified = '', string $etag = '', string $username = '', string $password = '', bool $scrape = false) {
// fetch the feed
$this->resource = self::download($url, $lastModified, $etag, $username, $password);
diff --git a/tests/cases/Feed/TestFeed.php b/tests/cases/Feed/TestFeed.php
index a5036d28..f9a422e4 100644
--- a/tests/cases/Feed/TestFeed.php
+++ b/tests/cases/Feed/TestFeed.php
@@ -150,6 +150,27 @@ class TestFeed extends \JKingWeb\Arsse\Test\AbstractTest {
Feed::discover($this->base."Discovery/Invalid");
}
+ public function testDiscoverAMissingFeed(): void {
+ $this->assertException("invalidUrl", "Feed");
+ Feed::discover($this->base."Discovery/Missing");
+ }
+
+ public function testDiscoverMultipleFeedsSuccessfully(): void {
+ $exp1 = [$this->base."Discovery/Feed", $this->base."Discovery/Missing"];
+ $exp2 = [$this->base."Discovery/Feed"];
+ $this->assertSame($exp1, Feed::discoverAll($this->base."Discovery/Valid"));
+ $this->assertSame($exp2, Feed::discoverAll($this->base."Discovery/Feed"));
+ }
+
+ public function testDiscoverMultipleFeedsUnsuccessfully(): void {
+ $this->assertSame([], Feed::discoverAll($this->base."Discovery/Invalid"));
+ }
+
+ public function testDiscoverMultipleMissingFeeds(): void {
+ $this->assertException("invalidUrl", "Feed");
+ Feed::discoverAll($this->base."Discovery/Missing");
+ }
+
public function testParseEntityExpansionAttack(): void {
$this->assertException("xmlEntity", "Feed");
new Feed(null, $this->base."Parsing/XEEAttack");
diff --git a/tests/docroot/Feed/Discovery/Missing.php b/tests/docroot/Feed/Discovery/Missing.php
new file mode 100644
index 00000000..666eb036
--- /dev/null
+++ b/tests/docroot/Feed/Discovery/Missing.php
@@ -0,0 +1,3 @@
+ 404,
+];
diff --git a/tests/docroot/Feed/Discovery/Valid.php b/tests/docroot/Feed/Discovery/Valid.php
index 9f34f716..af7b9c17 100644
--- a/tests/docroot/Feed/Discovery/Valid.php
+++ b/tests/docroot/Feed/Discovery/Valid.php
@@ -4,6 +4,7 @@
Example article
+
MESSAGE_BODY
];
From 94154d43543f4b53414c058a364086ae1c734e69 Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Wed, 2 Dec 2020 18:00:27 -0500
Subject: [PATCH 054/366] Implement Miniflux feed discovery
---
lib/REST/Miniflux/V1.php | 59 +++++++++++++++++++++++++---
lib/REST/NextcloudNews/V1_2.php | 1 -
locale/en.php | 5 +++
tests/cases/REST/Miniflux/TestV1.php | 17 ++++++++
4 files changed, 75 insertions(+), 7 deletions(-)
diff --git a/lib/REST/Miniflux/V1.php b/lib/REST/Miniflux/V1.php
index c2e84dd7..321716da 100644
--- a/lib/REST/Miniflux/V1.php
+++ b/lib/REST/Miniflux/V1.php
@@ -7,21 +7,31 @@ declare(strict_types=1);
namespace JKingWeb\Arsse\REST\Miniflux;
use JKingWeb\Arsse\Arsse;
+use JKingWeb\Arsse\Feed;
+use JKingWeb\Arsse\Feed\Exception as FeedException;
use JKingWeb\Arsse\AbstractException;
use JKingWeb\Arsse\Db\ExceptionInput;
use JKingWeb\Arsse\Misc\HTTP;
-use JKingWeb\Arsse\Misc\ValueInfo;
+use JKingWeb\Arsse\Misc\ValueInfo as V;
use JKingWeb\Arsse\REST\Exception;
use JKingWeb\Arsse\User\ExceptionConflict as UserException;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\ResponseInterface;
use Laminas\Diactoros\Response\EmptyResponse;
+use Laminas\Diactoros\Response\JsonResponse as Response;
class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
+ public const VERSION = "2.0.25";
+
protected const ACCEPTED_TYPES_OPML = ["application/xml", "text/xml", "text/x-opml"];
protected const ACCEPTED_TYPES_JSON = ["application/json"];
protected const TOKEN_LENGTH = 32;
- public const VERSION = "2.0.25";
+ protected const VALID_JSON = [
+ 'url' => "string",
+ 'username' => "string",
+ 'password' => "string",
+ 'user_agent' => "string",
+ ];
protected $paths = [
'/categories' => ['GET' => "getCategories", 'POST' => "createCategory"],
@@ -86,6 +96,8 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
if ($func instanceof ResponseInterface) {
return $func;
}
+ $data = [];
+ $query = [];
if ($func === "opmlImport") {
if (!HTTP::matchType($req, "", ...[self::ACCEPTED_TYPES_OPML])) {
return new ErrorResponse("", 415, ['Accept' => implode(", ", self::ACCEPTED_TYPES_OPML)]);
@@ -97,12 +109,16 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
// if the body could not be parsed as JSON, return "400 Bad Request"
return new ErrorResponse(["invalidBodyJSON", json_last_error_msg()], 400);
}
- } else {
- $data = null;
+ $data = $this->normalizeBody((array) $data);
+ if ($data instanceof ResponseInterface) {
+ return $data;
+ }
+ } elseif ($method === "GET") {
+ $query = $req->getQueryParams();
}
try {
$path = explode("/", ltrim($target, "/"));
- return $this->$func($path, $req->getQueryParams(), $data);
+ return $this->$func($path, $query, $data);
// @codeCoverageIgnoreStart
} catch (Exception $e) {
// if there was a REST exception return 400
@@ -118,7 +134,7 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
$path = explode("/", $url);
// any path components which are database IDs (integers greater than zero) should be replaced with "1", for easier comparison (we don't care about the specific ID)
for ($a = 0; $a < sizeof($path); $a++) {
- if (ValueInfo::id($path[$a])) {
+ if (V::id($path[$a])) {
$path[$a] = "1";
}
}
@@ -172,6 +188,37 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
}
}
+ protected function normalizeBody(array $body) {
+ // Miniflux does not attempt to coerce values into different types
+ foreach (self::VALID_JSON as $k => $t) {
+ if (!isset($body[$k])) {
+ $body[$k] = null;
+ } elseif (gettype($body[$k]) !== $t) {
+ return new ErrorResponse(["invalidInputType", 'field' => $k, 'expected' => $t, 'actual' => gettype($body[$k])]);
+ }
+ }
+ return $body;
+ }
+
+ protected function discoverSubscriptions(array $path, array $query, array $data) {
+ try {
+ $list = Feed::discoverAll((string) $data['url'], (string) $data['username'], (string) $data['password']);
+ } catch (FeedException $e) {
+ $msg = [
+ 10502 => "fetch404",
+ 10506 => "fetch403",
+ 10507 => "fetch401",
+ ][$e->getCode()] ?? "fetchOther";
+ return new ErrorResponse($msg, 500);
+ }
+ $out = [];
+ foreach($list as $url) {
+ // TODO: This needs to be refined once PicoFeed is replaced
+ $out[] = ['title' => "Feed", 'type' => "rss", 'url' => $url];
+ }
+ return new Response($out);
+ }
+
public static function tokenGenerate(string $user, string $label): string {
// Miniflux produces tokens in base64url alphabet
$t = str_replace(["+", "/"], ["-", "_"], base64_encode(random_bytes(self::TOKEN_LENGTH)));
diff --git a/lib/REST/NextcloudNews/V1_2.php b/lib/REST/NextcloudNews/V1_2.php
index 7cefe13e..c73ea8c7 100644
--- a/lib/REST/NextcloudNews/V1_2.php
+++ b/lib/REST/NextcloudNews/V1_2.php
@@ -22,7 +22,6 @@ use Laminas\Diactoros\Response\EmptyResponse;
class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler {
public const VERSION = "11.0.5";
- protected const REALM = "Nextcloud News API v1-2";
protected const ACCEPTED_TYPE = "application/json";
protected $dateFormat = "unix";
diff --git a/locale/en.php b/locale/en.php
index c0dea555..f58d7b49 100644
--- a/locale/en.php
+++ b/locale/en.php
@@ -9,6 +9,11 @@ return [
'API.Miniflux.Error.401' => 'Access Unauthorized',
'API.Miniflux.Error.invalidBodyJSON' => 'Invalid JSON payload: {0}',
+ 'API.Miniflux.Error.invalidInputType' => 'Input key "{field}" of type {actual} was expected as {expected}',
+ 'API.Miniflux.Error.fetch404' => 'Resource not found (404), this feed doesn\'t exists anymore, check the feed URL',
+ 'API.Miniflux.Error.fetch401' => 'You are not authorized to access this resource (invalid username/password)',
+ 'API.Miniflux.Error.fetch403' => 'Unable to fetch this resource (Status Code = 403)',
+ 'API.Miniflux.Error.fetchOther' => 'Unable to fetch this resource',
'API.TTRSS.Category.Uncategorized' => 'Uncategorized',
'API.TTRSS.Category.Special' => 'Special',
diff --git a/tests/cases/REST/Miniflux/TestV1.php b/tests/cases/REST/Miniflux/TestV1.php
index a66a8900..e94d1450 100644
--- a/tests/cases/REST/Miniflux/TestV1.php
+++ b/tests/cases/REST/Miniflux/TestV1.php
@@ -122,4 +122,21 @@ class TestV1 extends \JKingWeb\Arsse\Test\AbstractTest {
["/import", "POST", "application/xml, text/xml, text/x-opml"],
];
}
+
+ public function testRejectBadlyTypedData(): void {
+ $exp = new ErrorResponse(["invalidInputType", 'field' => "url", 'expected' => "string", 'actual' => "integer"], 400);
+ $this->assertMessage($exp, $this->req("POST", "/discover", ['url' => 2112]));
+ }
+
+ public function testDiscoverFeeds(): void {
+ $exp = new Response([
+ ['title' => "Feed", 'type' => "rss", 'url' => "http://localhost:8000/Feed/Discovery/Feed"],
+ ['title' => "Feed", 'type' => "rss", 'url' => "http://localhost:8000/Feed/Discovery/Missing"],
+ ]);
+ $this->assertMessage($exp, $this->req("POST", "/discover", ['url' => "http://localhost:8000/Feed/Discovery/Valid"]));
+ $exp = new Response([]);
+ $this->assertMessage($exp, $this->req("POST", "/discover", ['url' => "http://localhost:8000/Feed/Discovery/Invalid"]));
+ $exp = new ErrorResponse("fetch404", 500);
+ $this->assertMessage($exp, $this->req("POST", "/discover", ['url' => "http://localhost:8000/Feed/Discovery/Missing"]));
+ }
}
From 0f3e0411f0eeafecaf502b0b47e6c87c219b7cc7 Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Thu, 3 Dec 2020 15:15:23 -0500
Subject: [PATCH 055/366] Document some differences frrom Miniflux
---
docs/en/030_Supported_Protocols/005_Miniflux.md | 15 ++++++++++++---
1 file changed, 12 insertions(+), 3 deletions(-)
diff --git a/docs/en/030_Supported_Protocols/005_Miniflux.md b/docs/en/030_Supported_Protocols/005_Miniflux.md
index cf706ba5..04e53e2c 100644
--- a/docs/en/030_Supported_Protocols/005_Miniflux.md
+++ b/docs/en/030_Supported_Protocols/005_Miniflux.md
@@ -13,13 +13,22 @@
API Reference
-The Miniflux protocol is a well-designed protocol supporting a wide variety of operations on newsfeeds, folders (termed "categories"), and articles; it also allows for user administration, and native OPML importing and exporting.
+The Miniflux protocol is a well-designed protocol supporting a wide variety of operations on newsfeeds, folders (termed "categories"), and articles; it also allows for user administration, and native OPML importing and exporting. Architecturally it is similar to the Nextcloud News protocol, but is generally more efficient.
-Architecturally it is similar to the Nextcloud News protocol, but is generally more efficient.
+Miniflux version 2.0.25 is emulated, though not all features are implemented
+
+# Missing features
+
+- JSON Feed format is not suported
+- Various feed-related features are not supported; attempting to use them has no effect
+ - Rewrite rules and scraper rules
+ - Custom User-Agent strings
+ - The `disabled`, `ignore_http_cache`, and `fetch_via_proxy` flags
+ - Changing the URL, username, or password of a feed
# Differences
-TBD
+- Only the URL should be considered reliable in feed discovery results
# Interaction with nested folders
From 978929aabdb9d7c1c1af4e33a6dfab570763895c Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Sat, 5 Dec 2020 11:01:44 -0500
Subject: [PATCH 056/366] WIP redesign of user properties
---
lib/Database.php | 119 +++++++++++++++-------------
lib/User.php | 56 +++++++------
lib/User/Driver.php | 2 +-
sql/MySQL/6.sql | 11 ++-
sql/PostgreSQL/6.sql | 10 ++-
sql/SQLite3/6.sql | 16 +++-
tests/cases/Database/SeriesUser.php | 24 ++++--
7 files changed, 142 insertions(+), 96 deletions(-)
diff --git a/lib/Database.php b/lib/Database.php
index 760a0de4..9135b203 100644
--- a/lib/Database.php
+++ b/lib/Database.php
@@ -11,7 +11,7 @@ use JKingWeb\Arsse\Db\Statement;
use JKingWeb\Arsse\Misc\Query;
use JKingWeb\Arsse\Context\Context;
use JKingWeb\Arsse\Misc\Date;
-use JKingWeb\Arsse\Misc\ValueInfo;
+use JKingWeb\Arsse\Misc\ValueInfo as V;
use JKingWeb\Arsse\Misc\URL;
/** The high-level interface with the database
@@ -149,7 +149,7 @@ class Database {
$count = 0;
$convType = Db\AbstractStatement::TYPE_NORM_MAP[Statement::TYPES[$type]];
foreach ($values as $v) {
- $v = ValueInfo::normalize($v, $convType, null, "sql");
+ $v = V::normalize($v, $convType, null, "sql");
if (is_null($v)) {
// nulls are pointless to have
continue;
@@ -161,7 +161,7 @@ class Database {
$clause[] = $this->db->literalString($v);
}
} else {
- $clause[] = ValueInfo::normalize($v, ValueInfo::T_STRING, null, "sql");
+ $clause[] = V::normalize($v, V::T_STRING, null, "sql");
}
$count++;
}
@@ -299,32 +299,43 @@ class Database {
return true;
}
- public function userPropertiesGet(string $user): array {
- $out = $this->db->prepare("SELECT num, admin, lang, tz, sort_asc from arsse_users where id = ?", "str")->run($user)->getRow();
- if (!$out) {
+ public function userPropertiesGet(string $user, bool $includeLarge = true): array {
+ $meta = $this->db->prepareArray(
+ "SELECT \"key\", value from arsse_user_meta where owner = ? and \"key\" not in ('num', 'admin')
+ union all select 'num', num from arsse_users where id = ?
+ union all select 'admin', admin from arsse_users where id = ?",
+ ["str", "str", "str"]
+ )->run($user)->getRow();
+ if (!$meta) {
throw new User\ExceptionConflict("doesNotExist", ["action" => __FUNCTION__, "user" => $user]);
}
- settype($out['num'], "int");
- settype($out['admin'], "bool");
- settype($out['sort_asc'], "bool");
- return $out;
+ $meta = array_combine(array_column($meta, "key"), array_column($meta, "value"));
+ settype($meta['num'], "integer");
+ return $meta;
}
public function userPropertiesSet(string $user, array $data): bool {
if (!$this->userExists($user)) {
throw new User\ExceptionConflict("doesNotExist", ["action" => __FUNCTION__, "user" => $user]);
}
- $allowed = [
- 'admin' => "strict bool",
- 'lang' => "str",
- 'tz' => "strict str",
- 'sort_asc' => "strict bool",
- ];
- [$setClause, $setTypes, $setValues] = $this->generateSet($data, $allowed);
- if (!$setClause) {
- return false;
+ $update = $this->db->prepare("UPDATE arsse_user_meta set value = ? where owner = ? and \"key\" = ?", "str", "str", "str");
+ $insert = ["INSERT INTO arsse_user_meta values(?, ?, ?)", "str", "strict str", "str"];
+ foreach ($data as $k => $v) {
+ if ($k === "admin") {
+ $this->db->prepare("UPDATE arsse_users SET admin = ? where user = ?", "bool", "str")->run($v, $user);
+ } elseif ($k === "num") {
+ continue;
+ } else {
+ $success = $update->run($v, $user, $k)->changes();
+ if (!$success) {
+ if (!$insert instanceof Db\Statement) {
+ $insert = $this->db->prepare(...$insert);
+ }
+ $insert->run($user, $k, $v);
+ }
+ }
}
- return (bool) $this->db->prepare("UPDATE arsse_users set $setClause where id = ?", $setTypes, "str")->run($setValues, $user)->changes();
+ return true;
}
/** Creates a new session for the given user and returns the session identifier */
@@ -515,7 +526,7 @@ class Database {
* @param integer $id The identifier of the folder to delete
*/
public function folderRemove(string $user, $id): bool {
- if (!ValueInfo::id($id)) {
+ if (!V::id($id)) {
throw new Db\ExceptionInput("typeViolation", ["action" => __FUNCTION__, "field" => "folder", 'type' => "int > 0"]);
}
$changes = $this->db->prepare("DELETE FROM arsse_folders where owner = ? and id = ?", "str", "int")->run($user, $id)->changes();
@@ -527,7 +538,7 @@ class Database {
/** Returns the identifier, name, and parent of the given folder as an associative array */
public function folderPropertiesGet(string $user, $id): array {
- if (!ValueInfo::id($id)) {
+ if (!V::id($id)) {
throw new Db\ExceptionInput("typeViolation", ["action" => __FUNCTION__, "field" => "folder", 'type' => "int > 0"]);
}
$props = $this->db->prepare("SELECT id,name,parent from arsse_folders where owner = ? and id = ?", "str", "int")->run($user, $id)->getRow();
@@ -593,7 +604,7 @@ class Database {
*/
protected function folderValidateId(string $user, $id = null, bool $subject = false): array {
// if the specified ID is not a non-negative integer (or null), this will always fail
- if (!ValueInfo::id($id, true)) {
+ if (!V::id($id, true)) {
throw new Db\ExceptionInput("typeViolation", ["action" => $this->caller(), "field" => "folder", 'type' => "int >= 0"]);
}
// if a null or zero ID is specified this is a no-op
@@ -615,13 +626,13 @@ class Database {
// the root cannot be moved
throw new Db\ExceptionInput("circularDependence", $errData);
}
- $info = ValueInfo::int($parent);
+ $info = V::int($parent);
// the root is always a valid parent
- if ($info & (ValueInfo::NULL | ValueInfo::ZERO)) {
+ if ($info & (V::NULL | V::ZERO)) {
$parent = null;
} else {
// if a negative integer or non-integer is specified this will always fail
- if (!($info & ValueInfo::VALID) || (($info & ValueInfo::NEG))) {
+ if (!($info & V::VALID) || (($info & V::NEG))) {
throw new Db\ExceptionInput("idMissing", $errData);
}
$parent = (int) $parent;
@@ -668,12 +679,12 @@ class Database {
* @param integer|null $parent The parent folder context in which to check for duplication
*/
protected function folderValidateName($name, bool $checkDuplicates = false, $parent = null): bool {
- $info = ValueInfo::str($name);
- if ($info & (ValueInfo::NULL | ValueInfo::EMPTY)) {
+ $info = V::str($name);
+ if ($info & (V::NULL | V::EMPTY)) {
throw new Db\ExceptionInput("missing", ["action" => $this->caller(), "field" => "name"]);
- } elseif ($info & ValueInfo::WHITE) {
+ } elseif ($info & V::WHITE) {
throw new Db\ExceptionInput("whitespace", ["action" => $this->caller(), "field" => "name"]);
- } elseif (!($info & ValueInfo::VALID)) {
+ } elseif (!($info & V::VALID)) {
throw new Db\ExceptionInput("typeViolation", ["action" => $this->caller(), "field" => "name", 'type' => "string"]);
} elseif ($checkDuplicates) {
// make sure that a folder with the same prospective name and parent does not already exist: if the parent is null,
@@ -778,7 +789,7 @@ class Database {
* configurable retention period for newsfeeds
*/
public function subscriptionRemove(string $user, $id): bool {
- if (!ValueInfo::id($id)) {
+ if (!V::id($id)) {
throw new Db\ExceptionInput("typeViolation", ["action" => __FUNCTION__, "field" => "feed", 'type' => "int > 0"]);
}
$changes = $this->db->prepare("DELETE from arsse_subscriptions where owner = ? and id = ?", "str", "int")->run($user, $id)->changes();
@@ -807,7 +818,7 @@ class Database {
* - "unread": The number of unread articles associated with the subscription
*/
public function subscriptionPropertiesGet(string $user, $id): array {
- if (!ValueInfo::id($id)) {
+ if (!V::id($id)) {
throw new Db\ExceptionInput("typeViolation", ["action" => __FUNCTION__, "field" => "feed", 'type' => "int > 0"]);
}
$sub = $this->subscriptionList($user, null, true, (int) $id)->getRow();
@@ -841,12 +852,12 @@ class Database {
if (array_key_exists("title", $data)) {
// if the title is null, this signals intended use of the default title; otherwise make sure it's not effectively an empty string
if (!is_null($data['title'])) {
- $info = ValueInfo::str($data['title']);
- if ($info & ValueInfo::EMPTY) {
+ $info = V::str($data['title']);
+ if ($info & V::EMPTY) {
throw new Db\ExceptionInput("missing", ["action" => __FUNCTION__, "field" => "title"]);
- } elseif ($info & ValueInfo::WHITE) {
+ } elseif ($info & V::WHITE) {
throw new Db\ExceptionInput("whitespace", ["action" => __FUNCTION__, "field" => "title"]);
- } elseif (!($info & ValueInfo::VALID)) {
+ } elseif (!($info & V::VALID)) {
throw new Db\ExceptionInput("typeViolation", ["action" => __FUNCTION__, "field" => "title", 'type' => "string"]);
}
}
@@ -918,7 +929,7 @@ class Database {
if (!$out && $id) {
throw new Db\ExceptionInput("subjectMissing", ["action" => __FUNCTION__, "field" => "feed", 'id' => $id]);
}
- return ValueInfo::normalize($out, ValueInfo::T_DATE | ValueInfo::M_NULL, "sql");
+ return V::normalize($out, V::T_DATE | V::M_NULL, "sql");
}
/** Ensures the specified subscription exists and raises an exception otherwise
@@ -930,7 +941,7 @@ class Database {
* @param boolean $subject Whether the subscription is the subject (true) rather than the object (false) of the operation being performed; this only affects the semantics of the error message if validation fails
*/
protected function subscriptionValidateId(string $user, $id, bool $subject = false): array {
- if (!ValueInfo::id($id)) {
+ if (!V::id($id)) {
throw new Db\ExceptionInput("typeViolation", ["action" => $this->caller(), "field" => "feed", 'type' => "int > 0"]);
}
$out = $this->db->prepare("SELECT id,feed from arsse_subscriptions where id = ? and owner = ?", "int", "str")->run($id, $user)->getRow();
@@ -988,7 +999,7 @@ class Database {
*/
public function feedUpdate($feedID, bool $throwError = false): bool {
// check to make sure the feed exists
- if (!ValueInfo::id($feedID)) {
+ if (!V::id($feedID)) {
throw new Db\ExceptionInput("typeViolation", ["action" => __FUNCTION__, "field" => "feed", 'id' => $feedID, 'type' => "int > 0"]);
}
$f = $this->db->prepare("SELECT url, username, password, modified, etag, err_count, scrape FROM arsse_feeds where id = ?", "int")->run($feedID)->getRow();
@@ -1328,7 +1339,7 @@ class Database {
} else {
// normalize requested output and sorting columns
$norm = function($v) {
- return trim(strtolower(ValueInfo::normalize($v, ValueInfo::T_STRING)));
+ return trim(strtolower(V::normalize($v, V::T_STRING)));
};
$cols = array_map($norm, $cols);
// make an output column list
@@ -1798,7 +1809,7 @@ class Database {
* @param integer $id The identifier of the article to validate
*/
protected function articleValidateId(string $user, $id): array {
- if (!ValueInfo::id($id)) {
+ if (!V::id($id)) {
throw new Db\ExceptionInput("typeViolation", ["action" => $this->caller(), "field" => "article", 'type' => "int > 0"]); // @codeCoverageIgnore
}
$out = $this->db->prepare(
@@ -1825,7 +1836,7 @@ class Database {
* @param integer $id The identifier of the edition to validate
*/
protected function articleValidateEdition(string $user, int $id): array {
- if (!ValueInfo::id($id)) {
+ if (!V::id($id)) {
throw new Db\ExceptionInput("typeViolation", ["action" => $this->caller(), "field" => "edition", 'type' => "int > 0"]); // @codeCoverageIgnore
}
$out = $this->db->prepare(
@@ -2109,10 +2120,10 @@ class Database {
* @param boolean $subject Whether the label is the subject (true) rather than the object (false) of the operation being performed; this only affects the semantics of the error message if validation fails
*/
protected function labelValidateId(string $user, $id, bool $byName, bool $checkDb = true, bool $subject = false): array {
- if (!$byName && !ValueInfo::id($id)) {
+ if (!$byName && !V::id($id)) {
// if we're not referring to a label by name and the ID is invalid, throw an exception
throw new Db\ExceptionInput("typeViolation", ["action" => $this->caller(), "field" => "label", 'type' => "int > 0"]);
- } elseif ($byName && !(ValueInfo::str($id) & ValueInfo::VALID)) {
+ } elseif ($byName && !(V::str($id) & V::VALID)) {
// otherwise if we are referring to a label by name but the ID is not a string, also throw an exception
throw new Db\ExceptionInput("typeViolation", ["action" => $this->caller(), "field" => "label", 'type' => "string"]);
} elseif ($checkDb) {
@@ -2133,12 +2144,12 @@ class Database {
/** Ensures a prospective label name is syntactically valid and raises an exception otherwise */
protected function labelValidateName($name): bool {
- $info = ValueInfo::str($name);
- if ($info & (ValueInfo::NULL | ValueInfo::EMPTY)) {
+ $info = V::str($name);
+ if ($info & (V::NULL | V::EMPTY)) {
throw new Db\ExceptionInput("missing", ["action" => $this->caller(), "field" => "name"]);
- } elseif ($info & ValueInfo::WHITE) {
+ } elseif ($info & V::WHITE) {
throw new Db\ExceptionInput("whitespace", ["action" => $this->caller(), "field" => "name"]);
- } elseif (!($info & ValueInfo::VALID)) {
+ } elseif (!($info & V::VALID)) {
throw new Db\ExceptionInput("typeViolation", ["action" => $this->caller(), "field" => "name", 'type' => "string"]);
} else {
return true;
@@ -2381,10 +2392,10 @@ class Database {
* @param boolean $subject Whether the tag is the subject (true) rather than the object (false) of the operation being performed; this only affects the semantics of the error message if validation fails
*/
protected function tagValidateId(string $user, $id, bool $byName, bool $checkDb = true, bool $subject = false): array {
- if (!$byName && !ValueInfo::id($id)) {
+ if (!$byName && !V::id($id)) {
// if we're not referring to a tag by name and the ID is invalid, throw an exception
throw new Db\ExceptionInput("typeViolation", ["action" => $this->caller(), "field" => "tag", 'type' => "int > 0"]);
- } elseif ($byName && !(ValueInfo::str($id) & ValueInfo::VALID)) {
+ } elseif ($byName && !(V::str($id) & V::VALID)) {
// otherwise if we are referring to a tag by name but the ID is not a string, also throw an exception
throw new Db\ExceptionInput("typeViolation", ["action" => $this->caller(), "field" => "tag", 'type' => "string"]);
} elseif ($checkDb) {
@@ -2405,12 +2416,12 @@ class Database {
/** Ensures a prospective tag name is syntactically valid and raises an exception otherwise */
protected function tagValidateName($name): bool {
- $info = ValueInfo::str($name);
- if ($info & (ValueInfo::NULL | ValueInfo::EMPTY)) {
+ $info = V::str($name);
+ if ($info & (V::NULL | V::EMPTY)) {
throw new Db\ExceptionInput("missing", ["action" => $this->caller(), "field" => "name"]);
- } elseif ($info & ValueInfo::WHITE) {
+ } elseif ($info & V::WHITE) {
throw new Db\ExceptionInput("whitespace", ["action" => $this->caller(), "field" => "name"]);
- } elseif (!($info & ValueInfo::VALID)) {
+ } elseif (!($info & V::VALID)) {
throw new Db\ExceptionInput("typeViolation", ["action" => $this->caller(), "field" => "name", 'type' => "string"]);
} else {
return true;
diff --git a/lib/User.php b/lib/User.php
index e8359bc0..48d7c27d 100644
--- a/lib/User.php
+++ b/lib/User.php
@@ -14,6 +14,18 @@ class User {
public const DRIVER_NAMES = [
'internal' => \JKingWeb\Arsse\User\Internal\Driver::class,
];
+ public const PROPERTIES = [
+ 'admin' => V::T_BOOL,
+ 'lang' => V::T_STRING,
+ 'tz' => V::T_STRING,
+ 'sort_asc' => V::T_BOOL,
+ 'theme' => V::T_STRING,
+ 'page_size' => V::T_INT, // greater than zero
+ 'shortcuts' => V::T_BOOL,
+ 'gestures' => V::T_BOOL,
+ 'stylesheet' => V::T_STRING,
+ 'reading_time' => V::T_BOOL,
+ ];
public $id = null;
@@ -115,48 +127,42 @@ class User {
return (new PassGen)->length(Arsse::$conf->userTempPasswordLength)->get();
}
- public function propertiesGet(string $user): array {
- $extra = $this->u->userPropertiesGet($user);
+ public function propertiesGet(string $user, bool $includeLarge = true): array {
+ $extra = $this->u->userPropertiesGet($user, $includeLarge);
// synchronize the internal database
if (!Arsse::$db->userExists($user)) {
Arsse::$db->userAdd($user, null);
Arsse::$db->userPropertiesSet($user, $extra);
}
// retrieve from the database to get at least the user number, and anything else the driver does not provide
- $out = Arsse::$db->userPropertiesGet($user);
- // layer on the driver's data
- foreach (["tz", "admin", "sort_asc"] as $k) {
+ $meta = Arsse::$db->userPropertiesGet($user);
+ // combine all the data
+ $out = ['num' => $meta['num']];
+ foreach (self::PROPERTIES as $k => $t) {
if (array_key_exists($k, $extra)) {
- $out[$k] = $extra[$k] ?? $out[$k];
+ $v = $extra[$k];
+ } elseif (array_key_exists($k, $meta)) {
+ $v = $meta[$k];
+ } else {
+ $v = null;
}
- }
- // treat language specially since it may legitimately be null
- if (array_key_exists("lang", $extra)) {
- $out['lang'] = $extra['lang'];
+ $out[$k] = V::normalize($v, $t | V::M_NULL);
}
return $out;
}
public function propertiesSet(string $user, array $data): array {
$in = [];
- if (array_key_exists("tz", $data)) {
- if (!is_string($data['tz'])) {
- throw new User\ExceptionInput("invalidTimezone", ['field' => "tz", 'value' => ""]);
- } elseif (!@timezone_open($data['tz'])) {
- throw new User\ExceptionInput("invalidTimezone", ['field' => "tz", 'value' => $data['tz']]);
- }
- $in['tz'] = $data['tz'];
- }
- foreach (["admin", "sort_asc"] as $k) {
+ foreach (self::PROPERTIES as $k => $t) {
if (array_key_exists($k, $data)) {
- if (($v = V::normalize($data[$k], V::T_BOOL | V::M_DROP)) === null) {
- throw new User\ExceptionInput("invalidBoolean", $k);
- }
- $in[$k] = $v;
+ // TODO: handle type mistmatch exception
+ $in[$k] = V::normalize($data[$k], $t | V::M_NULL | V::M_STRICT);
}
}
- if (array_key_exists("lang", $data)) {
- $in['lang'] = V::normalize($data['lang'], V::T_STRING | V::M_NULL);
+ if (isset($in['tz']) && !@timezone_open($in['tz'])) {
+ throw new User\ExceptionInput("invalidTimezone", ['field' => "tz", 'value' => $in['tz']]);
+ } elseif (isset($in['page_size']) && $in['page_size'] < 1) {
+ throw new User\ExceptionInput("invalidNonZeroInteger", ['field' => "page_size"]);
}
$out = $this->u->userPropertiesSet($user, $in);
// synchronize the internal database
diff --git a/lib/User/Driver.php b/lib/User/Driver.php
index 5da6a0ca..e0d949c7 100644
--- a/lib/User/Driver.php
+++ b/lib/User/Driver.php
@@ -65,7 +65,7 @@ interface Driver {
*
* Any other keys will be ignored.
*/
- public function userPropertiesGet(string $user): array;
+ public function userPropertiesGet(string $user, bool $includeLarge = true): array;
/** Sets metadata about a user
*
diff --git a/sql/MySQL/6.sql b/sql/MySQL/6.sql
index 6c652e82..aeb7c126 100644
--- a/sql/MySQL/6.sql
+++ b/sql/MySQL/6.sql
@@ -8,9 +8,6 @@ alter table arsse_tokens add column data longtext default null;
alter table arsse_users add column num bigint unsigned unique;
alter table arsse_users add column admin boolean not null default 0;
-alter table arsse_users add column lang longtext;
-alter table arsse_users add column tz varchar(44) not null default 'Etc/UTC';
-alter table arsse_users add column sort_asc boolean not null default 0;
create temporary table arsse_users_existing(
id text not null,
num serial primary key
@@ -22,6 +19,14 @@ where u.id = n.id;
drop table arsse_users_existing;
alter table arsse_users modify num bigint unsigned not null;
+create table arsse_user_meta(
+ owner varchar(255) not null,
+ "key" varchar(255) not null,
+ value longtext,
+ foreign key(owner) references arsse_users(id) on delete cascade on update cascade,
+ primary key(owner,key)
+);
+
create table arsse_icons(
id serial primary key,
url varchar(767) unique not null,
diff --git a/sql/PostgreSQL/6.sql b/sql/PostgreSQL/6.sql
index f14f8c83..0b405a27 100644
--- a/sql/PostgreSQL/6.sql
+++ b/sql/PostgreSQL/6.sql
@@ -8,9 +8,6 @@ alter table arsse_tokens add column data text default null;
alter table arsse_users add column num bigint unique;
alter table arsse_users add column admin smallint not null default 0;
-alter table arsse_users add column lang text;
-alter table arsse_users add column tz text not null default 'Etc/UTC';
-alter table arsse_users add column sort_asc smallint not null default 0;
create temp table arsse_users_existing(
id text not null,
num bigserial
@@ -23,6 +20,13 @@ where u.id = e.id;
drop table arsse_users_existing;
alter table arsse_users alter column num set not null;
+create table arsse_user_meta(
+ owner text not null references arsse_users(id) on delete cascade on update cascade,
+ key text not null,
+ value text,
+ primary key(owner,key)
+);
+
create table arsse_icons(
id bigserial primary key,
url text unique not null,
diff --git a/sql/SQLite3/6.sql b/sql/SQLite3/6.sql
index 8c6f73f3..5f067229 100644
--- a/sql/SQLite3/6.sql
+++ b/sql/SQLite3/6.sql
@@ -6,7 +6,7 @@
-- This is a speculative addition to support OAuth login in the future
alter table arsse_tokens add column data text default null;
--- Add multiple columns to the users table
+-- Add num and admin columns to the users table
-- In particular this adds a numeric identifier for each user, which Miniflux requires
create table arsse_users_new(
-- users
@@ -14,9 +14,6 @@ create table arsse_users_new(
password text, -- password, salted and hashed; if using external authentication this would be blank
num integer unique not null, -- numeric identfier used by Miniflux
admin boolean not null default 0, -- Whether the user is an administrator
- lang text, -- The user's chosen language code e.g. 'en', 'fr-ca'; null uses the system default
- tz text not null default 'Etc/UTC', -- The user's chosen time zone, in zoneinfo format
- sort_asc boolean not null default 0 -- Whether the user prefers to sort articles in ascending order
) without rowid;
create temp table arsse_users_existing(
id text not null,
@@ -31,6 +28,17 @@ drop table arsse_users;
drop table arsse_users_existing;
alter table arsse_users_new rename to arsse_users;
+-- Add a table for other user metadata
+create table arsse_user_meta(
+ -- Metadata for users
+ -- It is up to individual applications (i.e. the client protocols) to cooperate with names and types
+ owner text not null references arsse_users(id) on delete cascade on update cascade, -- the user to whom the metadata belongs
+ key text not null, -- metadata key
+ value text, -- metadata value
+ primary key(owner,key)
+) without rowid;
+
+
-- Add a separate table for feed icons and replace their URLs in the feeds table with their IDs
create table arsse_icons(
-- Icons associated with feeds
diff --git a/tests/cases/Database/SeriesUser.php b/tests/cases/Database/SeriesUser.php
index 0c13012b..a3d5507c 100644
--- a/tests/cases/Database/SeriesUser.php
+++ b/tests/cases/Database/SeriesUser.php
@@ -17,14 +17,26 @@ trait SeriesUser {
'password' => 'str',
'num' => 'int',
'admin' => 'bool',
- 'lang' => 'str',
- 'tz' => 'str',
- 'sort_asc' => 'bool',
],
'rows' => [
- ["admin@example.net", '$2y$10$PbcG2ZR3Z8TuPzM7aHTF8.v61dtCjzjK78gdZJcp4UePE8T9jEgBW',1, 1, "en", "America/Toronto", 0], // password is hash of "secret"
- ["jane.doe@example.com", "",2, 0, "fr", "Asia/Kuala_Lumpur", 1],
- ["john.doe@example.com", "",3, 0, null, "Etc/UTC", 0],
+ ["admin@example.net", '$2y$10$PbcG2ZR3Z8TuPzM7aHTF8.v61dtCjzjK78gdZJcp4UePE8T9jEgBW', 1, 1], // password is hash of "secret"
+ ["jane.doe@example.com", "", 2, 0],
+ ["john.doe@example.com", "", 3, 0],
+ ],
+ ],
+ 'arsse_user_meta' => [
+ 'columns' => [
+ 'owner' => "str",
+ 'key' => "str",
+ 'value' => "str",
+ ],
+ 'rows' => [
+ ["admin@example.net", "lang", "en"],
+ ["admin@example.net", "tz", "America/Toronto"],
+ ["admin@example.net", "sort", "desc"],
+ ["jane.doe@example.com", "lang", "fr"],
+ ["jane.doe@example.com", "tz", "Asia/Kuala_Lumpur"],
+ ["jane.doe@example.com", "sort", "asc"],
],
],
];
From fcf1260dab61336c7e0a2316e913cf418048997f Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Sat, 5 Dec 2020 22:13:48 -0500
Subject: [PATCH 057/366] Adjust database portion of user property manager
---
lib/Database.php | 13 ++++++++----
lib/User.php | 1 +
lib/User/Internal/Driver.php | 2 +-
sql/SQLite3/6.sql | 2 +-
tests/cases/Database/SeriesUser.php | 31 ++++++++++++++++++-----------
5 files changed, 31 insertions(+), 18 deletions(-)
diff --git a/lib/Database.php b/lib/Database.php
index 9135b203..9c4cf7f0 100644
--- a/lib/Database.php
+++ b/lib/Database.php
@@ -300,12 +300,17 @@ class Database {
}
public function userPropertiesGet(string $user, bool $includeLarge = true): array {
+ $exclude = ["num", "admin"];
+ if (!$includeLarge) {
+ $exclude = array_merge($exclude, User::PROPERTIES_LARGE);
+ }
+ [$inClause, $inTypes, $inValues] = $this->generateIn($exclude, "str");
$meta = $this->db->prepareArray(
- "SELECT \"key\", value from arsse_user_meta where owner = ? and \"key\" not in ('num', 'admin')
+ "SELECT \"key\", value from arsse_user_meta where owner = ? and \"key\" not in ($inClause)
union all select 'num', num from arsse_users where id = ?
union all select 'admin', admin from arsse_users where id = ?",
- ["str", "str", "str"]
- )->run($user)->getRow();
+ ["str", $inTypes, "str", "str"]
+ )->run($user, $inValues, $user, $user)->getAll();
if (!$meta) {
throw new User\ExceptionConflict("doesNotExist", ["action" => __FUNCTION__, "user" => $user]);
}
@@ -322,7 +327,7 @@ class Database {
$insert = ["INSERT INTO arsse_user_meta values(?, ?, ?)", "str", "strict str", "str"];
foreach ($data as $k => $v) {
if ($k === "admin") {
- $this->db->prepare("UPDATE arsse_users SET admin = ? where user = ?", "bool", "str")->run($v, $user);
+ $this->db->prepare("UPDATE arsse_users SET admin = ? where id = ?", "bool", "str")->run($v, $user);
} elseif ($k === "num") {
continue;
} else {
diff --git a/lib/User.php b/lib/User.php
index 48d7c27d..124fd238 100644
--- a/lib/User.php
+++ b/lib/User.php
@@ -26,6 +26,7 @@ class User {
'stylesheet' => V::T_STRING,
'reading_time' => V::T_BOOL,
];
+ public const PROPERTIES_LARGE = ["stylesheet"];
public $id = null;
diff --git a/lib/User/Internal/Driver.php b/lib/User/Internal/Driver.php
index c6d0a989..27486fb1 100644
--- a/lib/User/Internal/Driver.php
+++ b/lib/User/Internal/Driver.php
@@ -71,7 +71,7 @@ class Driver implements \JKingWeb\Arsse\User\Driver {
return Arsse::$db->userExists($user);
}
- public function userPropertiesGet(string $user): array {
+ public function userPropertiesGet(string $user, bool $includeLarge = true): array {
// do nothing: the internal database will retrieve everything for us
if (!$this->userExists($user)) {
throw new ExceptionConflict("doesNotExist", ['action' => "userPasswordUnset", 'user' => $user]);
diff --git a/sql/SQLite3/6.sql b/sql/SQLite3/6.sql
index 5f067229..9e86182d 100644
--- a/sql/SQLite3/6.sql
+++ b/sql/SQLite3/6.sql
@@ -13,7 +13,7 @@ create table arsse_users_new(
id text primary key not null collate nocase, -- user id
password text, -- password, salted and hashed; if using external authentication this would be blank
num integer unique not null, -- numeric identfier used by Miniflux
- admin boolean not null default 0, -- Whether the user is an administrator
+ admin boolean not null default 0 -- Whether the user is an administrator
) without rowid;
create temp table arsse_users_existing(
id text not null,
diff --git a/tests/cases/Database/SeriesUser.php b/tests/cases/Database/SeriesUser.php
index a3d5507c..bf78cf86 100644
--- a/tests/cases/Database/SeriesUser.php
+++ b/tests/cases/Database/SeriesUser.php
@@ -33,10 +33,11 @@ trait SeriesUser {
'rows' => [
["admin@example.net", "lang", "en"],
["admin@example.net", "tz", "America/Toronto"],
- ["admin@example.net", "sort", "desc"],
+ ["admin@example.net", "sort_asc", "0"],
["jane.doe@example.com", "lang", "fr"],
["jane.doe@example.com", "tz", "Asia/Kuala_Lumpur"],
- ["jane.doe@example.com", "sort", "asc"],
+ ["jane.doe@example.com", "sort_asc", "1"],
+ ["john.doe@example.com", "stylesheet", "body {background:lightgray}"],
],
],
];
@@ -118,15 +119,18 @@ trait SeriesUser {
}
/** @dataProvider provideMetaData */
- public function testGetMetadata(string $user, array $exp): void {
- $this->assertSame($exp, Arsse::$db->userPropertiesGet($user));
+ public function testGetMetadata(string $user, bool $includeLarge, array $exp): void {
+ $this->assertSame($exp, Arsse::$db->userPropertiesGet($user, $includeLarge));
}
public function provideMetadata(): iterable {
return [
- ["admin@example.net", ['num' => 1, 'admin' => true, 'lang' => "en", 'tz' => "America/Toronto", 'sort_asc' => false]],
- ["jane.doe@example.com", ['num' => 2, 'admin' => false, 'lang' => "fr", 'tz' => "Asia/Kuala_Lumpur", 'sort_asc' => true]],
- ["john.doe@example.com", ['num' => 3, 'admin' => false, 'lang' => null, 'tz' => "Etc/UTC", 'sort_asc' => false]],
+ ["admin@example.net", true, ['lang' => "en", 'sort_asc' => "0", 'tz' => "America/Toronto", 'num' => 1, 'admin' => '1']],
+ ["jane.doe@example.com", true, ['lang' => "fr", 'sort_asc' => "1", 'tz' => "Asia/Kuala_Lumpur", 'num' => 2, 'admin' => '0']],
+ ["john.doe@example.com", true, ['stylesheet' => "body {background:lightgray}", 'num' => 3, 'admin' => '0']],
+ ["admin@example.net", false, ['lang' => "en", 'sort_asc' => "0", 'tz' => "America/Toronto", 'num' => 1, 'admin' => '1']],
+ ["jane.doe@example.com", false, ['lang' => "fr", 'sort_asc' => "1", 'tz' => "Asia/Kuala_Lumpur", 'num' => 2, 'admin' => '0']],
+ ["john.doe@example.com", false, ['num' => 3, 'admin' => '0']],
];
}
@@ -143,18 +147,21 @@ trait SeriesUser {
'sort_asc' => true,
];
$this->assertTrue(Arsse::$db->userPropertiesSet("john.doe@example.com", $in));
- $state = $this->primeExpectations($this->data, ['arsse_users' => ['id', 'num', 'admin', 'lang', 'tz', 'sort_asc']]);
- $state['arsse_users']['rows'][2] = ["john.doe@example.com", 3, 1, "en-ca", "Atlantic/Reykjavik", 1];
+ $state = $this->primeExpectations($this->data, ['arsse_users' => ['id', 'num', 'admin'], 'arsse_user_meta' => ["owner", "key", "value"]]);
+ $state['arsse_users']['rows'][2][2] = 1;
+ $state['arsse_user_meta']['rows'][] = ["john.doe@example.com", "lang", "en-ca"];
+ $state['arsse_user_meta']['rows'][] = ["john.doe@example.com", "tz", "Atlantic/Reykjavik"];
+ $state['arsse_user_meta']['rows'][] = ["john.doe@example.com", "sort_asc", "1"];
$this->compareExpectations(static::$drv, $state);
}
public function testSetNoMetadata(): void {
$in = [
'num' => 2112,
- 'blah' => "bloo",
+ 'stylesheet' => "body {background:lightgray}",
];
- $this->assertFalse(Arsse::$db->userPropertiesSet("john.doe@example.com", $in));
- $state = $this->primeExpectations($this->data, ['arsse_users' => ['id', 'num', 'admin', 'lang', 'tz', 'sort_asc']]);
+ $this->assertTrue(Arsse::$db->userPropertiesSet("john.doe@example.com", $in));
+ $state = $this->primeExpectations($this->data, ['arsse_users' => ['id', 'num', 'admin'], 'arsse_user_meta' => ["owner", "key", "value"]]);
$this->compareExpectations(static::$drv, $state);
}
From a4312434218f3113ecbb81497eae0aadbb2dc87e Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Sun, 6 Dec 2020 13:17:19 -0500
Subject: [PATCH 058/366] Fixes for MySQL and PostgreSQL
---
lib/Database.php | 21 +++++++++------------
lib/Db/MySQL/Statement.php | 6 +++++-
sql/MySQL/6.sql | 4 ++--
tests/cases/Database/SeriesUser.php | 12 ++++++------
4 files changed, 22 insertions(+), 21 deletions(-)
diff --git a/lib/Database.php b/lib/Database.php
index 9c4cf7f0..f01338a8 100644
--- a/lib/Database.php
+++ b/lib/Database.php
@@ -300,24 +300,21 @@ class Database {
}
public function userPropertiesGet(string $user, bool $includeLarge = true): array {
+ $basic = $this->db->prepare("SELECT num, admin from arsse_users where id = ?", "str")->run($user)->getRow();
+ if (!$basic) {
+ throw new User\ExceptionConflict("doesNotExist", ["action" => __FUNCTION__, "user" => $user]);
+ }
$exclude = ["num", "admin"];
if (!$includeLarge) {
$exclude = array_merge($exclude, User::PROPERTIES_LARGE);
}
[$inClause, $inTypes, $inValues] = $this->generateIn($exclude, "str");
- $meta = $this->db->prepareArray(
- "SELECT \"key\", value from arsse_user_meta where owner = ? and \"key\" not in ($inClause)
- union all select 'num', num from arsse_users where id = ?
- union all select 'admin', admin from arsse_users where id = ?",
- ["str", $inTypes, "str", "str"]
- )->run($user, $inValues, $user, $user)->getAll();
- if (!$meta) {
- throw new User\ExceptionConflict("doesNotExist", ["action" => __FUNCTION__, "user" => $user]);
- }
- $meta = array_combine(array_column($meta, "key"), array_column($meta, "value"));
+ $meta = $this->db->prepare("SELECT \"key\", value from arsse_user_meta where owner = ? and \"key\" not in ($inClause) order by \"key\"", "str", $inTypes)->run($user, $inValues)->getAll();
+ $meta = array_merge($basic, array_combine(array_column($meta, "key"), array_column($meta, "value")));
settype($meta['num'], "integer");
+ settype($meta['admin'], "integer");
return $meta;
- }
+ }
public function userPropertiesSet(string $user, array $data): bool {
if (!$this->userExists($user)) {
@@ -454,7 +451,7 @@ class Database {
/** List tokens associated with a user */
public function tokenList(string $user, string $class): Db\Result {
- return $this->db->prepare("SELECT id,created,expires,data from arsse_tokens where class = ? and user = ? and (expires is null or expires > CURRENT_TIMESTAMP)", "str", "str")->run($class, $user);
+ return $this->db->prepare("SELECT id,created,expires,data from arsse_tokens where class = ? and \"user\" = ? and (expires is null or expires > CURRENT_TIMESTAMP)", "str", "str")->run($class, $user);
}
/** Deletes expires tokens from the database, returning the number of deleted tokens */
diff --git a/lib/Db/MySQL/Statement.php b/lib/Db/MySQL/Statement.php
index 057225a6..aba3542b 100644
--- a/lib/Db/MySQL/Statement.php
+++ b/lib/Db/MySQL/Statement.php
@@ -84,7 +84,11 @@ class Statement extends \JKingWeb\Arsse\Db\AbstractStatement {
}
// create a result-set instance
$r = $this->st->get_result();
- $changes = $this->st->affected_rows;
+ if (preg_match("\d+", mysqli_info($this->db), $m)) {
+ $changes = (int) $m[0];
+ } else {
+ $changes = 0;
+ }
$lastId = $this->st->insert_id;
return new Result($r, [$changes, $lastId], $this);
}
diff --git a/sql/MySQL/6.sql b/sql/MySQL/6.sql
index aeb7c126..23ba5edb 100644
--- a/sql/MySQL/6.sql
+++ b/sql/MySQL/6.sql
@@ -24,8 +24,8 @@ create table arsse_user_meta(
"key" varchar(255) not null,
value longtext,
foreign key(owner) references arsse_users(id) on delete cascade on update cascade,
- primary key(owner,key)
-);
+ primary key(owner,"key")
+) character set utf8mb4 collate utf8mb4_unicode_ci;
create table arsse_icons(
id serial primary key,
diff --git a/tests/cases/Database/SeriesUser.php b/tests/cases/Database/SeriesUser.php
index bf78cf86..9ca140c7 100644
--- a/tests/cases/Database/SeriesUser.php
+++ b/tests/cases/Database/SeriesUser.php
@@ -125,12 +125,12 @@ trait SeriesUser {
public function provideMetadata(): iterable {
return [
- ["admin@example.net", true, ['lang' => "en", 'sort_asc' => "0", 'tz' => "America/Toronto", 'num' => 1, 'admin' => '1']],
- ["jane.doe@example.com", true, ['lang' => "fr", 'sort_asc' => "1", 'tz' => "Asia/Kuala_Lumpur", 'num' => 2, 'admin' => '0']],
- ["john.doe@example.com", true, ['stylesheet' => "body {background:lightgray}", 'num' => 3, 'admin' => '0']],
- ["admin@example.net", false, ['lang' => "en", 'sort_asc' => "0", 'tz' => "America/Toronto", 'num' => 1, 'admin' => '1']],
- ["jane.doe@example.com", false, ['lang' => "fr", 'sort_asc' => "1", 'tz' => "Asia/Kuala_Lumpur", 'num' => 2, 'admin' => '0']],
- ["john.doe@example.com", false, ['num' => 3, 'admin' => '0']],
+ ["admin@example.net", true, ['num' => 1, 'admin' => 1, 'lang' => "en", 'sort_asc' => "0", 'tz' => "America/Toronto"]],
+ ["jane.doe@example.com", true, ['num' => 2, 'admin' => 0, 'lang' => "fr", 'sort_asc' => "1", 'tz' => "Asia/Kuala_Lumpur"]],
+ ["john.doe@example.com", true, ['num' => 3, 'admin' => 0, 'stylesheet' => "body {background:lightgray}"]],
+ ["admin@example.net", false, ['num' => 1, 'admin' => 1, 'lang' => "en", 'sort_asc' => "0", 'tz' => "America/Toronto"]],
+ ["jane.doe@example.com", false, ['num' => 2, 'admin' => 0, 'lang' => "fr", 'sort_asc' => "1", 'tz' => "Asia/Kuala_Lumpur"]],
+ ["john.doe@example.com", false, ['num' => 3, 'admin' => 0]],
];
}
From ce68566fcb8d235e75ad048cb2ee6ab5d400d0a2 Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Sun, 6 Dec 2020 20:27:20 -0500
Subject: [PATCH 059/366] Hopefully fix MySQL
---
lib/Database.php | 2 +-
lib/Db/MySQL/Statement.php | 6 +-----
2 files changed, 2 insertions(+), 6 deletions(-)
diff --git a/lib/Database.php b/lib/Database.php
index f01338a8..ecf1edea 100644
--- a/lib/Database.php
+++ b/lib/Database.php
@@ -321,7 +321,7 @@ class Database {
throw new User\ExceptionConflict("doesNotExist", ["action" => __FUNCTION__, "user" => $user]);
}
$update = $this->db->prepare("UPDATE arsse_user_meta set value = ? where owner = ? and \"key\" = ?", "str", "str", "str");
- $insert = ["INSERT INTO arsse_user_meta values(?, ?, ?)", "str", "strict str", "str"];
+ $insert = ["INSERT INTO arsse_user_meta select ?, ?, ? where not exists(select 1 from arsse_user_meta where owner = ? and \"key\" = ?)", "str", "strict str", "str", "str", "strict str"];
foreach ($data as $k => $v) {
if ($k === "admin") {
$this->db->prepare("UPDATE arsse_users SET admin = ? where id = ?", "bool", "str")->run($v, $user);
diff --git a/lib/Db/MySQL/Statement.php b/lib/Db/MySQL/Statement.php
index aba3542b..057225a6 100644
--- a/lib/Db/MySQL/Statement.php
+++ b/lib/Db/MySQL/Statement.php
@@ -84,11 +84,7 @@ class Statement extends \JKingWeb\Arsse\Db\AbstractStatement {
}
// create a result-set instance
$r = $this->st->get_result();
- if (preg_match("\d+", mysqli_info($this->db), $m)) {
- $changes = (int) $m[0];
- } else {
- $changes = 0;
- }
+ $changes = $this->st->affected_rows;
$lastId = $this->st->insert_id;
return new Result($r, [$changes, $lastId], $this);
}
From e9d449a8ba76373f88c8cb785093683b48b00978 Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Mon, 7 Dec 2020 00:07:10 -0500
Subject: [PATCH 060/366] Fix user manager and tests
---
lib/AbstractException.php | 5 +++--
lib/User.php | 9 ++++++---
locale/en.php | 14 +++++++++++++-
tests/cases/User/TestUser.php | 19 +++++++++++--------
4 files changed, 33 insertions(+), 14 deletions(-)
diff --git a/lib/AbstractException.php b/lib/AbstractException.php
index d26b3cd3..73a17076 100644
--- a/lib/AbstractException.php
+++ b/lib/AbstractException.php
@@ -74,8 +74,9 @@ abstract class AbstractException extends \Exception {
"User/ExceptionConflict.alreadyExists" => 10403,
"User/ExceptionSession.invalid" => 10431,
"User/ExceptionInput.invalidTimezone" => 10441,
- "User/ExceptionInput.invalidBoolean" => 10442,
- "User/ExceptionInput.invalidUsername" => 10443,
+ "User/ExceptionInput.invalidValue" => 10442,
+ "User/ExceptionInput.invalidNonZeroInteger" => 10443,
+ "User/ExceptionInput.invalidUsername" => 10444,
"Feed/Exception.internalError" => 10500,
"Feed/Exception.invalidCertificate" => 10501,
"Feed/Exception.invalidUrl" => 10502,
diff --git a/lib/User.php b/lib/User.php
index 124fd238..bf457a95 100644
--- a/lib/User.php
+++ b/lib/User.php
@@ -136,7 +136,7 @@ class User {
Arsse::$db->userPropertiesSet($user, $extra);
}
// retrieve from the database to get at least the user number, and anything else the driver does not provide
- $meta = Arsse::$db->userPropertiesGet($user);
+ $meta = Arsse::$db->userPropertiesGet($user, $includeLarge);
// combine all the data
$out = ['num' => $meta['num']];
foreach (self::PROPERTIES as $k => $t) {
@@ -156,8 +156,11 @@ class User {
$in = [];
foreach (self::PROPERTIES as $k => $t) {
if (array_key_exists($k, $data)) {
- // TODO: handle type mistmatch exception
- $in[$k] = V::normalize($data[$k], $t | V::M_NULL | V::M_STRICT);
+ try {
+ $in[$k] = V::normalize($data[$k], $t | V::M_NULL | V::M_STRICT);
+ } catch (\JKingWeb\Arsse\ExceptionType $e) {
+ throw new User\ExceptionInput("invalidValue", ['field' => $k, 'type' => $t], $e);
+ }
}
}
if (isset($in['tz']) && !@timezone_open($in['tz'])) {
diff --git a/locale/en.php b/locale/en.php
index f58d7b49..cbf3d793 100644
--- a/locale/en.php
+++ b/locale/en.php
@@ -148,8 +148,20 @@ return [
'Exception.JKingWeb/Arsse/User/Exception.authFailed' => 'Authentication failed',
'Exception.JKingWeb/Arsse/User/ExceptionSession.invalid' => 'Session with ID {0} does not exist',
'Exception.JKingWeb/Arsse/User/ExceptionInput.invalidUsername' => 'User names may not contain the Unicode character {0}',
- 'Exception.JKingWeb/Arsse/User/ExceptionInput.invalidBoolean' => 'User property "{0}" must be a boolean value (true or false)',
+ 'Exception.JKingWeb/Arsse/User/ExceptionInput.invalidValue' =>
+ 'User property "{field}" must be {type, select,
+ 1 {null}
+ 2 {true or false}
+ 3 {an integer}
+ 4 {a real number}
+ 5 {a DateTime object}
+ 6 {a string}
+ 7 {an array}
+ 8 {a DateInterval object}
+ other {another type}
+ }',
'Exception.JKingWeb/Arsse/User/ExceptionInput.invalidTimezone' => 'User property "{field}" must be a valid zoneinfo timezone',
+ 'Exception.JKingWeb/Arsse/User/ExceptionInput.invalidNonZeroInteger' => 'User property "{field}" must be greater than zero',
'Exception.JKingWeb/Arsse/Feed/Exception.internalError' => 'Could not download feed "{url}" because of an internal error which is probably a bug',
'Exception.JKingWeb/Arsse/Feed/Exception.invalidCertificate' => 'Could not download feed "{url}" because its server is serving an invalid SSL certificate',
'Exception.JKingWeb/Arsse/Feed/Exception.invalidUrl' => 'Feed URL "{url}" is invalid',
diff --git a/tests/cases/User/TestUser.php b/tests/cases/User/TestUser.php
index 8863d5fc..84228cac 100644
--- a/tests/cases/User/TestUser.php
+++ b/tests/cases/User/TestUser.php
@@ -331,13 +331,14 @@ class TestUser extends \JKingWeb\Arsse\Test\AbstractTest {
/** @dataProvider provideProperties */
public function testGetThePropertiesOfAUser(array $exp, array $base, array $extra): void {
$user = "john.doe@example.com";
+ $exp = array_merge(['num' => null], array_combine(array_keys(User::PROPERTIES), array_fill(0, sizeof(User::PROPERTIES), null)), $exp);
$u = new User($this->drv);
\Phake::when($this->drv)->userPropertiesGet->thenReturn($extra);
\Phake::when(Arsse::$db)->userPropertiesGet->thenReturn($base);
\Phake::when(Arsse::$db)->userExists->thenReturn(true);
$this->assertSame($exp, $u->propertiesGet($user));
- \Phake::verify($this->drv)->userPropertiesGet($user);
- \Phake::verify(Arsse::$db)->userPropertiesGet($user);
+ \Phake::verify($this->drv)->userPropertiesGet($user, true);
+ \Phake::verify(Arsse::$db)->userPropertiesGet($user, true);
\Phake::verify(Arsse::$db)->userExists($user);
}
@@ -356,14 +357,15 @@ class TestUser extends \JKingWeb\Arsse\Test\AbstractTest {
$extra = ['tz' => "Europe/Istanbul"];
$base = ['num' => 47, 'admin' => false, 'lang' => null, 'tz' => "Etc/UTC", 'sort_asc' => false];
$exp = ['num' => 47, 'admin' => false, 'lang' => null, 'tz' => "Europe/Istanbul", 'sort_asc' => false];
+ $exp = array_merge(['num' => null], array_combine(array_keys(User::PROPERTIES), array_fill(0, sizeof(User::PROPERTIES), null)), $exp);
$u = new User($this->drv);
\Phake::when($this->drv)->userPropertiesGet->thenReturn($extra);
\Phake::when(Arsse::$db)->userPropertiesGet->thenReturn($base);
\Phake::when(Arsse::$db)->userAdd->thenReturn(true);
\Phake::when(Arsse::$db)->userExists->thenReturn(false);
$this->assertSame($exp, $u->propertiesGet($user));
- \Phake::verify($this->drv)->userPropertiesGet($user);
- \Phake::verify(Arsse::$db)->userPropertiesGet($user);
+ \Phake::verify($this->drv)->userPropertiesGet($user, true);
+ \Phake::verify(Arsse::$db)->userPropertiesGet($user, true);
\Phake::verify(Arsse::$db)->userPropertiesSet($user, $extra);
\Phake::verify(Arsse::$db)->userAdd($user, null);
\Phake::verify(Arsse::$db)->userExists($user);
@@ -377,7 +379,7 @@ class TestUser extends \JKingWeb\Arsse\Test\AbstractTest {
try {
$u->propertiesGet($user);
} finally {
- \Phake::verify($this->drv)->userPropertiesGet($user);
+ \Phake::verify($this->drv)->userPropertiesGet($user, true);
}
}
@@ -421,13 +423,14 @@ class TestUser extends \JKingWeb\Arsse\Test\AbstractTest {
public function providePropertyChanges(): iterable {
return [
[['admin' => true], ['admin' => true]],
- [['admin' => 2], new ExceptionInput("invalidBoolean")],
- [['sort_asc' => 2], new ExceptionInput("invalidBoolean")],
+ [['admin' => 2], new ExceptionInput("invalidValue")],
+ [['sort_asc' => 2], new ExceptionInput("invalidValue")],
[['tz' => "Etc/UTC"], ['tz' => "Etc/UTC"]],
[['tz' => "Etc/blah"], new ExceptionInput("invalidTimezone")],
- [['tz' => false], new ExceptionInput("invalidTimezone")],
+ [['tz' => false], new ExceptionInput("invalidValue")],
[['lang' => "en-ca"], ['lang' => "en-CA"]],
[['lang' => null], ['lang' => null]],
+ [['page_size' => 0], new ExceptionInput("invalidNonZeroInteger")]
];
}
From 2eedf7d38c3d00ef2c5402f734f28dab13994ea4 Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Mon, 7 Dec 2020 09:52:42 -0500
Subject: [PATCH 061/366] Finally fix MySQL
---
lib/Database.php | 13 +++++++------
1 file changed, 7 insertions(+), 6 deletions(-)
diff --git a/lib/Database.php b/lib/Database.php
index ecf1edea..f961f7dd 100644
--- a/lib/Database.php
+++ b/lib/Database.php
@@ -320,23 +320,24 @@ class Database {
if (!$this->userExists($user)) {
throw new User\ExceptionConflict("doesNotExist", ["action" => __FUNCTION__, "user" => $user]);
}
+ $tr = $this->begin();
+ $find = $this->db->prepare("SELECT count(*) from arsse_user_meta where owner = ? and \"key\" = ?", "str", "strict str");
$update = $this->db->prepare("UPDATE arsse_user_meta set value = ? where owner = ? and \"key\" = ?", "str", "str", "str");
- $insert = ["INSERT INTO arsse_user_meta select ?, ?, ? where not exists(select 1 from arsse_user_meta where owner = ? and \"key\" = ?)", "str", "strict str", "str", "str", "strict str"];
+ $insert = $this->db->prepare("INSERT INTO arsse_user_meta values(?, ?, ?)", "str", "strict str", "str");
foreach ($data as $k => $v) {
if ($k === "admin") {
$this->db->prepare("UPDATE arsse_users SET admin = ? where id = ?", "bool", "str")->run($v, $user);
} elseif ($k === "num") {
continue;
} else {
- $success = $update->run($v, $user, $k)->changes();
- if (!$success) {
- if (!$insert instanceof Db\Statement) {
- $insert = $this->db->prepare(...$insert);
- }
+ if ($find->run($user, $k)->getValue()) {
+ $update->run($v, $user, $k);
+ } else {
$insert->run($user, $k, $v);
}
}
}
+ $tr->commit();
return true;
}
From d85988f09d2dc8d564ee7b15c5199f5e40c8e18f Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Tue, 8 Dec 2020 15:34:31 -0500
Subject: [PATCH 062/366] Prototype Miniflux user querying
---
lib/Misc/Date.php | 2 +-
lib/REST/Miniflux/V1.php | 94 +++++++++++++++++++++++++++++++++++-----
locale/en.php | 2 +
3 files changed, 85 insertions(+), 13 deletions(-)
diff --git a/lib/Misc/Date.php b/lib/Misc/Date.php
index 6972ea5e..6384f4f3 100644
--- a/lib/Misc/Date.php
+++ b/lib/Misc/Date.php
@@ -6,7 +6,7 @@
declare(strict_types=1);
namespace JKingWeb\Arsse\Misc;
-class Date {
+abstract class Date {
public static function transform($date, string $outFormat = null, string $inFormat = null) {
$date = ValueInfo::normalize($date, ValueInfo::T_DATE, $inFormat);
if (!$date) {
diff --git a/lib/REST/Miniflux/V1.php b/lib/REST/Miniflux/V1.php
index 321716da..2f2685fc 100644
--- a/lib/REST/Miniflux/V1.php
+++ b/lib/REST/Miniflux/V1.php
@@ -12,6 +12,7 @@ use JKingWeb\Arsse\Feed\Exception as FeedException;
use JKingWeb\Arsse\AbstractException;
use JKingWeb\Arsse\Db\ExceptionInput;
use JKingWeb\Arsse\Misc\HTTP;
+use JKingWeb\Arsse\Misc\Date;
use JKingWeb\Arsse\Misc\ValueInfo as V;
use JKingWeb\Arsse\REST\Exception;
use JKingWeb\Arsse\User\ExceptionConflict as UserException;
@@ -32,8 +33,7 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
'password' => "string",
'user_agent' => "string",
];
-
- protected $paths = [
+ protected const PATHS = [
'/categories' => ['GET' => "getCategories", 'POST' => "createCategory"],
'/categories/1' => ['PUT' => "updateCategory", 'DELETE' => "deleteCategory"],
'/discover' => ['POST' => "discoverSubscriptions"],
@@ -42,7 +42,7 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
'/entries/1/bookmark' => ['PUT' => "toggleEntryBookmark"],
'/export' => ['GET' => "opmlExport"],
'/feeds' => ['GET' => "getFeeds", 'POST' => "createFeed"],
- '/feeds/1' => ['GET' => "getFeed", 'PUT' => "updateFeed", 'DELETE' => "removeFeed"],
+ '/feeds/1' => ['GET' => "getFeed", 'PUT' => "updateFeed", 'DELETE' => "removeFeed"],
'/feeds/1/entries/1' => ['GET' => "getFeedEntry"],
'/feeds/1/entries' => ['GET' => "getFeedEntries"],
'/feeds/1/icon' => ['GET' => "getFeedIcon"],
@@ -51,8 +51,16 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
'/import' => ['POST' => "opmlImport"],
'/me' => ['GET' => "getCurrentUser"],
'/users' => ['GET' => "getUsers", 'POST' => "createUser"],
- '/users/1' => ['GET' => "getUser", 'PUT' => "updateUser", 'DELETE' => "deleteUser"],
- '/users/*' => ['GET' => "getUser"],
+ '/users/1' => ['GET' => "getUserByNum", 'PUT' => "updateUserByNum", 'DELETE' => "deleteUser"],
+ '/users/*' => ['GET' => "getUserById"],
+ ];
+ protected const ADMIN_FUNCTIONS = [
+ 'getUsers' => true,
+ 'getUserByNum' => true,
+ 'getUserById' => true,
+ 'createUser' => true,
+ 'updateUserByNum' => true,
+ 'deleteUser' => true,
];
public function __construct() {
@@ -80,6 +88,11 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
return false;
}
+ protected function isAdmin(): bool {
+ return (bool) Arsse::$user->propertiesGet(Arsse::$user->id, false)['admin'];
+ }
+
+
public function dispatch(ServerRequestInterface $req): ResponseInterface {
// try to authenticate
if (!$this->authenticate($req)) {
@@ -96,6 +109,9 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
if ($func instanceof ResponseInterface) {
return $func;
}
+ if ((self::ADMIN_FUNCTIONS[$func] ?? false) && !$this->isAdmin()) {
+ return new ErrorResponse("403", 403);
+ }
$data = [];
$query = [];
if ($func === "opmlImport") {
@@ -148,9 +164,9 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
protected function handleHTTPOptions(string $url): ResponseInterface {
// normalize the URL path: change any IDs to 1 for easier comparison
$url = $this->normalizePathIDs($url);
- if (isset($this->paths[$url])) {
+ if (isset(self::PATHS[$url])) {
// if the path is supported, respond with the allowed methods and other metadata
- $allowed = array_keys($this->paths[$url]);
+ $allowed = array_keys(self::PATHS[$url]);
// if GET is allowed, so is HEAD
if (in_array("GET", $allowed)) {
array_unshift($allowed, "HEAD");
@@ -172,15 +188,15 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
$method = strtoupper($method);
// we now evaluate the supplied URL against every supported path for the selected scope
// the URL is evaluated as an array so as to avoid decoded escapes turning invalid URLs into valid ones
- if (isset($this->paths[$url])) {
+ if (isset(self::PATHS[$url])) {
// if the path is supported, make sure the method is allowed
- if (isset($this->paths[$url][$method])) {
+ if (isset(self::PATHS[$url][$method])) {
// if it is allowed, return the object method to run, assuming the method exists
- assert(method_exists($this, $this->paths[$url][$method]), new \Exception("Method is not implemented"));
- return $this->paths[$url][$method];
+ assert(method_exists($this, self::PATHS[$url][$method]), new \Exception("Method is not implemented"));
+ return self::PATHS[$url][$method];
} else {
// otherwise return 405
- return new EmptyResponse(405, ['Allow' => implode(", ", array_keys($this->paths[$url]))]);
+ return new EmptyResponse(405, ['Allow' => implode(", ", array_keys(self::PATHS[$url]))]);
}
} else {
// if the path is not supported, return 404
@@ -200,6 +216,40 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
return $body;
}
+ protected function listUsers(array $users, bool $reportMissing): array {
+ $out = [];
+ $now = Date::transform("now", "iso8601m");
+ foreach ($users as $u) {
+ try {
+ $info = Arsse::$user->propertiesGet($u, true);
+ } catch (UserException $e) {
+ if ($reportMissing) {
+ throw $e;
+ } else {
+ continue;
+ }
+ }
+ $out[] = [
+ 'id' => $info['num'],
+ 'username' => $u,
+ 'is_admin' => $info['admin'] ?? false,
+ 'theme' => $info['theme'] ?? "light_serif",
+ 'language' => $info['lang'] ?? "en_US",
+ 'timezone' => $info['tz'] ?? "UTC",
+ 'entry_sorting_direction' => ($info['sort_asc'] ?? false) ? "asc" : "desc",
+ 'entries_per_page' => $info['page_size'] ?? 100,
+ 'keyboard_shortcuts' => $info['shortcuts'] ?? true,
+ 'show_reading_time' => $info['reading_time'] ?? true,
+ 'last_login_at' => $now,
+ 'entry_swipe' => $info['swipe'] ?? true,
+ 'extra' => [
+ 'custom_css' => $info['stylesheet'] ?? "",
+ ],
+ ];
+ }
+ return $out;
+ }
+
protected function discoverSubscriptions(array $path, array $query, array $data) {
try {
$list = Feed::discoverAll((string) $data['url'], (string) $data['username'], (string) $data['password']);
@@ -219,6 +269,26 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
return new Response($out);
}
+ protected function getUsers(array $path, array $query, array $data) {
+ return new Response($this->listUsers(Arsse::$user->list(), false));
+ }
+
+ protected function getUserById(array $path, array $query, array $data) {
+ try {
+ return $this->listUsers([$path[1]], true)[0] ?? [];
+ } catch (UserException $e) {
+ return new ErrorResponse("404", 404);
+ }
+ }
+
+ protected function getUserByNum(array $path, array $query, array $data) {
+ return $this->listUsers([Arsse::$user->id], false)[0] ?? [];
+ }
+
+ protected function getCurrentUser(array $path, array $query, array $data) {
+ return new Response($this->listUsers([Arsse::$user->id], false)[0] ?? new \stdClass);
+ }
+
public static function tokenGenerate(string $user, string $label): string {
// Miniflux produces tokens in base64url alphabet
$t = str_replace(["+", "/"], ["-", "_"], base64_encode(random_bytes(self::TOKEN_LENGTH)));
diff --git a/locale/en.php b/locale/en.php
index cbf3d793..75b52e5f 100644
--- a/locale/en.php
+++ b/locale/en.php
@@ -8,6 +8,8 @@ return [
'CLI.Auth.Failure' => 'Authentication failed',
'API.Miniflux.Error.401' => 'Access Unauthorized',
+ 'API.Miniflux.Error.403' => 'Access Forbidden',
+ 'API.Miniflux.Error.404' => 'Resource Not Found',
'API.Miniflux.Error.invalidBodyJSON' => 'Invalid JSON payload: {0}',
'API.Miniflux.Error.invalidInputType' => 'Input key "{field}" of type {actual} was expected as {expected}',
'API.Miniflux.Error.fetch404' => 'Resource not found (404), this feed doesn\'t exists anymore, check the feed URL',
From 5c8365554129fb64761690b2d3a1b076a5270a61 Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Tue, 8 Dec 2020 16:10:23 -0500
Subject: [PATCH 063/366] Add modification timestamp to user metadata
---
lib/Database.php | 4 ++--
sql/MySQL/6.sql | 1 +
sql/PostgreSQL/6.sql | 1 +
sql/SQLite3/6.sql | 1 +
4 files changed, 5 insertions(+), 2 deletions(-)
diff --git a/lib/Database.php b/lib/Database.php
index f961f7dd..b2a7aa38 100644
--- a/lib/Database.php
+++ b/lib/Database.php
@@ -322,8 +322,8 @@ class Database {
}
$tr = $this->begin();
$find = $this->db->prepare("SELECT count(*) from arsse_user_meta where owner = ? and \"key\" = ?", "str", "strict str");
- $update = $this->db->prepare("UPDATE arsse_user_meta set value = ? where owner = ? and \"key\" = ?", "str", "str", "str");
- $insert = $this->db->prepare("INSERT INTO arsse_user_meta values(?, ?, ?)", "str", "strict str", "str");
+ $update = $this->db->prepare("UPDATE arsse_user_meta set value = ?, modified = CURRENT_TIMESTAMP where owner = ? and \"key\" = ?", "str", "str", "str");
+ $insert = $this->db->prepare("INSERT INTO arsse_user_meta(owner, \"key\", value) values(?, ?, ?)", "str", "strict str", "str");
foreach ($data as $k => $v) {
if ($k === "admin") {
$this->db->prepare("UPDATE arsse_users SET admin = ? where id = ?", "bool", "str")->run($v, $user);
diff --git a/sql/MySQL/6.sql b/sql/MySQL/6.sql
index 23ba5edb..36b2d6e4 100644
--- a/sql/MySQL/6.sql
+++ b/sql/MySQL/6.sql
@@ -22,6 +22,7 @@ alter table arsse_users modify num bigint unsigned not null;
create table arsse_user_meta(
owner varchar(255) not null,
"key" varchar(255) not null,
+ modified datetime(0) not null default CURRENT_TIMESTAMP,
value longtext,
foreign key(owner) references arsse_users(id) on delete cascade on update cascade,
primary key(owner,"key")
diff --git a/sql/PostgreSQL/6.sql b/sql/PostgreSQL/6.sql
index 0b405a27..a32eb0c0 100644
--- a/sql/PostgreSQL/6.sql
+++ b/sql/PostgreSQL/6.sql
@@ -23,6 +23,7 @@ alter table arsse_users alter column num set not null;
create table arsse_user_meta(
owner text not null references arsse_users(id) on delete cascade on update cascade,
key text not null,
+ modified timestamp(0) without time zone not null default CURRENT_TIMESTAMP,
value text,
primary key(owner,key)
);
diff --git a/sql/SQLite3/6.sql b/sql/SQLite3/6.sql
index 9e86182d..81e9e821 100644
--- a/sql/SQLite3/6.sql
+++ b/sql/SQLite3/6.sql
@@ -34,6 +34,7 @@ create table arsse_user_meta(
-- It is up to individual applications (i.e. the client protocols) to cooperate with names and types
owner text not null references arsse_users(id) on delete cascade on update cascade, -- the user to whom the metadata belongs
key text not null, -- metadata key
+ modified text not null default CURRENT_TIMESTAMP, -- time at which the metadata was last changed
value text, -- metadata value
primary key(owner,key)
) without rowid;
From 7c841b5fc2423b1ae0e8a65b853cdf6c3bd5dcad Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Wed, 9 Dec 2020 23:39:29 -0500
Subject: [PATCH 064/366] Test for listing users
---
lib/REST/Miniflux/V1.php | 9 +++-
tests/cases/REST/Miniflux/TestV1.php | 63 +++++++++++++++++++++++++++-
2 files changed, 69 insertions(+), 3 deletions(-)
diff --git a/lib/REST/Miniflux/V1.php b/lib/REST/Miniflux/V1.php
index 2f2685fc..5b1f51df 100644
--- a/lib/REST/Miniflux/V1.php
+++ b/lib/REST/Miniflux/V1.php
@@ -66,6 +66,11 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
public function __construct() {
}
+ /** @codeCoverageIgnore */
+ protected function now(): \DateTimeImmutable {
+ return Date::normalize("now");
+ }
+
protected function authenticate(ServerRequestInterface $req): bool {
// first check any tokens; this is what Miniflux does
if ($req->hasHeader("X-Auth-Token")) {
@@ -218,7 +223,7 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
protected function listUsers(array $users, bool $reportMissing): array {
$out = [];
- $now = Date::transform("now", "iso8601m");
+ $now = Date::transform($this->now(), "iso8601m");
foreach ($users as $u) {
try {
$info = Arsse::$user->propertiesGet($u, true);
@@ -275,7 +280,7 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
protected function getUserById(array $path, array $query, array $data) {
try {
- return $this->listUsers([$path[1]], true)[0] ?? [];
+ return new Response($this->listUsers([$path[1]], true)[0] ?? new \stdClass);
} catch (UserException $e) {
return new ErrorResponse("404", 404);
}
diff --git a/tests/cases/REST/Miniflux/TestV1.php b/tests/cases/REST/Miniflux/TestV1.php
index e94d1450..401c1bf9 100644
--- a/tests/cases/REST/Miniflux/TestV1.php
+++ b/tests/cases/REST/Miniflux/TestV1.php
@@ -11,8 +11,10 @@ use JKingWeb\Arsse\User;
use JKingWeb\Arsse\Database;
use JKingWeb\Arsse\Db\Transaction;
use JKingWeb\Arsse\Db\ExceptionInput;
+use JKingWeb\Arsse\Misc\Date;
use JKingWeb\Arsse\REST\Miniflux\V1;
use JKingWeb\Arsse\REST\Miniflux\ErrorResponse;
+use JKingWeb\Arsse\User\ExceptionConflict;
use Psr\Http\Message\ResponseInterface;
use Laminas\Diactoros\Response\JsonResponse as Response;
use Laminas\Diactoros\Response\EmptyResponse;
@@ -41,7 +43,6 @@ class TestV1 extends \JKingWeb\Arsse\Test\AbstractTest {
self::setConf();
// create a mock user manager
Arsse::$user = \Phake::mock(User::class);
- Arsse::$user->id = "john.doe@example.com";
// create a mock database interface
Arsse::$db = \Phake::mock(Database::class);
$this->transaction = \Phake::mock(Transaction::class);
@@ -139,4 +140,64 @@ class TestV1 extends \JKingWeb\Arsse\Test\AbstractTest {
$exp = new ErrorResponse("fetch404", 500);
$this->assertMessage($exp, $this->req("POST", "/discover", ['url' => "http://localhost:8000/Feed/Discovery/Missing"]));
}
+
+ public function testQueryUsers(): void {
+ $now = Date::normalize("now");
+ $u = [
+ ['num'=> 1, 'admin' => true, 'theme' => "custom", 'lang' => "fr_CA", 'tz' => "Asia/Gaza", 'sort_asc' => true, 'page_size' => 200, 'shortcuts' => false, 'reading_time' => false, 'swipe' => false, 'stylesheet' => "p {}"],
+ ['num'=> 2, 'admin' => false, 'theme' => null, 'lang' => null, 'tz' => null, 'sort_asc' => null, 'page_size' => null, 'shortcuts' => null, 'reading_time' => null, 'swipe' => null, 'stylesheet' => null],
+ new ExceptionConflict("doesNotExist"),
+ ];
+ $exp = [
+ [
+ 'id' => 1,
+ 'username' => "john.doe@example.com",
+ 'is_admin' => true,
+ 'theme' => "custom",
+ 'language' => "fr_CA",
+ 'timezone' => "Asia/Gaza",
+ 'entry_sorting_direction' => "asc",
+ 'entries_per_page' => 200,
+ 'keyboard_shortcuts' => false,
+ 'show_reading_time' => false,
+ 'last_login_at' => Date::transform($now, "iso8601m"),
+ 'entry_swipe' => false,
+ 'extra' => [
+ 'custom_css' => "p {}",
+ ],
+ ],
+ [
+ 'id' => 2,
+ 'username' => "jane.doe@example.com",
+ 'is_admin' => false,
+ 'theme' => "light_serif",
+ 'language' => "en_US",
+ 'timezone' => "UTC",
+ 'entry_sorting_direction' => "desc",
+ 'entries_per_page' => 100,
+ 'keyboard_shortcuts' => true,
+ 'show_reading_time' => true,
+ 'last_login_at' => Date::transform($now, "iso8601m"),
+ 'entry_swipe' => true,
+ 'extra' => [
+ 'custom_css' => "",
+ ],
+ ]
+ ];
+ // FIXME: Phake is somehow unable to mock the User class correctly, so we use PHPUnit's mocks instead
+ Arsse::$user = $this->createMock(User::class);
+ Arsse::$user->method("list")->willReturn(["john.doe@example.com", "jane.doe@example.com", "admin@example.com"]);
+ Arsse::$user->method("propertiesGet")->willReturnCallback(function(string $user, bool $includeLerge = true) use ($u) {
+ if ($user === "john.doe@example.com") {
+ return $u[0];
+ } elseif ($user === "jane.doe@example.com") {
+ return $u[1];
+ }else {
+ throw $u[2];
+ }
+ });
+ $this->h = $this->createPartialMock(V1::class, ["now"]);
+ $this->h->method("now")->willReturn($now);
+ $this->assertMessage(new Response($exp), $this->req("GET", "/users"));
+ }
}
From ebdfad535c0abe9704397ec51c666f9d24708f38 Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Thu, 10 Dec 2020 20:08:00 -0500
Subject: [PATCH 065/366] More Miniflux user tests
Also added user lookup functionality
---
lib/Database.php | 9 +++++++++
lib/REST/Miniflux/V1.php | 7 ++++++-
lib/User.php | 5 +++++
tests/cases/Database/SeriesUser.php | 11 +++++++++++
tests/cases/REST/Miniflux/TestV1.php | 22 +++++++++++++++++++++-
tests/cases/User/TestUser.php | 9 +++++++++
6 files changed, 61 insertions(+), 2 deletions(-)
diff --git a/lib/Database.php b/lib/Database.php
index b2a7aa38..799968fb 100644
--- a/lib/Database.php
+++ b/lib/Database.php
@@ -245,6 +245,15 @@ class Database {
return (bool) $this->db->prepare("SELECT count(*) from arsse_users where id = ?", "str")->run($user)->getValue();
}
+ /** Returns the username associated with a user number */
+ public function userLookup(int $num): string {
+ $out = $this->db->prepare("SELECT id from arsse_users where num = ?", "int")->run($num)->getValue();
+ if ($out === null) {
+ throw new User\ExceptionConflict("doesNotExist", ["action" => __FUNCTION__, "user" => $num]);
+ }
+ return $out;
+ }
+
/** Adds a user to the database
*
* @param string $user The user to add
diff --git a/lib/REST/Miniflux/V1.php b/lib/REST/Miniflux/V1.php
index 5b1f51df..1107b600 100644
--- a/lib/REST/Miniflux/V1.php
+++ b/lib/REST/Miniflux/V1.php
@@ -287,7 +287,12 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
}
protected function getUserByNum(array $path, array $query, array $data) {
- return $this->listUsers([Arsse::$user->id], false)[0] ?? [];
+ try {
+ $user = Arsse::$user->lookup((int) $path[1]);
+ return new Response($this->listUsers([$user], true)[0] ?? new \stdClass);
+ } catch (UserException $e) {
+ return new ErrorResponse("404", 404);
+ }
}
protected function getCurrentUser(array $path, array $query, array $data) {
diff --git a/lib/User.php b/lib/User.php
index bf457a95..5ab1b111 100644
--- a/lib/User.php
+++ b/lib/User.php
@@ -62,6 +62,11 @@ class User {
return $this->u->userList();
}
+ public function lookup(int $num): string {
+ // the user number is always stored in the internal database, so the user driver is not called here
+ return Arsse::$db->userLookup($num);
+ }
+
public function add(string $user, ?string $password = null): string {
// ensure the user name does not contain any U+003A COLON characters, as
// this is incompatible with HTTP Basic authentication
diff --git a/tests/cases/Database/SeriesUser.php b/tests/cases/Database/SeriesUser.php
index 9ca140c7..7ed01822 100644
--- a/tests/cases/Database/SeriesUser.php
+++ b/tests/cases/Database/SeriesUser.php
@@ -169,4 +169,15 @@ trait SeriesUser {
$this->assertException("doesNotExist", "User", "ExceptionConflict");
Arsse::$db->userPropertiesSet("john.doe@example.org", ['admin' => true]);
}
+
+ public function testLookUpAUserByNumber(): void {
+ $this->assertSame("admin@example.net", Arsse::$db->userLookup(1));
+ $this->assertSame("jane.doe@example.com", Arsse::$db->userLookup(2));
+ $this->assertSame("john.doe@example.com", Arsse::$db->userLookup(3));
+ }
+
+ public function testLookUpAMissingUserByNumber(): void {
+ $this->assertException("doesNotExist", "User", "ExceptionConflict");
+ Arsse::$db->userLookup(2112);
+ }
}
diff --git a/tests/cases/REST/Miniflux/TestV1.php b/tests/cases/REST/Miniflux/TestV1.php
index 401c1bf9..065b2762 100644
--- a/tests/cases/REST/Miniflux/TestV1.php
+++ b/tests/cases/REST/Miniflux/TestV1.php
@@ -192,12 +192,32 @@ class TestV1 extends \JKingWeb\Arsse\Test\AbstractTest {
return $u[0];
} elseif ($user === "jane.doe@example.com") {
return $u[1];
- }else {
+ } else {
+ throw $u[2];
+ }
+ });
+ Arsse::$user->method("lookup")->willReturnCallback(function(int $num) use ($u) {
+ if ($num === 1) {
+ return "john.doe@example.com";
+ } elseif ($num === 2) {
+ return "jane.doe@example.com";
+ } else {
throw $u[2];
}
});
$this->h = $this->createPartialMock(V1::class, ["now"]);
$this->h->method("now")->willReturn($now);
+ // list all users
$this->assertMessage(new Response($exp), $this->req("GET", "/users"));
+ // fetch John
+ $this->assertMessage(new Response($exp[0]), $this->req("GET", "/me"));
+ $this->assertMessage(new Response($exp[0]), $this->req("GET", "/users/john.doe@example.com"));
+ $this->assertMessage(new Response($exp[0]), $this->req("GET", "/users/1"));
+ // fetch Jane
+ $this->assertMessage(new Response($exp[1]), $this->req("GET", "/users/jane.doe@example.com"));
+ $this->assertMessage(new Response($exp[1]), $this->req("GET", "/users/2"));
+ // fetch no one
+ $this->assertMessage(new ErrorResponse("404", 404), $this->req("GET", "/users/jack.doe@example.com"));
+ $this->assertMessage(new ErrorResponse("404", 404), $this->req("GET", "/users/47"));
}
}
diff --git a/tests/cases/User/TestUser.php b/tests/cases/User/TestUser.php
index 84228cac..b7d1266e 100644
--- a/tests/cases/User/TestUser.php
+++ b/tests/cases/User/TestUser.php
@@ -90,6 +90,15 @@ class TestUser extends \JKingWeb\Arsse\Test\AbstractTest {
\Phake::verify($this->drv)->userList();
}
+ public function testLookUpAUserByNumber(): void {
+ $exp = "john.doe@example.com";
+ $u = new User($this->drv);
+ \Phake::when(Arsse::$db)->userLookup->thenReturn($exp);
+ $this->assertSame($exp, $u->lookup(2112));
+ \Phake::verify(Arsse::$db)->userLookup(2112);
+ }
+
+
public function testAddAUser(): void {
$user = "john.doe@example.com";
$pass = "secret";
From 4b7369838113daade4b0fc871a1a852b4eaff8f2 Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Thu, 10 Dec 2020 23:19:26 -0500
Subject: [PATCH 066/366] More user query tests
---
tests/cases/REST/Miniflux/TestV1.php | 122 +++++++++++++++------------
1 file changed, 68 insertions(+), 54 deletions(-)
diff --git a/tests/cases/REST/Miniflux/TestV1.php b/tests/cases/REST/Miniflux/TestV1.php
index 065b2762..89316153 100644
--- a/tests/cases/REST/Miniflux/TestV1.php
+++ b/tests/cases/REST/Miniflux/TestV1.php
@@ -21,11 +21,49 @@ use Laminas\Diactoros\Response\EmptyResponse;
/** @covers \JKingWeb\Arsse\REST\Miniflux\V1 */
class TestV1 extends \JKingWeb\Arsse\Test\AbstractTest {
+ protected const NOW = "2020-12-09T22:35:10.023419Z";
+
protected $h;
protected $transaction;
protected $token = "Tk2o9YubmZIL2fm2w8Z4KlDEQJz532fNSOcTG0s2_xc=";
+ protected $users = [
+ [
+ 'id' => 1,
+ 'username' => "john.doe@example.com",
+ 'is_admin' => true,
+ 'theme' => "custom",
+ 'language' => "fr_CA",
+ 'timezone' => "Asia/Gaza",
+ 'entry_sorting_direction' => "asc",
+ 'entries_per_page' => 200,
+ 'keyboard_shortcuts' => false,
+ 'show_reading_time' => false,
+ 'last_login_at' => self::NOW,
+ 'entry_swipe' => false,
+ 'extra' => [
+ 'custom_css' => "p {}",
+ ],
+ ],
+ [
+ 'id' => 2,
+ 'username' => "jane.doe@example.com",
+ 'is_admin' => false,
+ 'theme' => "light_serif",
+ 'language' => "en_US",
+ 'timezone' => "UTC",
+ 'entry_sorting_direction' => "desc",
+ 'entries_per_page' => 100,
+ 'keyboard_shortcuts' => true,
+ 'show_reading_time' => true,
+ 'last_login_at' => self::NOW,
+ 'entry_swipe' => true,
+ 'extra' => [
+ 'custom_css' => "",
+ ],
+ ]
+ ];
- protected function req(string $method, string $target, $data = "", array $headers = [], bool $authenticated = true, bool $body = true): ResponseInterface {
+ protected function req(string $method, string $target, $data = "", array $headers = [], ?string $user = "john.doe@example.com", bool $body = true): ResponseInterface {
$prefix = "/v1";
$url = $prefix.$target;
if ($body) {
@@ -34,7 +72,7 @@ class TestV1 extends \JKingWeb\Arsse\Test\AbstractTest {
$params = $data;
$data = [];
}
- $req = $this->serverRequest($method, $url, $prefix, $headers, [], $data, "application/json", $params, $authenticated ? "john.doe@example.com" : "");
+ $req = $this->serverRequest($method, $url, $prefix, $headers, [], $data, "application/json", $params, $user);
return $this->h->dispatch($req);
}
@@ -71,7 +109,7 @@ class TestV1 extends \JKingWeb\Arsse\Test\AbstractTest {
Arsse::$user->id = null;
\Phake::when(Arsse::$db)->tokenLookup->thenThrow(new ExceptionInput("subjectMissing"));
\Phake::when(Arsse::$db)->tokenLookup("miniflux.login", $this->token)->thenReturn(['user' => $user]);
- $this->assertMessage($exp, $this->req("GET", "/", "", $headers, $auth));
+ $this->assertMessage($exp, $this->req("GET", "/", "", $headers, $auth ? "john.doe@example.com" : null));
$this->assertSame($success ? $user : null, Arsse::$user->id);
}
@@ -141,49 +179,14 @@ class TestV1 extends \JKingWeb\Arsse\Test\AbstractTest {
$this->assertMessage($exp, $this->req("POST", "/discover", ['url' => "http://localhost:8000/Feed/Discovery/Missing"]));
}
- public function testQueryUsers(): void {
- $now = Date::normalize("now");
+ /** @dataProvider provideUserQueries */
+ public function testQueryUsers(bool $admin, string $route, ResponseInterface $exp): void {
$u = [
['num'=> 1, 'admin' => true, 'theme' => "custom", 'lang' => "fr_CA", 'tz' => "Asia/Gaza", 'sort_asc' => true, 'page_size' => 200, 'shortcuts' => false, 'reading_time' => false, 'swipe' => false, 'stylesheet' => "p {}"],
['num'=> 2, 'admin' => false, 'theme' => null, 'lang' => null, 'tz' => null, 'sort_asc' => null, 'page_size' => null, 'shortcuts' => null, 'reading_time' => null, 'swipe' => null, 'stylesheet' => null],
new ExceptionConflict("doesNotExist"),
];
- $exp = [
- [
- 'id' => 1,
- 'username' => "john.doe@example.com",
- 'is_admin' => true,
- 'theme' => "custom",
- 'language' => "fr_CA",
- 'timezone' => "Asia/Gaza",
- 'entry_sorting_direction' => "asc",
- 'entries_per_page' => 200,
- 'keyboard_shortcuts' => false,
- 'show_reading_time' => false,
- 'last_login_at' => Date::transform($now, "iso8601m"),
- 'entry_swipe' => false,
- 'extra' => [
- 'custom_css' => "p {}",
- ],
- ],
- [
- 'id' => 2,
- 'username' => "jane.doe@example.com",
- 'is_admin' => false,
- 'theme' => "light_serif",
- 'language' => "en_US",
- 'timezone' => "UTC",
- 'entry_sorting_direction' => "desc",
- 'entries_per_page' => 100,
- 'keyboard_shortcuts' => true,
- 'show_reading_time' => true,
- 'last_login_at' => Date::transform($now, "iso8601m"),
- 'entry_swipe' => true,
- 'extra' => [
- 'custom_css' => "",
- ],
- ]
- ];
+ $user = $admin ? "john.doe@example.com" : "jane.doe@example.com";
// FIXME: Phake is somehow unable to mock the User class correctly, so we use PHPUnit's mocks instead
Arsse::$user = $this->createMock(User::class);
Arsse::$user->method("list")->willReturn(["john.doe@example.com", "jane.doe@example.com", "admin@example.com"]);
@@ -206,18 +209,29 @@ class TestV1 extends \JKingWeb\Arsse\Test\AbstractTest {
}
});
$this->h = $this->createPartialMock(V1::class, ["now"]);
- $this->h->method("now")->willReturn($now);
- // list all users
- $this->assertMessage(new Response($exp), $this->req("GET", "/users"));
- // fetch John
- $this->assertMessage(new Response($exp[0]), $this->req("GET", "/me"));
- $this->assertMessage(new Response($exp[0]), $this->req("GET", "/users/john.doe@example.com"));
- $this->assertMessage(new Response($exp[0]), $this->req("GET", "/users/1"));
- // fetch Jane
- $this->assertMessage(new Response($exp[1]), $this->req("GET", "/users/jane.doe@example.com"));
- $this->assertMessage(new Response($exp[1]), $this->req("GET", "/users/2"));
- // fetch no one
- $this->assertMessage(new ErrorResponse("404", 404), $this->req("GET", "/users/jack.doe@example.com"));
- $this->assertMessage(new ErrorResponse("404", 404), $this->req("GET", "/users/47"));
+ $this->h->method("now")->willReturn(Date::normalize(self::NOW));
+ $this->assertMessage($exp, $this->req("GET", $route, "", [], $user));
+ }
+
+ public function provideUserQueries(): iterable {
+ self::clearData();
+ return [
+ [true, "/users", new Response($this->users)],
+ [true, "/me", new Response($this->users[0])],
+ [true, "/users/john.doe@example.com", new Response($this->users[0])],
+ [true, "/users/1", new Response($this->users[0])],
+ [true, "/users/jane.doe@example.com", new Response($this->users[1])],
+ [true, "/users/2", new Response($this->users[1])],
+ [true, "/users/jack.doe@example.com", new ErrorResponse("404", 404)],
+ [true, "/users/47", new ErrorResponse("404", 404)],
+ [false, "/users", new ErrorResponse("403", 403)],
+ [false, "/me", new Response($this->users[1])],
+ [false, "/users/john.doe@example.com", new ErrorResponse("403", 403)],
+ [false, "/users/1", new ErrorResponse("403", 403)],
+ [false, "/users/jane.doe@example.com", new ErrorResponse("403", 403)],
+ [false, "/users/2", new ErrorResponse("403", 403)],
+ [false, "/users/jack.doe@example.com", new ErrorResponse("403", 403)],
+ [false, "/users/47", new ErrorResponse("403", 403)],
+ ];
}
}
From 2e6c5d2ad23e41269762fd356df9ce1f0740e1f8 Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Fri, 11 Dec 2020 13:31:35 -0500
Subject: [PATCH 067/366] Query Miniflux categories
---
lib/REST/Miniflux/V1.php | 25 +++++++++++++++++++------
lib/User.php | 21 +++++++++++----------
locale/en.php | 13 +++++++------
tests/cases/REST/Miniflux/TestV1.php | 4 ++--
4 files changed, 39 insertions(+), 24 deletions(-)
diff --git a/lib/REST/Miniflux/V1.php b/lib/REST/Miniflux/V1.php
index 1107b600..ec82dcaa 100644
--- a/lib/REST/Miniflux/V1.php
+++ b/lib/REST/Miniflux/V1.php
@@ -128,7 +128,7 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
$data = @json_decode((string) $req->getBody(), true);
if (json_last_error() !== \JSON_ERROR_NONE) {
// if the body could not be parsed as JSON, return "400 Bad Request"
- return new ErrorResponse(["invalidBodyJSON", json_last_error_msg()], 400);
+ return new ErrorResponse(["InvalidBodyJSON", json_last_error_msg()], 400);
}
$data = $this->normalizeBody((array) $data);
if ($data instanceof ResponseInterface) {
@@ -215,7 +215,7 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
if (!isset($body[$k])) {
$body[$k] = null;
} elseif (gettype($body[$k]) !== $t) {
- return new ErrorResponse(["invalidInputType", 'field' => $k, 'expected' => $t, 'actual' => gettype($body[$k])]);
+ return new ErrorResponse(["InvalidInputType", 'field' => $k, 'expected' => $t, 'actual' => gettype($body[$k])]);
}
}
return $body;
@@ -260,10 +260,10 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
$list = Feed::discoverAll((string) $data['url'], (string) $data['username'], (string) $data['password']);
} catch (FeedException $e) {
$msg = [
- 10502 => "fetch404",
- 10506 => "fetch403",
- 10507 => "fetch401",
- ][$e->getCode()] ?? "fetchOther";
+ 10502 => "Fetch404",
+ 10506 => "Fetch403",
+ 10507 => "Fetch401",
+ ][$e->getCode()] ?? "FetchOther";
return new ErrorResponse($msg, 500);
}
$out = [];
@@ -299,6 +299,19 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
return new Response($this->listUsers([Arsse::$user->id], false)[0] ?? new \stdClass);
}
+ protected function getCategories(array $path, array $query, array $data) {
+ $out = [];
+ $meta = Arsse::$user->propertiesGet(Arsse::$user->id, false);
+ // add the root folder as a category
+ $out[] = ['id' => 1, 'title' => $meta['root_folder_name'] ?? Arsse::$lang->msg("API.Miniflux.DefaultCategoryName"), 'user_id' => $meta['num']];
+ // add other top folders as categories
+ foreach (Arsse::$db->folderList(Arsse::$user->id, null, false) as $f) {
+ // always add 1 to the ID since the root folder will always be 1 instead of 0.
+ $out[] = ['id' => $f['id'] + 1, 'title' => $f['name'], 'user_id' => $meta['num']];
+ }
+ return new Response($out);
+ }
+
public static function tokenGenerate(string $user, string $label): string {
// Miniflux produces tokens in base64url alphabet
$t = str_replace(["+", "/"], ["-", "_"], base64_encode(random_bytes(self::TOKEN_LENGTH)));
diff --git a/lib/User.php b/lib/User.php
index 5ab1b111..df9a49d9 100644
--- a/lib/User.php
+++ b/lib/User.php
@@ -15,16 +15,17 @@ class User {
'internal' => \JKingWeb\Arsse\User\Internal\Driver::class,
];
public const PROPERTIES = [
- 'admin' => V::T_BOOL,
- 'lang' => V::T_STRING,
- 'tz' => V::T_STRING,
- 'sort_asc' => V::T_BOOL,
- 'theme' => V::T_STRING,
- 'page_size' => V::T_INT, // greater than zero
- 'shortcuts' => V::T_BOOL,
- 'gestures' => V::T_BOOL,
- 'stylesheet' => V::T_STRING,
- 'reading_time' => V::T_BOOL,
+ 'admin' => V::T_BOOL,
+ 'lang' => V::T_STRING,
+ 'tz' => V::T_STRING,
+ 'sort_asc' => V::T_BOOL,
+ 'theme' => V::T_STRING,
+ 'page_size' => V::T_INT, // greater than zero
+ 'shortcuts' => V::T_BOOL,
+ 'gestures' => V::T_BOOL,
+ 'stylesheet' => V::T_STRING,
+ 'reading_time' => V::T_BOOL,
+ 'root_folder_name' => V::T_STRING,
];
public const PROPERTIES_LARGE = ["stylesheet"];
diff --git a/locale/en.php b/locale/en.php
index 75b52e5f..0e03fd97 100644
--- a/locale/en.php
+++ b/locale/en.php
@@ -7,15 +7,16 @@ return [
'CLI.Auth.Success' => 'Authentication successful',
'CLI.Auth.Failure' => 'Authentication failed',
+ 'API.Miniflux.DefaultCategoryName' => "All",
'API.Miniflux.Error.401' => 'Access Unauthorized',
'API.Miniflux.Error.403' => 'Access Forbidden',
'API.Miniflux.Error.404' => 'Resource Not Found',
- 'API.Miniflux.Error.invalidBodyJSON' => 'Invalid JSON payload: {0}',
- 'API.Miniflux.Error.invalidInputType' => 'Input key "{field}" of type {actual} was expected as {expected}',
- 'API.Miniflux.Error.fetch404' => 'Resource not found (404), this feed doesn\'t exists anymore, check the feed URL',
- 'API.Miniflux.Error.fetch401' => 'You are not authorized to access this resource (invalid username/password)',
- 'API.Miniflux.Error.fetch403' => 'Unable to fetch this resource (Status Code = 403)',
- 'API.Miniflux.Error.fetchOther' => 'Unable to fetch this resource',
+ 'API.Miniflux.Error.InvalidBodyJSON' => 'Invalid JSON payload: {0}',
+ 'API.Miniflux.Error.InvalidInputType' => 'Input key "{field}" of type {actual} was expected as {expected}',
+ 'API.Miniflux.Error.Fetch404' => 'Resource not found (404), this feed doesn\'t exists anymore, check the feed URL',
+ 'API.Miniflux.Error.Fetch401' => 'You are not authorized to access this resource (invalid username/password)',
+ 'API.Miniflux.Error.Fetch403' => 'Unable to fetch this resource (Status Code = 403)',
+ 'API.Miniflux.Error.FetchOther' => 'Unable to fetch this resource',
'API.TTRSS.Category.Uncategorized' => 'Uncategorized',
'API.TTRSS.Category.Special' => 'Special',
diff --git a/tests/cases/REST/Miniflux/TestV1.php b/tests/cases/REST/Miniflux/TestV1.php
index 89316153..4ac27344 100644
--- a/tests/cases/REST/Miniflux/TestV1.php
+++ b/tests/cases/REST/Miniflux/TestV1.php
@@ -163,7 +163,7 @@ class TestV1 extends \JKingWeb\Arsse\Test\AbstractTest {
}
public function testRejectBadlyTypedData(): void {
- $exp = new ErrorResponse(["invalidInputType", 'field' => "url", 'expected' => "string", 'actual' => "integer"], 400);
+ $exp = new ErrorResponse(["InvalidInputType", 'field' => "url", 'expected' => "string", 'actual' => "integer"], 400);
$this->assertMessage($exp, $this->req("POST", "/discover", ['url' => 2112]));
}
@@ -175,7 +175,7 @@ class TestV1 extends \JKingWeb\Arsse\Test\AbstractTest {
$this->assertMessage($exp, $this->req("POST", "/discover", ['url' => "http://localhost:8000/Feed/Discovery/Valid"]));
$exp = new Response([]);
$this->assertMessage($exp, $this->req("POST", "/discover", ['url' => "http://localhost:8000/Feed/Discovery/Invalid"]));
- $exp = new ErrorResponse("fetch404", 500);
+ $exp = new ErrorResponse("Fetch404", 500);
$this->assertMessage($exp, $this->req("POST", "/discover", ['url' => "http://localhost:8000/Feed/Discovery/Missing"]));
}
From 3ebb46f48e79fadfc4e0c70bb76a35c1d5f9aecf Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Fri, 11 Dec 2020 23:47:13 -0500
Subject: [PATCH 068/366] Some work on categories
---
.../030_Supported_Protocols/005_Miniflux.md | 15 ++-
lib/REST/Miniflux/V1.php | 95 ++++++++++++++-----
locale/en.php | 4 +-
.../cases/REST/Miniflux/TestErrorResponse.php | 2 +-
tests/cases/REST/Miniflux/TestV1.php | 53 ++++++++++-
5 files changed, 135 insertions(+), 34 deletions(-)
diff --git a/docs/en/030_Supported_Protocols/005_Miniflux.md b/docs/en/030_Supported_Protocols/005_Miniflux.md
index 04e53e2c..0d5792e9 100644
--- a/docs/en/030_Supported_Protocols/005_Miniflux.md
+++ b/docs/en/030_Supported_Protocols/005_Miniflux.md
@@ -13,9 +13,9 @@
API Reference
-The Miniflux protocol is a well-designed protocol supporting a wide variety of operations on newsfeeds, folders (termed "categories"), and articles; it also allows for user administration, and native OPML importing and exporting. Architecturally it is similar to the Nextcloud News protocol, but is generally more efficient.
+The Miniflux protocol is a fairly well-designed protocol supporting a wide variety of operations on newsfeeds, folders (termed "categories"), and articles; it also allows for user administration, and native OPML importing and exporting. Architecturally it is similar to the Nextcloud News protocol, but is generally more efficient and has more capabilities.
-Miniflux version 2.0.25 is emulated, though not all features are implemented
+Miniflux version 2.0.26 is emulated, though not all features are implemented
# Missing features
@@ -28,8 +28,15 @@ Miniflux version 2.0.25 is emulated, though not all features are implemented
# Differences
+- Various error messages differ due to significant implementation differences
- Only the URL should be considered reliable in feed discovery results
+- The "All" category is treated specially (see below for details)
+- Category names consisting only of whitespace are rejected along with the empty string
-# Interaction with nested folders
+# Special handling of the "All" category
-Tiny Tiny RSS is unique in allowing newsfeeds to be grouped into folders nested to arbitrary depth. When newsfeeds are placed into nested folders, they simply appear in the top-level folder when accessed via the Miniflux protocol. This does not affect OPML exports, where full nesting is preserved.
+Nextcloud News' root folder and Tiny Tiny RSS' "Uncategorized" catgory are mapped to Miniflux's initial "All" category. This Miniflux category can be renamed, but it cannot be deleted. Attempting to do so will delete the child feeds it contains, but not the category itself.
+
+# Interaction with nested categories
+
+Tiny Tiny RSS is unique in allowing newsfeeds to be grouped into categories nested to arbitrary depth. When newsfeeds are placed into nested categories, they simply appear in the top-level category when accessed via the Miniflux protocol. This does not affect OPML exports, where full nesting is preserved.
diff --git a/lib/REST/Miniflux/V1.php b/lib/REST/Miniflux/V1.php
index ec82dcaa..fd6baa9c 100644
--- a/lib/REST/Miniflux/V1.php
+++ b/lib/REST/Miniflux/V1.php
@@ -32,27 +32,31 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
'username' => "string",
'password' => "string",
'user_agent' => "string",
+ 'title' => "string",
];
protected const PATHS = [
- '/categories' => ['GET' => "getCategories", 'POST' => "createCategory"],
- '/categories/1' => ['PUT' => "updateCategory", 'DELETE' => "deleteCategory"],
- '/discover' => ['POST' => "discoverSubscriptions"],
- '/entries' => ['GET' => "getEntries", 'PUT' => "updateEntries"],
- '/entries/1' => ['GET' => "getEntry"],
- '/entries/1/bookmark' => ['PUT' => "toggleEntryBookmark"],
- '/export' => ['GET' => "opmlExport"],
- '/feeds' => ['GET' => "getFeeds", 'POST' => "createFeed"],
- '/feeds/1' => ['GET' => "getFeed", 'PUT' => "updateFeed", 'DELETE' => "removeFeed"],
- '/feeds/1/entries/1' => ['GET' => "getFeedEntry"],
- '/feeds/1/entries' => ['GET' => "getFeedEntries"],
- '/feeds/1/icon' => ['GET' => "getFeedIcon"],
- '/feeds/1/refresh' => ['PUT' => "refreshFeed"],
- '/feeds/refresh' => ['PUT' => "refreshAllFeeds"],
- '/import' => ['POST' => "opmlImport"],
- '/me' => ['GET' => "getCurrentUser"],
- '/users' => ['GET' => "getUsers", 'POST' => "createUser"],
- '/users/1' => ['GET' => "getUserByNum", 'PUT' => "updateUserByNum", 'DELETE' => "deleteUser"],
- '/users/*' => ['GET' => "getUserById"],
+ '/categories' => ['GET' => "getCategories", 'POST' => "createCategory"],
+ '/categories/1' => ['PUT' => "updateCategory", 'DELETE' => "deleteCategory"],
+ '/categories/1/mark-all-as-read' => ['PUT' => "markCategory"],
+ '/discover' => ['POST' => "discoverSubscriptions"],
+ '/entries' => ['GET' => "getEntries", 'PUT' => "updateEntries"],
+ '/entries/1' => ['GET' => "getEntry"],
+ '/entries/1/bookmark' => ['PUT' => "toggleEntryBookmark"],
+ '/export' => ['GET' => "opmlExport"],
+ '/feeds' => ['GET' => "getFeeds", 'POST' => "createFeed"],
+ '/feeds/1' => ['GET' => "getFeed", 'PUT' => "updateFeed", 'DELETE' => "removeFeed"],
+ '/feeds/1/mark-all-as-read' => ['PUT' => "markFeed"],
+ '/feeds/1/entries/1' => ['GET' => "getFeedEntry"],
+ '/feeds/1/entries' => ['GET' => "getFeedEntries"],
+ '/feeds/1/icon' => ['GET' => "getFeedIcon"],
+ '/feeds/1/refresh' => ['PUT' => "refreshFeed"],
+ '/feeds/refresh' => ['PUT' => "refreshAllFeeds"],
+ '/import' => ['POST' => "opmlImport"],
+ '/me' => ['GET' => "getCurrentUser"],
+ '/users' => ['GET' => "getUsers", 'POST' => "createUser"],
+ '/users/1' => ['GET' => "getUserByNum", 'PUT' => "updateUserByNum", 'DELETE' => "deleteUser"],
+ '/users/1/mark-all-as-read' => ['PUT' => "markAll"],
+ '/users/*' => ['GET' => "getUserById"],
];
protected const ADMIN_FUNCTIONS = [
'getUsers' => true,
@@ -85,7 +89,7 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
return true;
}
}
- // next check HTTP auth
+ // next check HTTP auth
if ($req->getAttribute("authenticated", false)) {
Arsse::$user->id = $req->getAttribute("authenticatedUser");
return true;
@@ -255,7 +259,7 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
return $out;
}
- protected function discoverSubscriptions(array $path, array $query, array $data) {
+ protected function discoverSubscriptions(array $path, array $query, array $data): ResponseInterface {
try {
$list = Feed::discoverAll((string) $data['url'], (string) $data['username'], (string) $data['password']);
} catch (FeedException $e) {
@@ -274,11 +278,11 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
return new Response($out);
}
- protected function getUsers(array $path, array $query, array $data) {
+ protected function getUsers(array $path, array $query, array $data): ResponseInterface {
return new Response($this->listUsers(Arsse::$user->list(), false));
}
- protected function getUserById(array $path, array $query, array $data) {
+ protected function getUserById(array $path, array $query, array $data): ResponseInterface {
try {
return new Response($this->listUsers([$path[1]], true)[0] ?? new \stdClass);
} catch (UserException $e) {
@@ -286,7 +290,7 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
}
}
- protected function getUserByNum(array $path, array $query, array $data) {
+ protected function getUserByNum(array $path, array $query, array $data): ResponseInterface {
try {
$user = Arsse::$user->lookup((int) $path[1]);
return new Response($this->listUsers([$user], true)[0] ?? new \stdClass);
@@ -295,11 +299,11 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
}
}
- protected function getCurrentUser(array $path, array $query, array $data) {
+ protected function getCurrentUser(array $path, array $query, array $data): ResponseInterface {
return new Response($this->listUsers([Arsse::$user->id], false)[0] ?? new \stdClass);
}
- protected function getCategories(array $path, array $query, array $data) {
+ protected function getCategories(array $path, array $query, array $data): ResponseInterface {
$out = [];
$meta = Arsse::$user->propertiesGet(Arsse::$user->id, false);
// add the root folder as a category
@@ -312,6 +316,45 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
return new Response($out);
}
+ protected function createCategory(array $path, array $query, array $data): ResponseInterface {
+ try {
+ $id = Arsse::$db->folderAdd(Arsse::$user->id, ['name' => (string) $data['title']]);
+ } catch (ExceptionInput $e) {
+ if ($e->getCode() === 10236) {
+ return new ErrorResponse(["DuplicateCategory", 'title' => $data['title']], 500);
+ } else {
+ return new ErrorResponse(["InvalidCategory", 'title' => $data['title']], 500);
+ }
+ }
+ $meta = Arsse::$user->propertiesGet(Arsse::$user->id, false);
+ return new Response(['id' => $id + 1, 'title' => $data['title'], 'user_id' => $meta['num']]);
+ }
+
+ protected function updateCategory(array $path, array $query, array $data): ResponseInterface {
+ $folder = $path[1] - 1;
+ $title = $data['title'] ?? "";
+ try {
+ if ($folder === 0) {
+ if (!strlen(trim($title))) {
+ throw new ExceptionInput("whitespace");
+ }
+ $title = Arsse::$user->propertiesSet(Arsse::$user->id, ['root_folder_name' => $title])['root_folder_name'];
+ } else {
+ Arsse::$db->folderPropertiesSet(Arsse::$user->id, $folder, ['name' => $title]);
+ }
+ } catch (ExceptionInput $e) {
+ if ($e->getCode() === 10236) {
+ return new ErrorResponse(["DuplicateCategory", 'title' => $title], 500);
+ } elseif ($e->getCode === 10239) {
+ return new ErrorResponse("404", 404);
+ } else {
+ return new ErrorResponse(["InvalidCategory", 'title' => $title], 500);
+ }
+ }
+ $meta = Arsse::$user->propertiesGet(Arsse::$user->id, false);
+ return new Response(['id' => (int) $path[1], 'title' => $title, 'user_id' => $meta['num']]);
+ }
+
public static function tokenGenerate(string $user, string $label): string {
// Miniflux produces tokens in base64url alphabet
$t = str_replace(["+", "/"], ["-", "_"], base64_encode(random_bytes(self::TOKEN_LENGTH)));
diff --git a/locale/en.php b/locale/en.php
index 0e03fd97..acdaa601 100644
--- a/locale/en.php
+++ b/locale/en.php
@@ -17,7 +17,9 @@ return [
'API.Miniflux.Error.Fetch401' => 'You are not authorized to access this resource (invalid username/password)',
'API.Miniflux.Error.Fetch403' => 'Unable to fetch this resource (Status Code = 403)',
'API.Miniflux.Error.FetchOther' => 'Unable to fetch this resource',
-
+ 'API.Miniflux.Error.DuplicateCategory' => 'Category "{title}" already exists',
+ 'API.Miniflux.Error.InvalidCategory' => 'Invalid category title "{title}"',
+
'API.TTRSS.Category.Uncategorized' => 'Uncategorized',
'API.TTRSS.Category.Special' => 'Special',
'API.TTRSS.Category.Labels' => 'Labels',
diff --git a/tests/cases/REST/Miniflux/TestErrorResponse.php b/tests/cases/REST/Miniflux/TestErrorResponse.php
index 23d6e286..5852b4d0 100644
--- a/tests/cases/REST/Miniflux/TestErrorResponse.php
+++ b/tests/cases/REST/Miniflux/TestErrorResponse.php
@@ -16,7 +16,7 @@ class TestErrorResponse extends \JKingWeb\Arsse\Test\AbstractTest {
}
public function testCreateVariableResponse(): void {
- $act = new ErrorResponse(["invalidBodyJSON", "Doh!"], 401);
+ $act = new ErrorResponse(["InvalidBodyJSON", "Doh!"], 401);
$this->assertSame('{"error_message":"Invalid JSON payload: Doh!"}', (string) $act->getBody());
}
}
diff --git a/tests/cases/REST/Miniflux/TestV1.php b/tests/cases/REST/Miniflux/TestV1.php
index 4ac27344..02599480 100644
--- a/tests/cases/REST/Miniflux/TestV1.php
+++ b/tests/cases/REST/Miniflux/TestV1.php
@@ -18,6 +18,7 @@ use JKingWeb\Arsse\User\ExceptionConflict;
use Psr\Http\Message\ResponseInterface;
use Laminas\Diactoros\Response\JsonResponse as Response;
use Laminas\Diactoros\Response\EmptyResponse;
+use JKingWeb\Arsse\Test\Result;
/** @covers \JKingWeb\Arsse\REST\Miniflux\V1 */
class TestV1 extends \JKingWeb\Arsse\Test\AbstractTest {
@@ -79,8 +80,9 @@ class TestV1 extends \JKingWeb\Arsse\Test\AbstractTest {
public function setUp(): void {
self::clearData();
self::setConf();
- // create a mock user manager
- Arsse::$user = \Phake::mock(User::class);
+ // create a mock user manager; we use a PHPUnitmock because Phake for reasons unknown is unable to mock the User class correctly, sometimes
+ Arsse::$user = $this->createMock(User::class);
+ Arsse::$user->method("propertiesGet")->willReturn(['num' => 42, 'admin' => false, 'root_folder_name' => null]);
// create a mock database interface
Arsse::$db = \Phake::mock(Database::class);
$this->transaction = \Phake::mock(Transaction::class);
@@ -234,4 +236,51 @@ class TestV1 extends \JKingWeb\Arsse\Test\AbstractTest {
[false, "/users/47", new ErrorResponse("403", 403)],
];
}
+
+ public function testListCategories(): void {
+ \Phake::when(Arsse::$db)->folderList->thenReturn(new Result($this->v([
+ ['id' => 1, 'name' => "Science"],
+ ['id' => 20, 'name' => "Technology"],
+ ])));
+ $exp = new Response([
+ ['id' => 1, 'title' => "All", 'user_id' => 42],
+ ['id' => 2, 'title' => "Science", 'user_id' => 42],
+ ['id' => 21, 'title' => "Technology", 'user_id' => 42],
+ ]);
+ $this->assertMessage($exp, $this->req("GET", "/categories"));
+ \Phake::verify(Arsse::$db)->folderList("john.doe@example.com", null, false);
+ // run test again with a renamed root folder
+ Arsse::$user = $this->createMock(User::class);
+ Arsse::$user->method("propertiesGet")->willReturn(['num' => 47, 'admin' => false, 'root_folder_name' => "Uncategorized"]);
+ $exp = new Response([
+ ['id' => 1, 'title' => "Uncategorized", 'user_id' => 47],
+ ['id' => 2, 'title' => "Science", 'user_id' => 47],
+ ['id' => 21, 'title' => "Technology", 'user_id' => 47],
+ ]);
+ $this->assertMessage($exp, $this->req("GET", "/categories"));
+ }
+
+ /** @dataProvider provideCategoryAdditions */
+ public function testAddACategory($title, ResponseInterface $exp): void {
+ if (!strlen((string) $title)) {
+ \Phake::when(Arsse::$db)->folderAdd->thenThrow(new ExceptionInput("missing"));
+ } elseif (!strlen(trim((string) $title))) {
+ \Phake::when(Arsse::$db)->folderAdd->thenThrow(new ExceptionInput("whitespace"));
+ } elseif ($title === "Duplicate") {
+ \Phake::when(Arsse::$db)->folderAdd->thenThrow(new ExceptionInput("constraintViolation"));
+ } else {
+ \Phake::when(Arsse::$db)->folderAdd->thenReturn(2111);
+ }
+ $this->assertMessage($exp, $this->req("POST", "/categories", ['title' => $title]));
+ }
+
+ public function provideCategoryAdditions(): iterable {
+ return [
+ ["New", new Response(['id' => 2112, 'title' => "New", 'user_id' => 42])],
+ ["Duplicate", new ErrorResponse(["DuplicateCategory", 'title' => "Duplicate"], 500)],
+ ["", new ErrorResponse(["InvalidCategory", 'title' => ""], 500)],
+ [" ", new ErrorResponse(["InvalidCategory", 'title' => " "], 500)],
+ [false, new ErrorResponse(["InvalidInputType", 'field' => "title", 'actual' => "boolean", 'expected' => "string"], 400)],
+ ];
+ }
}
From eb079166de569b0f91c8491985a2840c276083af Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Sun, 13 Dec 2020 12:56:57 -0500
Subject: [PATCH 069/366] Tests for category renaming
---
.../030_Supported_Protocols/005_Miniflux.md | 1 +
lib/REST/Miniflux/V1.php | 4 +-
tests/cases/REST/Miniflux/TestV1.php | 40 +++++++++++++++++++
3 files changed, 44 insertions(+), 1 deletion(-)
diff --git a/docs/en/030_Supported_Protocols/005_Miniflux.md b/docs/en/030_Supported_Protocols/005_Miniflux.md
index 0d5792e9..84e7d283 100644
--- a/docs/en/030_Supported_Protocols/005_Miniflux.md
+++ b/docs/en/030_Supported_Protocols/005_Miniflux.md
@@ -29,6 +29,7 @@ Miniflux version 2.0.26 is emulated, though not all features are implemented
# Differences
- Various error messages differ due to significant implementation differences
+- `PUT` requests which return a body respond with `200 OK` rather than `201 Created`
- Only the URL should be considered reliable in feed discovery results
- The "All" category is treated specially (see below for details)
- Category names consisting only of whitespace are rejected along with the empty string
diff --git a/lib/REST/Miniflux/V1.php b/lib/REST/Miniflux/V1.php
index fd6baa9c..d84a7e9b 100644
--- a/lib/REST/Miniflux/V1.php
+++ b/lib/REST/Miniflux/V1.php
@@ -331,10 +331,12 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
}
protected function updateCategory(array $path, array $query, array $data): ResponseInterface {
+ // category IDs in Miniflux are always greater than 1; we have folder 0, so we decrement category IDs by 1 to get the folder ID
$folder = $path[1] - 1;
$title = $data['title'] ?? "";
try {
if ($folder === 0) {
+ // folder 0 doesn't actually exist in the database, so its name is kept as user metadata
if (!strlen(trim($title))) {
throw new ExceptionInput("whitespace");
}
@@ -345,7 +347,7 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
} catch (ExceptionInput $e) {
if ($e->getCode() === 10236) {
return new ErrorResponse(["DuplicateCategory", 'title' => $title], 500);
- } elseif ($e->getCode === 10239) {
+ } elseif ($e->getCode() === 10239) {
return new ErrorResponse("404", 404);
} else {
return new ErrorResponse(["InvalidCategory", 'title' => $title], 500);
diff --git a/tests/cases/REST/Miniflux/TestV1.php b/tests/cases/REST/Miniflux/TestV1.php
index 02599480..f9bb423c 100644
--- a/tests/cases/REST/Miniflux/TestV1.php
+++ b/tests/cases/REST/Miniflux/TestV1.php
@@ -283,4 +283,44 @@ class TestV1 extends \JKingWeb\Arsse\Test\AbstractTest {
[false, new ErrorResponse(["InvalidInputType", 'field' => "title", 'actual' => "boolean", 'expected' => "string"], 400)],
];
}
+
+ /** @dataProvider provideCategoryUpdates */
+ public function testRenameACategory(int $id, $title, ResponseInterface $exp): void {
+ Arsse::$user->method("propertiesSet")->willReturn(['root_folder_name' => $title]);
+ if (!in_array($id, [1,2])) {
+ \Phake::when(Arsse::$db)->folderPropertiesSet->thenThrow(new ExceptionInput("subjectMissing"));
+ } elseif (!strlen((string) $title)) {
+ \Phake::when(Arsse::$db)->folderPropertiesSet->thenThrow(new ExceptionInput("missing"));
+ } elseif (!strlen(trim((string) $title))) {
+ \Phake::when(Arsse::$db)->folderPropertiesSet->thenThrow(new ExceptionInput("whitespace"));
+ } elseif ($title === "Duplicate") {
+ \Phake::when(Arsse::$db)->folderPropertiesSet->thenThrow(new ExceptionInput("constraintViolation"));
+ } else {
+ \Phake::when(Arsse::$db)->folderPropertiesSet->thenReturn(true);
+ }
+ if ($id === 1) {
+ $times = (int) (is_string($title) && strlen(trim($title)));
+ Arsse::$user->expects($this->exactly($times))->method("propertiesSet")->with("john.doe@example.com", ['root_folder_name' => $title]);
+ }
+ $this->assertMessage($exp, $this->req("PUT", "/categories/$id", ['title' => $title]));
+ if ($id !== 1 && is_string($title)) {
+ \Phake::verify(Arsse::$db)->folderPropertiesSet("john.doe@example.com", $id - 1, ['name' => $title]);
+ }
+ }
+
+ public function provideCategoryUpdates(): iterable {
+ return [
+ [3, "New", new ErrorResponse("404", 404)],
+ [2, "New", new Response(['id' => 2, 'title' => "New", 'user_id' => 42])],
+ [2, "Duplicate", new ErrorResponse(["DuplicateCategory", 'title' => "Duplicate"], 500)],
+ [2, "", new ErrorResponse(["InvalidCategory", 'title' => ""], 500)],
+ [2, " ", new ErrorResponse(["InvalidCategory", 'title' => " "], 500)],
+ [2, false, new ErrorResponse(["InvalidInputType", 'field' => "title", 'actual' => "boolean", 'expected' => "string"], 400)],
+ [1, "New", new Response(['id' => 1, 'title' => "New", 'user_id' => 42])],
+ [1, "Duplicate", new Response(['id' => 1, 'title' => "Duplicate", 'user_id' => 42])], // This is allowed because the name of the root folder is only a duplicate in circumstances where it is used
+ [1, "", new ErrorResponse(["InvalidCategory", 'title' => ""], 500)],
+ [1, " ", new ErrorResponse(["InvalidCategory", 'title' => " "], 500)],
+ [1, false, new ErrorResponse(["InvalidInputType", 'field' => "title", 'actual' => "boolean", 'expected' => "string"], 400)],
+ ];
+ }
}
From 5124f76b70e9ea9323f046b1d2c3b1a64ebe750c Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Sun, 13 Dec 2020 22:10:34 -0500
Subject: [PATCH 070/366] Implementcategory deletion
---
lib/REST/Miniflux/V1.php | 22 ++++++++-
tests/cases/REST/Miniflux/TestV1.php | 73 +++++++++++++++++-----------
tests/lib/AbstractTest.php | 6 ++-
3 files changed, 70 insertions(+), 31 deletions(-)
diff --git a/lib/REST/Miniflux/V1.php b/lib/REST/Miniflux/V1.php
index d84a7e9b..19f0d0b9 100644
--- a/lib/REST/Miniflux/V1.php
+++ b/lib/REST/Miniflux/V1.php
@@ -347,7 +347,7 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
} catch (ExceptionInput $e) {
if ($e->getCode() === 10236) {
return new ErrorResponse(["DuplicateCategory", 'title' => $title], 500);
- } elseif ($e->getCode() === 10239) {
+ } elseif (in_array($e->getCode(), [10237, 10239])) {
return new ErrorResponse("404", 404);
} else {
return new ErrorResponse(["InvalidCategory", 'title' => $title], 500);
@@ -357,6 +357,26 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
return new Response(['id' => (int) $path[1], 'title' => $title, 'user_id' => $meta['num']]);
}
+ protected function deleteCategory(array $path, array $query, array $data): ResponseInterface {
+ try {
+ $folder = $path[1] - 1;
+ if ($folder !== 0) {
+ Arsse::$db->folderRemove(Arsse::$user->id, $folder);
+ } else {
+ // if we're deleting from the root folder, delete each child subscription individually
+ // otherwise we'd be deleting the entire tree
+ $tr = Arsse::$db->begin();
+ foreach (Arsse::$db->subscriptionList(Arsse::$user->id, null, false) as $sub) {
+ Arsse::$db->subscriptionRemove(Arsse::$user->id, $sub['id']);
+ }
+ $tr->commit();
+ }
+ } catch (ExceptionInput $e) {
+ return new ErrorResponse("404", 404);
+ }
+ return new EmptyResponse(204);
+ }
+
public static function tokenGenerate(string $user, string $label): string {
// Miniflux produces tokens in base64url alphabet
$t = str_replace(["+", "/"], ["-", "_"], base64_encode(random_bytes(self::TOKEN_LENGTH)));
diff --git a/tests/cases/REST/Miniflux/TestV1.php b/tests/cases/REST/Miniflux/TestV1.php
index f9bb423c..e2f1033f 100644
--- a/tests/cases/REST/Miniflux/TestV1.php
+++ b/tests/cases/REST/Miniflux/TestV1.php
@@ -285,42 +285,59 @@ class TestV1 extends \JKingWeb\Arsse\Test\AbstractTest {
}
/** @dataProvider provideCategoryUpdates */
- public function testRenameACategory(int $id, $title, ResponseInterface $exp): void {
+ public function testRenameACategory(int $id, $title, $out, ResponseInterface $exp): void {
Arsse::$user->method("propertiesSet")->willReturn(['root_folder_name' => $title]);
- if (!in_array($id, [1,2])) {
- \Phake::when(Arsse::$db)->folderPropertiesSet->thenThrow(new ExceptionInput("subjectMissing"));
- } elseif (!strlen((string) $title)) {
- \Phake::when(Arsse::$db)->folderPropertiesSet->thenThrow(new ExceptionInput("missing"));
- } elseif (!strlen(trim((string) $title))) {
- \Phake::when(Arsse::$db)->folderPropertiesSet->thenThrow(new ExceptionInput("whitespace"));
- } elseif ($title === "Duplicate") {
- \Phake::when(Arsse::$db)->folderPropertiesSet->thenThrow(new ExceptionInput("constraintViolation"));
+ if (is_string($out)) {
+ \Phake::when(Arsse::$db)->folderPropertiesSet->thenThrow(new ExceptionInput($out));
} else {
- \Phake::when(Arsse::$db)->folderPropertiesSet->thenReturn(true);
- }
- if ($id === 1) {
- $times = (int) (is_string($title) && strlen(trim($title)));
- Arsse::$user->expects($this->exactly($times))->method("propertiesSet")->with("john.doe@example.com", ['root_folder_name' => $title]);
+ \Phake::when(Arsse::$db)->folderPropertiesSet->thenReturn($out);
}
+ $times = (int) ($id === 1 && is_string($title) && strlen(trim($title)));
+ Arsse::$user->expects($this->exactly($times))->method("propertiesSet")->with("john.doe@example.com", ['root_folder_name' => $title]);
$this->assertMessage($exp, $this->req("PUT", "/categories/$id", ['title' => $title]));
- if ($id !== 1 && is_string($title)) {
- \Phake::verify(Arsse::$db)->folderPropertiesSet("john.doe@example.com", $id - 1, ['name' => $title]);
- }
+ $times = (int) ($id !== 1 && is_string($title));
+ \Phake::verify(Arsse::$db, \Phake::times($times))->folderPropertiesSet("john.doe@example.com", $id - 1, ['name' => $title]);
}
public function provideCategoryUpdates(): iterable {
return [
- [3, "New", new ErrorResponse("404", 404)],
- [2, "New", new Response(['id' => 2, 'title' => "New", 'user_id' => 42])],
- [2, "Duplicate", new ErrorResponse(["DuplicateCategory", 'title' => "Duplicate"], 500)],
- [2, "", new ErrorResponse(["InvalidCategory", 'title' => ""], 500)],
- [2, " ", new ErrorResponse(["InvalidCategory", 'title' => " "], 500)],
- [2, false, new ErrorResponse(["InvalidInputType", 'field' => "title", 'actual' => "boolean", 'expected' => "string"], 400)],
- [1, "New", new Response(['id' => 1, 'title' => "New", 'user_id' => 42])],
- [1, "Duplicate", new Response(['id' => 1, 'title' => "Duplicate", 'user_id' => 42])], // This is allowed because the name of the root folder is only a duplicate in circumstances where it is used
- [1, "", new ErrorResponse(["InvalidCategory", 'title' => ""], 500)],
- [1, " ", new ErrorResponse(["InvalidCategory", 'title' => " "], 500)],
- [1, false, new ErrorResponse(["InvalidInputType", 'field' => "title", 'actual' => "boolean", 'expected' => "string"], 400)],
+ [3, "New", "subjectMissing", new ErrorResponse("404", 404)],
+ [2, "New", true, new Response(['id' => 2, 'title' => "New", 'user_id' => 42])],
+ [2, "Duplicate", "constraintViolation", new ErrorResponse(["DuplicateCategory", 'title' => "Duplicate"], 500)],
+ [2, "", "missing", new ErrorResponse(["InvalidCategory", 'title' => ""], 500)],
+ [2, " ", "whitespace", new ErrorResponse(["InvalidCategory", 'title' => " "], 500)],
+ [2, false, "subjectMissing", new ErrorResponse(["InvalidInputType", 'field' => "title", 'actual' => "boolean", 'expected' => "string"], 400)],
+ [1, "New", true, new Response(['id' => 1, 'title' => "New", 'user_id' => 42])],
+ [1, "Duplicate", "constraintViolation", new Response(['id' => 1, 'title' => "Duplicate", 'user_id' => 42])], // This is allowed because the name of the root folder is only a duplicate in circumstances where it is used
+ [1, "", "missing", new ErrorResponse(["InvalidCategory", 'title' => ""], 500)],
+ [1, " ", "whitespace", new ErrorResponse(["InvalidCategory", 'title' => " "], 500)],
+ [1, false, false, new ErrorResponse(["InvalidInputType", 'field' => "title", 'actual' => "boolean", 'expected' => "string"], 400)],
];
}
+
+ public function testDeleteARealCategory(): void {
+ \Phake::when(Arsse::$db)->folderRemove->thenReturn(true)->thenThrow(new ExceptionInput("subjectMissing"));
+ $this->assertMessage(new EmptyResponse(204), $this->req("DELETE", "/categories/2112"));
+ \Phake::verify(Arsse::$db)->folderRemove("john.doe@example.com", 2111);
+ $this->assertMessage(new ErrorResponse("404", 404), $this->req("DELETE", "/categories/47"));
+ \Phake::verify(Arsse::$db)->folderRemove("john.doe@example.com", 46);
+ }
+
+ public function testDeleteTheSpecialCategory(): void {
+ \Phake::when(Arsse::$db)->subscriptionList->thenReturn(new Result($this->v([
+ ['id' => 1],
+ ['id' => 47],
+ ['id' => 2112],
+ ])));
+ \Phake::when(Arsse::$db)->subscriptionRemove->thenReturn(true);
+ $this->assertMessage(new EmptyResponse(204), $this->req("DELETE", "/categories/1"));
+ \Phake::inOrder(
+ \Phake::verify(Arsse::$db)->begin(),
+ \Phake::verify(Arsse::$db)->subscriptionList("john.doe@example.com", null, false),
+ \Phake::verify(Arsse::$db)->subscriptionRemove("john.doe@example.com", 1),
+ \Phake::verify(Arsse::$db)->subscriptionRemove("john.doe@example.com", 47),
+ \Phake::verify(Arsse::$db)->subscriptionRemove("john.doe@example.com", 2112),
+ \Phake::verify($this->transaction)->commit()
+ );
+ }
}
diff --git a/tests/lib/AbstractTest.php b/tests/lib/AbstractTest.php
index ed172430..54cde189 100644
--- a/tests/lib/AbstractTest.php
+++ b/tests/lib/AbstractTest.php
@@ -154,7 +154,7 @@ abstract class AbstractTest extends \PHPUnit\Framework\TestCase {
protected function assertMessage(MessageInterface $exp, MessageInterface $act, string $text = ''): void {
if ($exp instanceof ResponseInterface) {
$this->assertInstanceOf(ResponseInterface::class, $act, $text);
- $this->assertEquals($exp->getStatusCode(), $act->getStatusCode(), $text);
+ $this->assertSame($exp->getStatusCode(), $act->getStatusCode(), $text);
} elseif ($exp instanceof RequestInterface) {
if ($exp instanceof ServerRequestInterface) {
$this->assertInstanceOf(ServerRequestInterface::class, $act, $text);
@@ -165,12 +165,14 @@ abstract class AbstractTest extends \PHPUnit\Framework\TestCase {
$this->assertSame($exp->getRequestTarget(), $act->getRequestTarget(), $text);
}
if ($exp instanceof JsonResponse) {
+ $this->assertInstanceOf(JsonResponse::class, $act, $text);
$this->assertEquals($exp->getPayload(), $act->getPayload(), $text);
$this->assertSame($exp->getPayload(), $act->getPayload(), $text);
} elseif ($exp instanceof XmlResponse) {
+ $this->assertInstanceOf(XmlResponse::class, $act, $text);
$this->assertXmlStringEqualsXmlString((string) $exp->getBody(), (string) $act->getBody(), $text);
} else {
- $this->assertEquals((string) $exp->getBody(), (string) $act->getBody(), $text);
+ $this->assertSame((string) $exp->getBody(), (string) $act->getBody(), $text);
}
$this->assertEquals($exp->getHeaders(), $act->getHeaders(), $text);
}
From 95a2018e755997af6d3fe484ba3e1bc42a670072 Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Mon, 14 Dec 2020 12:41:09 -0500
Subject: [PATCH 071/366] Implement caategory marking as read
---
lib/REST/Miniflux/V1.php | 270 +++++++++++++++++----------
lib/REST/NextcloudNews/V1_2.php | 1 -
tests/cases/REST/Miniflux/TestV1.php | 13 ++
3 files changed, 185 insertions(+), 99 deletions(-)
diff --git a/lib/REST/Miniflux/V1.php b/lib/REST/Miniflux/V1.php
index 19f0d0b9..99c6a9b2 100644
--- a/lib/REST/Miniflux/V1.php
+++ b/lib/REST/Miniflux/V1.php
@@ -10,6 +10,7 @@ use JKingWeb\Arsse\Arsse;
use JKingWeb\Arsse\Feed;
use JKingWeb\Arsse\Feed\Exception as FeedException;
use JKingWeb\Arsse\AbstractException;
+use JKingWeb\Arsse\Context\Context;
use JKingWeb\Arsse\Db\ExceptionInput;
use JKingWeb\Arsse\Misc\HTTP;
use JKingWeb\Arsse\Misc\Date;
@@ -34,37 +35,82 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
'user_agent' => "string",
'title' => "string",
];
- protected const PATHS = [
- '/categories' => ['GET' => "getCategories", 'POST' => "createCategory"],
- '/categories/1' => ['PUT' => "updateCategory", 'DELETE' => "deleteCategory"],
- '/categories/1/mark-all-as-read' => ['PUT' => "markCategory"],
- '/discover' => ['POST' => "discoverSubscriptions"],
- '/entries' => ['GET' => "getEntries", 'PUT' => "updateEntries"],
- '/entries/1' => ['GET' => "getEntry"],
- '/entries/1/bookmark' => ['PUT' => "toggleEntryBookmark"],
- '/export' => ['GET' => "opmlExport"],
- '/feeds' => ['GET' => "getFeeds", 'POST' => "createFeed"],
- '/feeds/1' => ['GET' => "getFeed", 'PUT' => "updateFeed", 'DELETE' => "removeFeed"],
- '/feeds/1/mark-all-as-read' => ['PUT' => "markFeed"],
- '/feeds/1/entries/1' => ['GET' => "getFeedEntry"],
- '/feeds/1/entries' => ['GET' => "getFeedEntries"],
- '/feeds/1/icon' => ['GET' => "getFeedIcon"],
- '/feeds/1/refresh' => ['PUT' => "refreshFeed"],
- '/feeds/refresh' => ['PUT' => "refreshAllFeeds"],
- '/import' => ['POST' => "opmlImport"],
- '/me' => ['GET' => "getCurrentUser"],
- '/users' => ['GET' => "getUsers", 'POST' => "createUser"],
- '/users/1' => ['GET' => "getUserByNum", 'PUT' => "updateUserByNum", 'DELETE' => "deleteUser"],
- '/users/1/mark-all-as-read' => ['PUT' => "markAll"],
- '/users/*' => ['GET' => "getUserById"],
- ];
- protected const ADMIN_FUNCTIONS = [
- 'getUsers' => true,
- 'getUserByNum' => true,
- 'getUserById' => true,
- 'createUser' => true,
- 'updateUserByNum' => true,
- 'deleteUser' => true,
+ protected const CALLS = [ // handler method Admin Path Body Query
+ '/categories' => [
+ 'GET' => ["getCategories", false, false, false, false],
+ 'POST' => ["createCategory", false, false, true, false],
+ ],
+ '/categories/1' => [
+ 'PUT' => ["updateCategory", false, true, true, false],
+ 'DELETE' => ["deleteCategory", false, true, false, false],
+ ],
+ '/categories/1/mark-all-as-read' => [
+ 'PUT' => ["markCategory", false, true, false, false],
+ ],
+ '/discover' => [
+ 'POST' => ["discoverSubscriptions", false, false, true, false],
+ ],
+ '/entries' => [
+ 'GET' => ["getEntries", false, false, false, true],
+ 'PUT' => ["updateEntries", false, false, true, false],
+ ],
+ '/entries/1' => [
+ 'GET' => ["getEntry", false, true, false, false],
+ ],
+ '/entries/1/bookmark' => [
+ 'PUT' => ["toggleEntryBookmark", false, true, false, false],
+ ],
+ '/export' => [
+ 'GET' => ["opmlExport", false, false, false, false],
+ ],
+ '/feeds' => [
+ 'GET' => ["getFeeds", false, false, false, false],
+ 'POST' => ["createFeed", false, false, true, false],
+ ],
+ '/feeds/1' => [
+ 'GET' => ["getFeed", false, true, false, false],
+ 'PUT' => ["updateFeed", false, true, true, false],
+ 'DELETE' => ["deleteFeed", false, true, false, false],
+ ],
+ '/feeds/1/entries' => [
+ 'GET' => ["getFeedEntries", false, true, false, false],
+ ],
+ '/feeds/1/entries/1' => [
+ 'GET' => ["getFeedEntry", false, true, false, false],
+ ],
+ '/feeds/1/icon' => [
+ 'GET' => ["getFeedIcon", false, true, false, false],
+ ],
+ '/feeds/1/mark-all-as-read' => [
+ 'PUT' => ["markFeed", false, true, false, false],
+ ],
+ '/feeds/1/refresh' => [
+ 'PUT' => ["refreshFeed", false, true, false, false],
+ ],
+ '/feeds/refresh' => [
+ 'PUT' => ["refreshAllFeeds", false, false, false, false],
+ ],
+ '/import' => [
+ 'POST' => ["opmlImport", false, false, true, false],
+ ],
+ '/me' => [
+ 'GET' => ["getCurrentUser", false, false, false, false],
+ ],
+ '/users' => [
+ 'GET' => ["getUsers", true, false, false, false],
+ 'POST' => ["createUser", true, false, true, false],
+ ],
+ '/users/1' => [
+ 'GET' => ["getUserByNum", true, true, false, false],
+ 'PUT' => ["updateUserByNum", true, true, true, false],
+ 'DELETE' => ["deleteUserByNum", true, true, false, false],
+ ],
+ '/users/1/mark-all-as-read' => [
+ 'PUT' => ["markUserByNum", false, true, false, false],
+ ],
+ '/users/*' => [
+ 'GET' => ["getUserById", true, true, false, false],
+ ],
];
public function __construct() {
@@ -117,33 +163,45 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
$func = $this->chooseCall($target, $method);
if ($func instanceof ResponseInterface) {
return $func;
+ } else {
+ [$func, $reqAdmin, $reqPath, $reqBody, $reqQuery] = $func;
}
- if ((self::ADMIN_FUNCTIONS[$func] ?? false) && !$this->isAdmin()) {
+ if ($reqAdmin && !$this->isAdmin()) {
return new ErrorResponse("403", 403);
}
- $data = [];
- $query = [];
- if ($func === "opmlImport") {
- if (!HTTP::matchType($req, "", ...[self::ACCEPTED_TYPES_OPML])) {
- return new ErrorResponse("", 415, ['Accept' => implode(", ", self::ACCEPTED_TYPES_OPML)]);
+ $args = [];
+ if ($reqPath) {
+ $args[] = explode("/", ltrim($target, "/"));
+ }
+ if ($reqBody) {
+ if ($func === "opmlImport") {
+ if (!HTTP::matchType($req, "", ...[self::ACCEPTED_TYPES_OPML])) {
+ return new ErrorResponse("", 415, ['Accept' => implode(", ", self::ACCEPTED_TYPES_OPML)]);
+ }
+ $args[] = (string) $req->getBody();
+ } else {
+ $data = (string) $req->getBody();
+ if (strlen($data)) {
+ $data = @json_decode($data, true);
+ if (json_last_error() !== \JSON_ERROR_NONE) {
+ // if the body could not be parsed as JSON, return "400 Bad Request"
+ return new ErrorResponse(["InvalidBodyJSON", json_last_error_msg()], 400);
+ }
+ } else {
+ $data = [];
+ }
+ $data = $this->normalizeBody((array) $data);
+ if ($data instanceof ResponseInterface) {
+ return $data;
+ }
}
- $data = (string) $req->getBody();
- } elseif ($method === "POST" || $method === "PUT") {
- $data = @json_decode((string) $req->getBody(), true);
- if (json_last_error() !== \JSON_ERROR_NONE) {
- // if the body could not be parsed as JSON, return "400 Bad Request"
- return new ErrorResponse(["InvalidBodyJSON", json_last_error_msg()], 400);
- }
- $data = $this->normalizeBody((array) $data);
- if ($data instanceof ResponseInterface) {
- return $data;
- }
- } elseif ($method === "GET") {
- $query = $req->getQueryParams();
+ $args[] = $data;
+ }
+ if ($reqQuery) {
+ $args[] = $req->getQueryParams();
}
try {
- $path = explode("/", ltrim($target, "/"));
- return $this->$func($path, $query, $data);
+ return $this->$func(...$args);
// @codeCoverageIgnoreStart
} catch (Exception $e) {
// if there was a REST exception return 400
@@ -155,6 +213,28 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
// @codeCoverageIgnoreEnd
}
+ protected function chooseCall(string $url, string $method) {
+ // // normalize the URL path: change any IDs to 1 for easier comparison
+ $url = $this->normalizePathIds($url);
+ // normalize the HTTP method to uppercase
+ $method = strtoupper($method);
+ // we now evaluate the supplied URL against every supported path for the selected scope
+ if (isset(self::CALLS[$url])) {
+ // if the path is supported, make sure the method is allowed
+ if (isset(self::CALLS[$url][$method])) {
+ // if it is allowed, return the object method to run, assuming the method exists
+ assert(method_exists($this, self::CALLS[$url][$method][0]), new \Exception("Method is not implemented"));
+ return self::CALLS[$url][$method];
+ } else {
+ // otherwise return 405
+ return new EmptyResponse(405, ['Allow' => implode(", ", array_keys(self::CALLS[$url]))]);
+ }
+ } else {
+ // if the path is not supported, return 404
+ return new EmptyResponse(404);
+ }
+ }
+
protected function normalizePathIds(string $url): string {
$path = explode("/", $url);
// any path components which are database IDs (integers greater than zero) should be replaced with "1", for easier comparison (we don't care about the specific ID)
@@ -170,12 +250,24 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
return implode("/", $path);
}
+ protected function normalizeBody(array $body) {
+ // Miniflux does not attempt to coerce values into different types
+ foreach (self::VALID_JSON as $k => $t) {
+ if (!isset($body[$k])) {
+ $body[$k] = null;
+ } elseif (gettype($body[$k]) !== $t) {
+ return new ErrorResponse(["InvalidInputType", 'field' => $k, 'expected' => $t, 'actual' => gettype($body[$k])]);
+ }
+ }
+ return $body;
+ }
+
protected function handleHTTPOptions(string $url): ResponseInterface {
// normalize the URL path: change any IDs to 1 for easier comparison
$url = $this->normalizePathIDs($url);
- if (isset(self::PATHS[$url])) {
+ if (isset(self::CALLS[$url])) {
// if the path is supported, respond with the allowed methods and other metadata
- $allowed = array_keys(self::PATHS[$url]);
+ $allowed = array_keys(self::CALLS[$url]);
// if GET is allowed, so is HEAD
if (in_array("GET", $allowed)) {
array_unshift($allowed, "HEAD");
@@ -190,41 +282,6 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
}
}
- protected function chooseCall(string $url, string $method) {
- // // normalize the URL path: change any IDs to 1 for easier comparison
- $url = $this->normalizePathIds($url);
- // normalize the HTTP method to uppercase
- $method = strtoupper($method);
- // we now evaluate the supplied URL against every supported path for the selected scope
- // the URL is evaluated as an array so as to avoid decoded escapes turning invalid URLs into valid ones
- if (isset(self::PATHS[$url])) {
- // if the path is supported, make sure the method is allowed
- if (isset(self::PATHS[$url][$method])) {
- // if it is allowed, return the object method to run, assuming the method exists
- assert(method_exists($this, self::PATHS[$url][$method]), new \Exception("Method is not implemented"));
- return self::PATHS[$url][$method];
- } else {
- // otherwise return 405
- return new EmptyResponse(405, ['Allow' => implode(", ", array_keys(self::PATHS[$url]))]);
- }
- } else {
- // if the path is not supported, return 404
- return new EmptyResponse(404);
- }
- }
-
- protected function normalizeBody(array $body) {
- // Miniflux does not attempt to coerce values into different types
- foreach (self::VALID_JSON as $k => $t) {
- if (!isset($body[$k])) {
- $body[$k] = null;
- } elseif (gettype($body[$k]) !== $t) {
- return new ErrorResponse(["InvalidInputType", 'field' => $k, 'expected' => $t, 'actual' => gettype($body[$k])]);
- }
- }
- return $body;
- }
-
protected function listUsers(array $users, bool $reportMissing): array {
$out = [];
$now = Date::transform($this->now(), "iso8601m");
@@ -259,7 +316,7 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
return $out;
}
- protected function discoverSubscriptions(array $path, array $query, array $data): ResponseInterface {
+ protected function discoverSubscriptions(array $data): ResponseInterface {
try {
$list = Feed::discoverAll((string) $data['url'], (string) $data['username'], (string) $data['password']);
} catch (FeedException $e) {
@@ -278,11 +335,11 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
return new Response($out);
}
- protected function getUsers(array $path, array $query, array $data): ResponseInterface {
+ protected function getUsers(): ResponseInterface {
return new Response($this->listUsers(Arsse::$user->list(), false));
}
- protected function getUserById(array $path, array $query, array $data): ResponseInterface {
+ protected function getUserById(array $path): ResponseInterface {
try {
return new Response($this->listUsers([$path[1]], true)[0] ?? new \stdClass);
} catch (UserException $e) {
@@ -290,7 +347,7 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
}
}
- protected function getUserByNum(array $path, array $query, array $data): ResponseInterface {
+ protected function getUserByNum(array $path): ResponseInterface {
try {
$user = Arsse::$user->lookup((int) $path[1]);
return new Response($this->listUsers([$user], true)[0] ?? new \stdClass);
@@ -299,11 +356,11 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
}
}
- protected function getCurrentUser(array $path, array $query, array $data): ResponseInterface {
+ protected function getCurrentUser(): ResponseInterface {
return new Response($this->listUsers([Arsse::$user->id], false)[0] ?? new \stdClass);
}
- protected function getCategories(array $path, array $query, array $data): ResponseInterface {
+ protected function getCategories(): ResponseInterface {
$out = [];
$meta = Arsse::$user->propertiesGet(Arsse::$user->id, false);
// add the root folder as a category
@@ -316,7 +373,7 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
return new Response($out);
}
- protected function createCategory(array $path, array $query, array $data): ResponseInterface {
+ protected function createCategory(array $data): ResponseInterface {
try {
$id = Arsse::$db->folderAdd(Arsse::$user->id, ['name' => (string) $data['title']]);
} catch (ExceptionInput $e) {
@@ -330,7 +387,7 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
return new Response(['id' => $id + 1, 'title' => $data['title'], 'user_id' => $meta['num']]);
}
- protected function updateCategory(array $path, array $query, array $data): ResponseInterface {
+ protected function updateCategory(array $path, array $data): ResponseInterface {
// category IDs in Miniflux are always greater than 1; we have folder 0, so we decrement category IDs by 1 to get the folder ID
$folder = $path[1] - 1;
$title = $data['title'] ?? "";
@@ -357,7 +414,7 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
return new Response(['id' => (int) $path[1], 'title' => $title, 'user_id' => $meta['num']]);
}
- protected function deleteCategory(array $path, array $query, array $data): ResponseInterface {
+ protected function deleteCategory(array $path): ResponseInterface {
try {
$folder = $path[1] - 1;
if ($folder !== 0) {
@@ -377,6 +434,23 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
return new EmptyResponse(204);
}
+ protected function markCategory(array $path): ResponseInterface {
+ $folder = $path[1] - 1;
+ $c = new Context;
+ if ($folder === 0) {
+ // if we're marking the root folder don't also mark its child folders, since Miniflux organizes it as a peer of other folders
+ $c = $c->folderShallow($folder);
+ } else {
+ $c = $c->folder($folder);
+ }
+ try {
+ Arsse::$db->articleMark(Arsse::$user->id, ['read' => true], $c);
+ } catch (ExceptionInput $e) {
+ return new ErrorResponse("404", 404);
+ }
+ return new EmptyResponse(204);
+ }
+
public static function tokenGenerate(string $user, string $label): string {
// Miniflux produces tokens in base64url alphabet
$t = str_replace(["+", "/"], ["-", "_"], base64_encode(random_bytes(self::TOKEN_LENGTH)));
diff --git a/lib/REST/NextcloudNews/V1_2.php b/lib/REST/NextcloudNews/V1_2.php
index c73ea8c7..5c5a9443 100644
--- a/lib/REST/NextcloudNews/V1_2.php
+++ b/lib/REST/NextcloudNews/V1_2.php
@@ -142,7 +142,6 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler {
// normalize the HTTP method to uppercase
$method = strtoupper($method);
// we now evaluate the supplied URL against every supported path for the selected scope
- // the URL is evaluated as an array so as to avoid decoded escapes turning invalid URLs into valid ones
if (isset($this->paths[$url])) {
// if the path is supported, make sure the method is allowed
if (isset($this->paths[$url][$method])) {
diff --git a/tests/cases/REST/Miniflux/TestV1.php b/tests/cases/REST/Miniflux/TestV1.php
index e2f1033f..3778a547 100644
--- a/tests/cases/REST/Miniflux/TestV1.php
+++ b/tests/cases/REST/Miniflux/TestV1.php
@@ -7,6 +7,7 @@ declare(strict_types=1);
namespace JKingWeb\Arsse\TestCase\REST\Miniflux;
use JKingWeb\Arsse\Arsse;
+use JKingWeb\Arsse\Context\Context;
use JKingWeb\Arsse\User;
use JKingWeb\Arsse\Database;
use JKingWeb\Arsse\Db\Transaction;
@@ -340,4 +341,16 @@ class TestV1 extends \JKingWeb\Arsse\Test\AbstractTest {
\Phake::verify($this->transaction)->commit()
);
}
+
+ public function testMarkACategoryAsRead(): void {
+ \Phake::when(Arsse::$db)->articleMark->thenReturn(1)->thenReturn(1)->thenThrow(new ExceptionInput("idMissing"));
+ $this->assertMessage(new EmptyResponse(204), $this->req("PUT", "/categories/2/mark-all-as-read"));
+ $this->assertMessage(new EmptyResponse(204), $this->req("PUT", "/categories/1/mark-all-as-read"));
+ $this->assertMessage(new ErrorResponse("404", 404), $this->req("PUT", "/categories/2112/mark-all-as-read"));
+ \Phake::inOrder(
+ \Phake::verify(Arsse::$db)->articleMark("john.doe@example.com", ['read' => true], (new Context)->folder(1)),
+ \Phake::verify(Arsse::$db)->articleMark("john.doe@example.com", ['read' => true], (new Context)->folderShallow(0)),
+ \Phake::verify(Arsse::$db)->articleMark("john.doe@example.com", ['read' => true], (new Context)->folder(2111))
+ );
+ }
}
From c43d0dcae3686e7627c53565e37bed05bbd14c04 Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Mon, 14 Dec 2020 20:09:38 -0500
Subject: [PATCH 072/366] Groundwork for filtering rules
---
docs/en/030_Supported_Protocols/005_Miniflux.md | 9 ++++++++-
sql/MySQL/6.sql | 3 +++
sql/PostgreSQL/6.sql | 3 +++
sql/SQLite3/6.sql | 7 +++++--
4 files changed, 19 insertions(+), 3 deletions(-)
diff --git a/docs/en/030_Supported_Protocols/005_Miniflux.md b/docs/en/030_Supported_Protocols/005_Miniflux.md
index 84e7d283..b29c0825 100644
--- a/docs/en/030_Supported_Protocols/005_Miniflux.md
+++ b/docs/en/030_Supported_Protocols/005_Miniflux.md
@@ -10,7 +10,7 @@
API endpoint
/v1/
Specifications
- API Reference
+ API Reference , Filtering Rules
The Miniflux protocol is a fairly well-designed protocol supporting a wide variety of operations on newsfeeds, folders (termed "categories"), and articles; it also allows for user administration, and native OPML importing and exporting. Architecturally it is similar to the Nextcloud News protocol, but is generally more efficient and has more capabilities.
@@ -33,6 +33,13 @@ Miniflux version 2.0.26 is emulated, though not all features are implemented
- Only the URL should be considered reliable in feed discovery results
- The "All" category is treated specially (see below for details)
- Category names consisting only of whitespace are rejected along with the empty string
+- Filtering rules may not function identically (see below for details)
+
+# Behaviour of filtering (block and keep) rules
+
+The Miniflux documentation gives only a brief example of a pattern for its filtering rules; the allowed syntax is described in full [in Google's documentation for RE2](https://github.com/google/re2/wiki/Syntax). Being a PHP application, The Arsse instead accepts [PCRE syntax](http://www.pcre.org/original/doc/html/pcresyntax.html) (or since PHP 7.3 [PCRE2 syntax](https://www.pcre.org/current/doc/html/pcre2syntax.html)), specifically in UTF-8 mode. Delimiters should not be included, and slashes should not be escaped; anchors may be used if desired. For example `^(?i)RE/MAX$` is a valid pattern.
+
+For convenience the patterns are tested after collapsing whitespace. Unlike Miniflux, The Arsse tests the patterns against an article's author-supplied categories if they do not match its title.
# Special handling of the "All" category
diff --git a/sql/MySQL/6.sql b/sql/MySQL/6.sql
index 36b2d6e4..9370e274 100644
--- a/sql/MySQL/6.sql
+++ b/sql/MySQL/6.sql
@@ -6,6 +6,9 @@
alter table arsse_tokens add column data longtext default null;
+alter table arsse_subscriptions add column keep_rule longtext default null;
+alter table arsse_subscriptions add column block_rule longtext default null;
+
alter table arsse_users add column num bigint unsigned unique;
alter table arsse_users add column admin boolean not null default 0;
create temporary table arsse_users_existing(
diff --git a/sql/PostgreSQL/6.sql b/sql/PostgreSQL/6.sql
index a32eb0c0..f936b87a 100644
--- a/sql/PostgreSQL/6.sql
+++ b/sql/PostgreSQL/6.sql
@@ -6,6 +6,9 @@
alter table arsse_tokens add column data text default null;
+alter table arsse_subscriptions add column keep_rule text default null;
+alter table arsse_subscriptions add column block_rule text default null;
+
alter table arsse_users add column num bigint unique;
alter table arsse_users add column admin smallint not null default 0;
create temp table arsse_users_existing(
diff --git a/sql/SQLite3/6.sql b/sql/SQLite3/6.sql
index 81e9e821..752c0568 100644
--- a/sql/SQLite3/6.sql
+++ b/sql/SQLite3/6.sql
@@ -6,8 +6,11 @@
-- This is a speculative addition to support OAuth login in the future
alter table arsse_tokens add column data text default null;
--- Add num and admin columns to the users table
--- In particular this adds a numeric identifier for each user, which Miniflux requires
+-- Add columns to subscriptions to store "keep" and "block" filtering rules from Miniflux
+alter table arsse_subscriptions add column keep_rule text default null;
+alter table arsse_subscriptions add column block_rule text default null;
+
+-- Add numeric identifier and admin columns to the users table
create table arsse_users_new(
-- users
id text primary key not null collate nocase, -- user id
From d5cd5b6a17503c5774d45a9c0ebe92dc5f220ea9 Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Tue, 15 Dec 2020 13:20:03 -0500
Subject: [PATCH 073/366] Implement hidden marks
Tests are still needed
---
lib/Context/Context.php | 5 +++++
lib/Database.php | 33 +++++++++++++++++++++++---------
sql/MySQL/6.sql | 1 +
sql/PostgreSQL/6.sql | 1 +
sql/SQLite3/6.sql | 4 +++-
tests/cases/Misc/TestContext.php | 1 +
6 files changed, 35 insertions(+), 10 deletions(-)
diff --git a/lib/Context/Context.php b/lib/Context/Context.php
index fb1236a3..8e1b699c 100644
--- a/lib/Context/Context.php
+++ b/lib/Context/Context.php
@@ -13,6 +13,7 @@ class Context extends ExclusionContext {
public $offset = 0;
public $unread;
public $starred;
+ public $hidden;
public $labelled;
public $annotated;
@@ -46,6 +47,10 @@ class Context extends ExclusionContext {
return $this->act(__FUNCTION__, func_num_args(), $spec);
}
+ public function hidden(bool $spec = null) {
+ return $this->act(__FUNCTION__, func_num_args(), $spec);
+ }
+
public function labelled(bool $spec = null) {
return $this->act(__FUNCTION__, func_num_args(), $spec);
}
diff --git a/lib/Database.php b/lib/Database.php
index 799968fb..30a126f4 100644
--- a/lib/Database.php
+++ b/lib/Database.php
@@ -741,7 +741,7 @@ class Database {
"SELECT
s.id as id,
s.feed as feed,
- f.url,source,folder,pinned,err_count,err_msg,order_type,added,
+ f.url,source,folder,pinned,err_count,err_msg,order_type,added,keep_rule,block_rule,
f.updated as updated,
f.modified as edited,
s.modified as modified,
@@ -762,8 +762,8 @@ class Database {
// topmost folders belonging to the user
$q->setCTE("topmost(f_id,top)", "SELECT id,id from arsse_folders where owner = ? and parent is null union all select id,top from arsse_folders join topmost on parent=f_id", ["str"], [$user]);
if ($id) {
- // this condition facilitates the implementation of subscriptionPropertiesGet, which would otherwise have to duplicate the complex query; it takes precedence over a specified folder
// if an ID is specified, add a suitable WHERE condition and bindings
+ // this condition facilitates the implementation of subscriptionPropertiesGet, which would otherwise have to duplicate the complex query; it takes precedence over a specified folder
$q->setWhere("s.id = ?", "int", $id);
} elseif ($folder && $recursive) {
// if a folder is specified and we're listing recursively, add a common table expression to list it and its children so that we select from the entire subtree
@@ -1194,6 +1194,19 @@ class Database {
return $out;
}
+ /** Retrieves the set of filters users have applied to a given feed
+ *
+ * Each record includes the following keys:
+ *
+ * - "owner": The user for whom to apply the filters
+ * - "sub": The subscription ID which ties the user to the feed
+ * - "keep": The "keep" rule; any articles which fail to match this rule are hidden
+ * - "block": The block rule; any article which matches this rule are hidden
+ */
+ public function feedRulesGet(int $feedID): Db\Result {
+ return $this->db->prepare("SELECT owner, id as sub, keep_rule as keep, block_rule as block from arsse_subscriptions where feed = ? and (coalesce(keep_rule, '') || coalesce(block_rule, '')) <> ''", "int")->run($feedID);
+ }
+
/** Retrieves various identifiers for the latest $count articles in the given newsfeed. The identifiers are:
*
* - "id": The database record key for the article
@@ -1652,6 +1665,7 @@ class Database {
*
* - "read": Whether the article should be marked as read (true) or unread (false)
* - "starred": Whether the article should (true) or should not (false) be marked as starred/favourite
+ * - "hidden": Whether the article should (true) or should not (false) be suppressed from normal listings; this is normally set by the system rather than the user directly
* - "note": A string containing a freeform plain-text note for the article
*
* @param string $user The user who owns the articles to be modified
@@ -1662,22 +1676,23 @@ class Database {
$data = [
'read' => $data['read'] ?? null,
'starred' => $data['starred'] ?? null,
+ 'hidden' => $data['hidden'] ?? null,
'note' => $data['note'] ?? null,
];
- if (!isset($data['read']) && !isset($data['starred']) && !isset($data['note'])) {
+ if (!isset($data['read']) && !isset($data['starred']) && !isset($data['hidden']) && !isset($data['note'])) {
return 0;
}
$context = $context ?? new Context;
$tr = $this->begin();
$out = 0;
- if ($data['read'] || $data['starred'] || strlen($data['note'] ?? "")) {
+ if ($data['read'] || $data['starred'] || $data['hidden'] || strlen($data['note'] ?? "")) {
// first prepare a query to insert any missing marks rows for the articles we want to mark
// but only insert new mark records if we're setting at least one "positive" mark
$q = $this->articleQuery($user, $context, ["id", "subscription", "note"]);
- $q->setWhere("arsse_marks.starred is null"); // null means there is no marks row for the article
+ $q->setWhere("arsse_marks.starred is null"); // null means there is no marks row for the article, because the column is defined not-null
$this->db->prepare("INSERT INTO arsse_marks(article,subscription,note) ".$q->getQuery(), $q->getTypes())->run($q->getValues());
}
- if (isset($data['read']) && (isset($data['starred']) || isset($data['note'])) && ($context->edition() || $context->editions())) {
+ if (isset($data['read']) && (isset($data['starred']) || isset($data['hidden']) || isset($data['note'])) && ($context->edition() || $context->editions())) {
// if marking by edition both read and something else, do separate marks for starred and note than for read
// marking as read is ignored if the edition is not the latest, but the same is not true of the other two marks
$this->db->query("UPDATE arsse_marks set touched = 0 where touched <> 0");
@@ -1693,7 +1708,7 @@ class Database {
} else {
$context->articles($this->editionArticle(...$context->editions))->editions(null);
}
- // set starred and/or note marks (unless all requested editions actually do not exist)
+ // set starred, hidden, and/or note marks (unless all requested editions actually do not exist)
if ($context->article || $context->articles) {
$q = $this->articleQuery($user, $context, ["id", "subscription"]);
$q->setWhere("(arsse_marks.note <> coalesce(?,arsse_marks.note) or arsse_marks.starred <> coalesce(?,arsse_marks.starred))", ["str", "bool"], [$data['note'], $data['starred']]);
@@ -1701,7 +1716,7 @@ class Database {
$data = array_filter($data, function($v) {
return isset($v);
});
- [$set, $setTypes, $setValues] = $this->generateSet($data, ['starred' => "bool", 'note' => "str"]);
+ [$set, $setTypes, $setValues] = $this->generateSet($data, ['starred' => "bool", 'hidden' => "bool", 'note' => "str"]);
$q->setBody("UPDATE arsse_marks set touched = 1, $set where article in(select article from target_articles) and subscription in(select distinct subscription from target_articles)", $setTypes, $setValues);
$this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues());
}
@@ -1725,7 +1740,7 @@ class Database {
$data = array_filter($data, function($v) {
return isset($v);
});
- [$set, $setTypes, $setValues] = $this->generateSet($data, ['read' => "bool", 'starred' => "bool", 'note' => "str"]);
+ [$set, $setTypes, $setValues] = $this->generateSet($data, ['read' => "bool", 'starred' => "bool", 'hidden' => "bool", 'note' => "str"]);
$q->setBody("UPDATE arsse_marks set $set, modified = CURRENT_TIMESTAMP where article in(select article from target_articles) and subscription in(select distinct subscription from target_articles)", $setTypes, $setValues);
$out = $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues())->changes();
}
diff --git a/sql/MySQL/6.sql b/sql/MySQL/6.sql
index 9370e274..c2f8b532 100644
--- a/sql/MySQL/6.sql
+++ b/sql/MySQL/6.sql
@@ -8,6 +8,7 @@ alter table arsse_tokens add column data longtext default null;
alter table arsse_subscriptions add column keep_rule longtext default null;
alter table arsse_subscriptions add column block_rule longtext default null;
+alter table arsse_marks add column hidden boolean not null default 0;
alter table arsse_users add column num bigint unsigned unique;
alter table arsse_users add column admin boolean not null default 0;
diff --git a/sql/PostgreSQL/6.sql b/sql/PostgreSQL/6.sql
index f936b87a..a27b87a6 100644
--- a/sql/PostgreSQL/6.sql
+++ b/sql/PostgreSQL/6.sql
@@ -8,6 +8,7 @@ alter table arsse_tokens add column data text default null;
alter table arsse_subscriptions add column keep_rule text default null;
alter table arsse_subscriptions add column block_rule text default null;
+alter table arsse_marks add column hidden smallint not null default 0;
alter table arsse_users add column num bigint unique;
alter table arsse_users add column admin smallint not null default 0;
diff --git a/sql/SQLite3/6.sql b/sql/SQLite3/6.sql
index 752c0568..3c5f3589 100644
--- a/sql/SQLite3/6.sql
+++ b/sql/SQLite3/6.sql
@@ -6,9 +6,11 @@
-- This is a speculative addition to support OAuth login in the future
alter table arsse_tokens add column data text default null;
--- Add columns to subscriptions to store "keep" and "block" filtering rules from Miniflux
+-- Add columns to subscriptions to store "keep" and "block" filtering rules from Miniflux,
+-- as well as a column to mark articles as hidden for users
alter table arsse_subscriptions add column keep_rule text default null;
alter table arsse_subscriptions add column block_rule text default null;
+alter table arsse_marks add column hidden boolean not null default 0;
-- Add numeric identifier and admin columns to the users table
create table arsse_users_new(
diff --git a/tests/cases/Misc/TestContext.php b/tests/cases/Misc/TestContext.php
index 037ca8e1..46ecaaff 100644
--- a/tests/cases/Misc/TestContext.php
+++ b/tests/cases/Misc/TestContext.php
@@ -46,6 +46,7 @@ class TestContext extends \JKingWeb\Arsse\Test\AbstractTest {
'oldestEdition' => 1337,
'unread' => true,
'starred' => true,
+ 'hidden' => true,
'modifiedSince' => new \DateTime(),
'notModifiedSince' => new \DateTime(),
'markedSince' => new \DateTime(),
From 8ae3740d5fefab94a5435c93f63c2f189553bb36 Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Tue, 15 Dec 2020 19:28:51 -0500
Subject: [PATCH 074/366] Implement querying articles by hidden mark
---
lib/Database.php | 2 ++
tests/cases/Database/SeriesArticle.php | 30 +++++++++++++++-----------
2 files changed, 19 insertions(+), 13 deletions(-)
diff --git a/lib/Database.php b/lib/Database.php
index 30a126f4..466a0362 100644
--- a/lib/Database.php
+++ b/lib/Database.php
@@ -1312,6 +1312,7 @@ class Database {
'folder' => "coalesce(arsse_subscriptions.folder,0)",
'subscription' => "arsse_subscriptions.id",
'feed' => "arsse_subscriptions.feed",
+ 'hidden' => "coalesce(arsse_marks.hidden,0)",
'starred' => "coalesce(arsse_marks.starred,0)",
'unread' => "abs(coalesce(arsse_marks.read,0) - 1)",
'note' => "coalesce(arsse_marks.note,'')",
@@ -1417,6 +1418,7 @@ class Database {
"subscriptions" => ["subscription", "in", "int", ""],
"unread" => ["unread", "=", "bool", ""],
"starred" => ["starred", "=", "bool", ""],
+ "hidden" => ["hidden", "=", "bool", ""],
];
foreach ($options as $m => [$col, $op, $type, $pair]) {
if (!$context->$m()) {
diff --git a/tests/cases/Database/SeriesArticle.php b/tests/cases/Database/SeriesArticle.php
index 4edd8c82..a930caea 100644
--- a/tests/cases/Database/SeriesArticle.php
+++ b/tests/cases/Database/SeriesArticle.php
@@ -235,21 +235,23 @@ trait SeriesArticle {
'starred' => "bool",
'modified' => "datetime",
'note' => "str",
+ 'hidden' => "bool",
],
'rows' => [
- [1, 1,1,1,'2000-01-01 00:00:00',''],
- [5, 19,1,0,'2016-01-01 00:00:00',''],
- [5, 20,0,1,'2005-01-01 00:00:00',''],
- [7, 20,1,0,'2010-01-01 00:00:00',''],
- [8, 102,1,0,'2000-01-02 02:00:00','Note 2'],
- [9, 103,0,1,'2000-01-03 03:00:00','Note 3'],
- [9, 104,1,1,'2000-01-04 04:00:00','Note 4'],
- [10,105,0,0,'2000-01-05 05:00:00',''],
- [11, 19,0,0,'2017-01-01 00:00:00','ook'],
- [11, 20,1,0,'2017-01-01 00:00:00','eek'],
- [12, 3,0,1,'2017-01-01 00:00:00','ack'],
- [12, 4,1,1,'2017-01-01 00:00:00','ach'],
- [1, 2,0,0,'2010-01-01 00:00:00','Some Note'],
+ [1, 1,1,1,'2000-01-01 00:00:00','',0],
+ [5, 19,1,0,'2016-01-01 00:00:00','',0],
+ [5, 20,0,1,'2005-01-01 00:00:00','',0],
+ [7, 20,1,0,'2010-01-01 00:00:00','',0],
+ [8, 102,1,0,'2000-01-02 02:00:00','Note 2',0],
+ [9, 103,0,1,'2000-01-03 03:00:00','Note 3',0],
+ [9, 104,1,1,'2000-01-04 04:00:00','Note 4',0],
+ [10,105,0,0,'2000-01-05 05:00:00','',0],
+ [11, 19,0,0,'2017-01-01 00:00:00','ook',0],
+ [11, 20,1,0,'2017-01-01 00:00:00','eek',0],
+ [12, 3,0,1,'2017-01-01 00:00:00','ack',0],
+ [12, 4,1,1,'2017-01-01 00:00:00','ach',0],
+ [1, 2,0,0,'2010-01-01 00:00:00','Some Note',0],
+ [3, 5,0,0,'2000-01-01 00:00:00','',1],
],
],
'arsse_categories' => [ // author-supplied categories
@@ -443,6 +445,8 @@ trait SeriesArticle {
'Starred and Read in subscription' => [(new Context)->starred(true)->unread(false)->subscription(5), []],
'Annotated' => [(new Context)->annotated(true), [2]],
'Not annotated' => [(new Context)->annotated(false), [1,3,4,5,6,7,8,19,20]],
+ 'Hidden' => [(new Context)->hidden(true), [5]],
+ 'Not hidden' => [(new Context)->hidden(false), [1,2,3,4,6,7,8,19,20]],
'Labelled' => [(new Context)->labelled(true), [1,5,8,19,20]],
'Not labelled' => [(new Context)->labelled(false), [2,3,4,6,7]],
'Not after edition 999' => [(new Context)->subscription(5)->latestEdition(999), [19]],
From ffc98daff3a62f06b93ea5a67b71a4cfd7724c06 Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Tue, 15 Dec 2020 19:50:26 -0500
Subject: [PATCH 075/366] Adjust article marking tests to account for new
hidden mark
---
tests/cases/Database/SeriesArticle.php | 86 +++++++++++++-------------
1 file changed, 43 insertions(+), 43 deletions(-)
diff --git a/tests/cases/Database/SeriesArticle.php b/tests/cases/Database/SeriesArticle.php
index a930caea..37dc10bf 100644
--- a/tests/cases/Database/SeriesArticle.php
+++ b/tests/cases/Database/SeriesArticle.php
@@ -407,7 +407,7 @@ trait SeriesArticle {
"content", "media_url", "media_type",
"note",
];
- $this->checkTables = ['arsse_marks' => ["subscription","article","read","starred","modified","note"]];
+ $this->checkTables = ['arsse_marks' => ["subscription", "article", "read", "starred", "modified", "note", "hidden"]];
$this->user = "john.doe@example.net";
}
@@ -624,10 +624,10 @@ trait SeriesArticle {
$state['arsse_marks']['rows'][8][4] = $now;
$state['arsse_marks']['rows'][10][2] = 1;
$state['arsse_marks']['rows'][10][4] = $now;
- $state['arsse_marks']['rows'][] = [13,5,1,0,$now,''];
- $state['arsse_marks']['rows'][] = [13,6,1,0,$now,''];
- $state['arsse_marks']['rows'][] = [14,7,1,0,$now,''];
- $state['arsse_marks']['rows'][] = [14,8,1,0,$now,''];
+ $state['arsse_marks']['rows'][] = [13,5,1,0,$now,'',0];
+ $state['arsse_marks']['rows'][] = [13,6,1,0,$now,'',0];
+ $state['arsse_marks']['rows'][] = [14,7,1,0,$now,'',0];
+ $state['arsse_marks']['rows'][] = [14,8,1,0,$now,'',0];
$this->compareExpectations(static::$drv, $state);
}
@@ -650,10 +650,10 @@ trait SeriesArticle {
$state['arsse_marks']['rows'][8][4] = $now;
$state['arsse_marks']['rows'][9][3] = 1;
$state['arsse_marks']['rows'][9][4] = $now;
- $state['arsse_marks']['rows'][] = [13,5,0,1,$now,''];
- $state['arsse_marks']['rows'][] = [13,6,0,1,$now,''];
- $state['arsse_marks']['rows'][] = [14,7,0,1,$now,''];
- $state['arsse_marks']['rows'][] = [14,8,0,1,$now,''];
+ $state['arsse_marks']['rows'][] = [13,5,0,1,$now,'',0];
+ $state['arsse_marks']['rows'][] = [13,6,0,1,$now,'',0];
+ $state['arsse_marks']['rows'][] = [14,7,0,1,$now,'',0];
+ $state['arsse_marks']['rows'][] = [14,8,0,1,$now,'',0];
$this->compareExpectations(static::$drv, $state);
}
@@ -682,10 +682,10 @@ trait SeriesArticle {
$state['arsse_marks']['rows'][9][4] = $now;
$state['arsse_marks']['rows'][10][2] = 1;
$state['arsse_marks']['rows'][10][4] = $now;
- $state['arsse_marks']['rows'][] = [13,5,1,1,$now,''];
- $state['arsse_marks']['rows'][] = [13,6,1,1,$now,''];
- $state['arsse_marks']['rows'][] = [14,7,1,1,$now,''];
- $state['arsse_marks']['rows'][] = [14,8,1,1,$now,''];
+ $state['arsse_marks']['rows'][] = [13,5,1,1,$now,'',0];
+ $state['arsse_marks']['rows'][] = [13,6,1,1,$now,'',0];
+ $state['arsse_marks']['rows'][] = [14,7,1,1,$now,'',0];
+ $state['arsse_marks']['rows'][] = [14,8,1,1,$now,'',0];
$this->compareExpectations(static::$drv, $state);
}
@@ -700,10 +700,10 @@ trait SeriesArticle {
$state['arsse_marks']['rows'][9][4] = $now;
$state['arsse_marks']['rows'][11][2] = 0;
$state['arsse_marks']['rows'][11][4] = $now;
- $state['arsse_marks']['rows'][] = [13,5,0,1,$now,''];
- $state['arsse_marks']['rows'][] = [13,6,0,1,$now,''];
- $state['arsse_marks']['rows'][] = [14,7,0,1,$now,''];
- $state['arsse_marks']['rows'][] = [14,8,0,1,$now,''];
+ $state['arsse_marks']['rows'][] = [13,5,0,1,$now,'',0];
+ $state['arsse_marks']['rows'][] = [13,6,0,1,$now,'',0];
+ $state['arsse_marks']['rows'][] = [14,7,0,1,$now,'',0];
+ $state['arsse_marks']['rows'][] = [14,8,0,1,$now,'',0];
$this->compareExpectations(static::$drv, $state);
}
@@ -718,10 +718,10 @@ trait SeriesArticle {
$state['arsse_marks']['rows'][10][4] = $now;
$state['arsse_marks']['rows'][11][3] = 0;
$state['arsse_marks']['rows'][11][4] = $now;
- $state['arsse_marks']['rows'][] = [13,5,1,0,$now,''];
- $state['arsse_marks']['rows'][] = [13,6,1,0,$now,''];
- $state['arsse_marks']['rows'][] = [14,7,1,0,$now,''];
- $state['arsse_marks']['rows'][] = [14,8,1,0,$now,''];
+ $state['arsse_marks']['rows'][] = [13,5,1,0,$now,'',0];
+ $state['arsse_marks']['rows'][] = [13,6,1,0,$now,'',0];
+ $state['arsse_marks']['rows'][] = [14,7,1,0,$now,'',0];
+ $state['arsse_marks']['rows'][] = [14,8,1,0,$now,'',0];
$this->compareExpectations(static::$drv, $state);
}
@@ -737,10 +737,10 @@ trait SeriesArticle {
$state['arsse_marks']['rows'][10][4] = $now;
$state['arsse_marks']['rows'][11][5] = "New note";
$state['arsse_marks']['rows'][11][4] = $now;
- $state['arsse_marks']['rows'][] = [13,5,0,0,$now,'New note'];
- $state['arsse_marks']['rows'][] = [13,6,0,0,$now,'New note'];
- $state['arsse_marks']['rows'][] = [14,7,0,0,$now,'New note'];
- $state['arsse_marks']['rows'][] = [14,8,0,0,$now,'New note'];
+ $state['arsse_marks']['rows'][] = [13,5,0,0,$now,'New note',0];
+ $state['arsse_marks']['rows'][] = [13,6,0,0,$now,'New note',0];
+ $state['arsse_marks']['rows'][] = [14,7,0,0,$now,'New note',0];
+ $state['arsse_marks']['rows'][] = [14,8,0,0,$now,'New note',0];
$this->compareExpectations(static::$drv, $state);
}
@@ -748,10 +748,10 @@ trait SeriesArticle {
Arsse::$db->articleMark($this->user, ['read' => true], (new Context)->folder(7));
$now = Date::transform(time(), "sql");
$state = $this->primeExpectations($this->data, $this->checkTables);
- $state['arsse_marks']['rows'][] = [13,5,1,0,$now,''];
- $state['arsse_marks']['rows'][] = [13,6,1,0,$now,''];
- $state['arsse_marks']['rows'][] = [14,7,1,0,$now,''];
- $state['arsse_marks']['rows'][] = [14,8,1,0,$now,''];
+ $state['arsse_marks']['rows'][] = [13,5,1,0,$now,'',0];
+ $state['arsse_marks']['rows'][] = [13,6,1,0,$now,'',0];
+ $state['arsse_marks']['rows'][] = [14,7,1,0,$now,'',0];
+ $state['arsse_marks']['rows'][] = [14,8,1,0,$now,'',0];
$this->compareExpectations(static::$drv, $state);
}
@@ -759,8 +759,8 @@ trait SeriesArticle {
Arsse::$db->articleMark($this->user, ['read' => true], (new Context)->folder(8));
$now = Date::transform(time(), "sql");
$state = $this->primeExpectations($this->data, $this->checkTables);
- $state['arsse_marks']['rows'][] = [13,5,1,0,$now,''];
- $state['arsse_marks']['rows'][] = [13,6,1,0,$now,''];
+ $state['arsse_marks']['rows'][] = [13,5,1,0,$now,'',0];
+ $state['arsse_marks']['rows'][] = [13,6,1,0,$now,'',0];
$this->compareExpectations(static::$drv, $state);
}
@@ -773,8 +773,8 @@ trait SeriesArticle {
Arsse::$db->articleMark($this->user, ['read' => true], (new Context)->subscription(13));
$now = Date::transform(time(), "sql");
$state = $this->primeExpectations($this->data, $this->checkTables);
- $state['arsse_marks']['rows'][] = [13,5,1,0,$now,''];
- $state['arsse_marks']['rows'][] = [13,6,1,0,$now,''];
+ $state['arsse_marks']['rows'][] = [13,5,1,0,$now,'',0];
+ $state['arsse_marks']['rows'][] = [13,6,1,0,$now,'',0];
$this->compareExpectations(static::$drv, $state);
}
@@ -798,7 +798,7 @@ trait SeriesArticle {
$state = $this->primeExpectations($this->data, $this->checkTables);
$state['arsse_marks']['rows'][9][3] = 1;
$state['arsse_marks']['rows'][9][4] = $now;
- $state['arsse_marks']['rows'][] = [14,7,0,1,$now,''];
+ $state['arsse_marks']['rows'][] = [14,7,0,1,$now,'',0];
$this->compareExpectations(static::$drv, $state);
}
@@ -811,7 +811,7 @@ trait SeriesArticle {
$state['arsse_marks']['rows'][9][4] = $now;
$state['arsse_marks']['rows'][11][2] = 0;
$state['arsse_marks']['rows'][11][4] = $now;
- $state['arsse_marks']['rows'][] = [14,7,0,1,$now,''];
+ $state['arsse_marks']['rows'][] = [14,7,0,1,$now,'',0];
$this->compareExpectations(static::$drv, $state);
}
@@ -840,7 +840,7 @@ trait SeriesArticle {
$state = $this->primeExpectations($this->data, $this->checkTables);
$state['arsse_marks']['rows'][9][3] = 1;
$state['arsse_marks']['rows'][9][4] = $now;
- $state['arsse_marks']['rows'][] = [14,7,0,1,$now,''];
+ $state['arsse_marks']['rows'][] = [14,7,0,1,$now,'',0];
$this->compareExpectations(static::$drv, $state);
}
@@ -878,7 +878,7 @@ trait SeriesArticle {
$state['arsse_marks']['rows'][9][4] = $now;
$state['arsse_marks']['rows'][11][2] = 0;
$state['arsse_marks']['rows'][11][4] = $now;
- $state['arsse_marks']['rows'][] = [14,7,0,1,$now,''];
+ $state['arsse_marks']['rows'][] = [14,7,0,1,$now,'',0];
$this->compareExpectations(static::$drv, $state);
}
@@ -938,10 +938,10 @@ trait SeriesArticle {
$state = $this->primeExpectations($this->data, $this->checkTables);
$state['arsse_marks']['rows'][8][3] = 1;
$state['arsse_marks']['rows'][8][4] = $now;
- $state['arsse_marks']['rows'][] = [13,5,0,1,$now,''];
- $state['arsse_marks']['rows'][] = [13,6,0,1,$now,''];
- $state['arsse_marks']['rows'][] = [14,7,0,1,$now,''];
- $state['arsse_marks']['rows'][] = [14,8,0,1,$now,''];
+ $state['arsse_marks']['rows'][] = [13,5,0,1,$now,'',0];
+ $state['arsse_marks']['rows'][] = [13,6,0,1,$now,'',0];
+ $state['arsse_marks']['rows'][] = [14,7,0,1,$now,'',0];
+ $state['arsse_marks']['rows'][] = [14,8,0,1,$now,'',0];
$this->compareExpectations(static::$drv, $state);
}
@@ -960,8 +960,8 @@ trait SeriesArticle {
Arsse::$db->articleMark($this->user, ['starred' => true], (new Context)->notMarkedSince('2000-01-01T00:00:00Z'));
$now = Date::transform(time(), "sql");
$state = $this->primeExpectations($this->data, $this->checkTables);
- $state['arsse_marks']['rows'][] = [13,5,0,1,$now,''];
- $state['arsse_marks']['rows'][] = [14,7,0,1,$now,''];
+ $state['arsse_marks']['rows'][] = [13,5,0,1,$now,'',0];
+ $state['arsse_marks']['rows'][] = [14,7,0,1,$now,'',0];
$this->compareExpectations(static::$drv, $state);
}
From 86c4a30744fe838a86572031e6dd5a616ec76104 Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Thu, 17 Dec 2020 18:12:52 -0500
Subject: [PATCH 076/366] Adjust articleStarred function to discount hidden
---
lib/Database.php | 4 ++--
tests/cases/Database/SeriesArticle.php | 3 ++-
2 files changed, 4 insertions(+), 3 deletions(-)
diff --git a/lib/Database.php b/lib/Database.php
index 466a0362..55c21e0f 100644
--- a/lib/Database.php
+++ b/lib/Database.php
@@ -1750,7 +1750,7 @@ class Database {
return $out;
}
- /** Returns statistics about the articles starred by the given user
+ /** Returns statistics about the articles starred by the given user. Hidden articles are excluded
*
* The associative array returned has the following keys:
*
@@ -1765,7 +1765,7 @@ class Database {
coalesce(sum(abs(\"read\" - 1)),0) as unread,
coalesce(sum(\"read\"),0) as \"read\"
FROM (
- select \"read\" from arsse_marks where starred = 1 and subscription in (select id from arsse_subscriptions where owner = ?)
+ select \"read\" from arsse_marks where starred = 1 and hidden <> 1 and subscription in (select id from arsse_subscriptions where owner = ?)
) as starred_data",
"str"
)->run($user)->getRow();
diff --git a/tests/cases/Database/SeriesArticle.php b/tests/cases/Database/SeriesArticle.php
index 37dc10bf..a6b6bdbd 100644
--- a/tests/cases/Database/SeriesArticle.php
+++ b/tests/cases/Database/SeriesArticle.php
@@ -252,6 +252,7 @@ trait SeriesArticle {
[12, 4,1,1,'2017-01-01 00:00:00','ach',0],
[1, 2,0,0,'2010-01-01 00:00:00','Some Note',0],
[3, 5,0,0,'2000-01-01 00:00:00','',1],
+ [6, 1,0,1,'2010-01-01 00:00:00','',1],
],
],
'arsse_categories' => [ // author-supplied categories
@@ -969,7 +970,7 @@ trait SeriesArticle {
$setSize = (new \ReflectionClassConstant(Database::class, "LIMIT_SET_SIZE"))->getValue();
$this->assertSame(2, Arsse::$db->articleCount("john.doe@example.com", (new Context)->starred(true)));
$this->assertSame(4, Arsse::$db->articleCount("john.doe@example.com", (new Context)->folder(1)));
- $this->assertSame(0, Arsse::$db->articleCount("jane.doe@example.com", (new Context)->starred(true)));
+ $this->assertSame(1, Arsse::$db->articleCount("jane.doe@example.com", (new Context)->starred(true)));
$this->assertSame(10, Arsse::$db->articleCount("john.doe@example.com", (new Context)->articles(range(1, $setSize * 3))));
}
From 97010d882290f3098f961f32df10f8e6d4c17073 Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Sat, 19 Dec 2020 10:59:40 -0500
Subject: [PATCH 077/366] Tests for marking articles hidden
---
lib/Database.php | 4 +-
tests/cases/Database/SeriesArticle.php | 113 ++++++++++++++++++++++++-
2 files changed, 114 insertions(+), 3 deletions(-)
diff --git a/lib/Database.php b/lib/Database.php
index 55c21e0f..08e3f050 100644
--- a/lib/Database.php
+++ b/lib/Database.php
@@ -1713,7 +1713,7 @@ class Database {
// set starred, hidden, and/or note marks (unless all requested editions actually do not exist)
if ($context->article || $context->articles) {
$q = $this->articleQuery($user, $context, ["id", "subscription"]);
- $q->setWhere("(arsse_marks.note <> coalesce(?,arsse_marks.note) or arsse_marks.starred <> coalesce(?,arsse_marks.starred))", ["str", "bool"], [$data['note'], $data['starred']]);
+ $q->setWhere("(arsse_marks.note <> coalesce(?,arsse_marks.note) or arsse_marks.starred <> coalesce(?,arsse_marks.starred) or arsse_marks.hidden <> coalesce(?,arsse_marks.hidden))", ["str", "bool", "bool"], [$data['note'], $data['starred'], $data['hidden']]);
$q->pushCTE("target_articles(article,subscription)");
$data = array_filter($data, function($v) {
return isset($v);
@@ -1737,7 +1737,7 @@ class Database {
}
}
$q = $this->articleQuery($user, $context, ["id", "subscription"]);
- $q->setWhere("(arsse_marks.note <> coalesce(?,arsse_marks.note) or arsse_marks.starred <> coalesce(?,arsse_marks.starred) or arsse_marks.read <> coalesce(?,arsse_marks.read))", ["str", "bool", "bool"], [$data['note'], $data['starred'], $data['read']]);
+ $q->setWhere("(arsse_marks.note <> coalesce(?,arsse_marks.note) or arsse_marks.starred <> coalesce(?,arsse_marks.starred) or arsse_marks.read <> coalesce(?,arsse_marks.read) or arsse_marks.hidden <> coalesce(?,arsse_marks.hidden))", ["str", "bool", "bool", "bool"], [$data['note'], $data['starred'], $data['read'], $data['hidden']]);
$q->pushCTE("target_articles(article,subscription)");
$data = array_filter($data, function($v) {
return isset($v);
diff --git a/tests/cases/Database/SeriesArticle.php b/tests/cases/Database/SeriesArticle.php
index a6b6bdbd..0653b580 100644
--- a/tests/cases/Database/SeriesArticle.php
+++ b/tests/cases/Database/SeriesArticle.php
@@ -253,6 +253,7 @@ trait SeriesArticle {
[1, 2,0,0,'2010-01-01 00:00:00','Some Note',0],
[3, 5,0,0,'2000-01-01 00:00:00','',1],
[6, 1,0,1,'2010-01-01 00:00:00','',1],
+ [6, 2,1,0,'2010-01-01 00:00:00','',1],
],
],
'arsse_categories' => [ // author-supplied categories
@@ -1035,4 +1036,114 @@ trait SeriesArticle {
yield [$method];
}
}
-}
+
+ public function testMarkAllArticlesNotHidden(): void {
+ Arsse::$db->articleMark("jane.doe@example.com", ['hidden' => false]);
+ $now = Date::transform(time(), "sql");
+ $state = $this->primeExpectations($this->data, $this->checkTables);
+ $state['arsse_marks']['rows'][14][6] = 0;
+ $state['arsse_marks']['rows'][14][4] = $now;
+ $state['arsse_marks']['rows'][15][6] = 0;
+ $state['arsse_marks']['rows'][15][4] = $now;
+ $this->compareExpectations(static::$drv, $state);
+ }
+
+ public function testMarkAllArticlesHidden(): void {
+ Arsse::$db->articleMark("jane.doe@example.com", ['hidden' => true]);
+ $now = Date::transform(time(), "sql");
+ $state = $this->primeExpectations($this->data, $this->checkTables);
+ $state['arsse_marks']['rows'][3][6] = 1;
+ $state['arsse_marks']['rows'][3][4] = $now;
+ $state['arsse_marks']['rows'][] = [7,19,0,0,$now,'',1];
+ $this->compareExpectations(static::$drv, $state);
+ }
+
+ public function testMarkAllArticlesUnreadAndNotHidden(): void {
+ Arsse::$db->articleMark("jane.doe@example.com", ['read' => false, 'hidden' => false]);
+ $now = Date::transform(time(), "sql");
+ $state = $this->primeExpectations($this->data, $this->checkTables);
+ $state['arsse_marks']['rows'][3][2] = 0;
+ $state['arsse_marks']['rows'][3][4] = $now;
+ $state['arsse_marks']['rows'][14][6] = 0;
+ $state['arsse_marks']['rows'][14][4] = $now;
+ $state['arsse_marks']['rows'][15][2] = 0;
+ $state['arsse_marks']['rows'][15][6] = 0;
+ $state['arsse_marks']['rows'][15][4] = $now;
+ $this->compareExpectations(static::$drv, $state);
+ }
+
+ public function testMarkAllArticlesReadAndHidden(): void {
+ Arsse::$db->articleMark("jane.doe@example.com", ['read' => true, 'hidden' => true]);
+ $now = Date::transform(time(), "sql");
+ $state = $this->primeExpectations($this->data, $this->checkTables);
+ $state['arsse_marks']['rows'][3][6] = 1;
+ $state['arsse_marks']['rows'][3][4] = $now;
+ $state['arsse_marks']['rows'][14][2] = 1;
+ $state['arsse_marks']['rows'][14][4] = $now;
+ $state['arsse_marks']['rows'][] = [7,19,1,0,$now,'',1];
+ $this->compareExpectations(static::$drv, $state);
+ }
+
+ public function testMarkAllArticlesUnreadAndHidden(): void {
+ Arsse::$db->articleMark("jane.doe@example.com", ['read' => false,'hidden' => true]);
+ $now = Date::transform(time(), "sql");
+ $state = $this->primeExpectations($this->data, $this->checkTables);
+ $state['arsse_marks']['rows'][3][2] = 0;
+ $state['arsse_marks']['rows'][3][6] = 1;
+ $state['arsse_marks']['rows'][3][4] = $now;
+ $state['arsse_marks']['rows'][15][2] = 0;
+ $state['arsse_marks']['rows'][15][4] = $now;
+ $state['arsse_marks']['rows'][] = [7,19,0,0,$now,'',1];
+ $this->compareExpectations(static::$drv, $state);
+ }
+
+ public function testMarkAllArticlesReadAndNotHidden(): void {
+ Arsse::$db->articleMark("jane.doe@example.com", ['read' => true,'hidden' => false]);
+ $now = Date::transform(time(), "sql");
+ $state = $this->primeExpectations($this->data, $this->checkTables);
+ $state['arsse_marks']['rows'][14][2] = 1;
+ $state['arsse_marks']['rows'][14][6] = 0;
+ $state['arsse_marks']['rows'][14][4] = $now;
+ $state['arsse_marks']['rows'][15][6] = 0;
+ $state['arsse_marks']['rows'][15][4] = $now;
+ $state['arsse_marks']['rows'][] = [7,19,1,0,$now,'',0];
+ $this->compareExpectations(static::$drv, $state);
+ }
+
+ public function testMarkMultipleEditionsUnreadAndHiddenWithStale(): void {
+ Arsse::$db->articleMark("jane.doe@example.com", ['read' => false,'hidden' => true], (new Context)->editions([1,2,19,20]));
+ $now = Date::transform(time(), "sql");
+ $state = $this->primeExpectations($this->data, $this->checkTables);
+ $state['arsse_marks']['rows'][3][6] = 1;
+ $state['arsse_marks']['rows'][3][4] = $now;
+ $state['arsse_marks']['rows'][15][2] = 0;
+ $state['arsse_marks']['rows'][15][6] = 1;
+ $state['arsse_marks']['rows'][15][4] = $now;
+ $state['arsse_marks']['rows'][] = [7,19,0,0,$now,'',1];
+ $this->compareExpectations(static::$drv, $state);
+ }
+
+ public function testMarkAStaleEditionHidden(): void {
+ Arsse::$db->articleMark("jane.doe@example.com", ['hidden' => true], (new Context)->edition(20));
+ $now = Date::transform(time(), "sql");
+ $state = $this->primeExpectations($this->data, $this->checkTables);
+ $state['arsse_marks']['rows'][3][6] = 1;
+ $state['arsse_marks']['rows'][3][4] = $now;
+ $this->compareExpectations(static::$drv, $state);
+ }
+
+ public function testMarkAStaleEditionUnreadAndHidden(): void {
+ Arsse::$db->articleMark("jane.doe@example.com", ['read' => false,'hidden' => true], (new Context)->edition(20)); // only starred is changed
+ $now = Date::transform(time(), "sql");
+ $state = $this->primeExpectations($this->data, $this->checkTables);
+ $state['arsse_marks']['rows'][3][6] = 1;
+ $state['arsse_marks']['rows'][3][4] = $now;
+ $this->compareExpectations(static::$drv, $state);
+ }
+
+ public function testMarkAStaleEditionUnreadAndNotHidden(): void {
+ Arsse::$db->articleMark("jane.doe@example.com", ['read' => false,'hidden' => false], (new Context)->edition(20)); // no changes occur
+ $state = $this->primeExpectations($this->data, $this->checkTables);
+ $this->compareExpectations(static::$drv, $state);
+ }
+}
\ No newline at end of file
From 8527c83976e7a06980d85b72794a6bdcba33bf0a Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Sun, 20 Dec 2020 11:55:36 -0500
Subject: [PATCH 078/366] Exclude hiddens from subscription unread count
Also fix a bug that would result in the unread count being null if
no marks existed
---
CHANGELOG | 1 +
lib/Database.php | 11 +++++++++--
tests/cases/Database/SeriesSubscription.php | 19 +++++++++++++++++--
3 files changed, 27 insertions(+), 4 deletions(-)
diff --git a/CHANGELOG b/CHANGELOG
index 3b65066b..a3847c4f 100644
--- a/CHANGELOG
+++ b/CHANGELOG
@@ -3,6 +3,7 @@ Version 0.9.0 (????-??-??)
Bug fixes:
- Use icons specified in Atom feeds when available
+- Do not return null as subscription unread count
Changes:
- Explicitly forbid U+003A COLON in usernames, for compatibility with HTTP
diff --git a/lib/Database.php b/lib/Database.php
index 08e3f050..9ad0eb2c 100644
--- a/lib/Database.php
+++ b/lib/Database.php
@@ -748,13 +748,20 @@ class Database {
i.url as favicon,
t.top as top_folder,
coalesce(s.title, f.title) as title,
- (articles - marked) as unread
+ coalesce((articles - hidden - marked + hidden_marked), articles) as unread
FROM arsse_subscriptions as s
left join topmost as t on t.f_id = s.folder
join arsse_feeds as f on f.id = s.feed
left join arsse_icons as i on i.id = f.icon
left join (select feed, count(*) as articles from arsse_articles group by feed) as article_stats on article_stats.feed = s.feed
- left join (select subscription, sum(\"read\") as marked from arsse_marks group by subscription) as mark_stats on mark_stats.subscription = s.id"
+ left join (
+ select
+ subscription,
+ sum(cast((\"read\" = 1 and hidden = 0) as integer)) as marked,
+ sum(cast((\"read\" = 0 and hidden = 1) as integer)) as hidden,
+ sum(cast((\"read\" = 1 and hidden = 1) as integer)) as hidden_marked
+ from arsse_marks group by subscription
+ ) as mark_stats on mark_stats.subscription = s.id"
);
$q->setWhere("s.owner = ?", ["str"], [$user]);
$nocase = $this->db->sqlToken("nocase");
diff --git a/tests/cases/Database/SeriesSubscription.php b/tests/cases/Database/SeriesSubscription.php
index 749c8752..0e14a7bf 100644
--- a/tests/cases/Database/SeriesSubscription.php
+++ b/tests/cases/Database/SeriesSubscription.php
@@ -21,8 +21,9 @@ trait SeriesSubscription {
'num' => 'int',
],
'rows' => [
- ["jane.doe@example.com", "",1],
- ["john.doe@example.com", "",2],
+ ["jane.doe@example.com", "", 1],
+ ["john.doe@example.com", "", 2],
+ ["jill.doe@example.com", "", 3]
],
],
'arsse_folders' => [
@@ -81,6 +82,7 @@ trait SeriesSubscription {
[1,"john.doe@example.com",2,null,null,1,2],
[2,"jane.doe@example.com",2,null,null,0,0],
[3,"john.doe@example.com",3,"Ook",2,0,1],
+ [4,"jill.doe@example.com",2,null,null,0,0],
],
],
'arsse_tags' => [
@@ -291,6 +293,19 @@ trait SeriesSubscription {
$this->assertResult($exp, Arsse::$db->subscriptionList($this->user));
$this->assertArraySubset($exp[0], Arsse::$db->subscriptionPropertiesGet($this->user, 1));
$this->assertArraySubset($exp[1], Arsse::$db->subscriptionPropertiesGet($this->user, 3));
+ // test that an absence of marks does not corrupt unread count
+ $exp = [
+ [
+ 'url' => "http://example.com/feed2",
+ 'title' => "eek",
+ 'folder' => null,
+ 'top_folder' => null,
+ 'unread' => 5,
+ 'pinned' => 0,
+ 'order_type' => 0,
+ ],
+ ];
+ $this->assertResult($exp, Arsse::$db->subscriptionList("jill.doe@example.com"));
}
public function testListSubscriptionsInAFolder(): void {
From f0bfe1fdff9c868327511009c2564153198bc443 Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Sun, 20 Dec 2020 17:34:32 -0500
Subject: [PATCH 079/366] Simplify editionLatest Database method
Also adjust label querying to take hidden marks into account
---
lib/Database.php | 76 +++++++++++++++-----------
tests/cases/Database/SeriesArticle.php | 7 ++-
tests/cases/Database/SeriesLabel.php | 27 +++++----
3 files changed, 64 insertions(+), 46 deletions(-)
diff --git a/lib/Database.php b/lib/Database.php
index 9ad0eb2c..32e88d33 100644
--- a/lib/Database.php
+++ b/lib/Database.php
@@ -79,7 +79,11 @@ class Database {
/** Returns the bare name of the calling context's calling method, when __FUNCTION__ is not appropriate */
protected function caller(): string {
- return debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 3)[2]['function'];
+ $trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 4);
+ if ($trace[2]['function'] === "articleQuery") {
+ return $trace[3]['function'];
+ }
+ return $trace[2]['function'];
}
/** Returns the current (actual) schema version of the database; compared against self::SCHEMA_VERSION to know when an upgrade is required */
@@ -748,7 +752,7 @@ class Database {
i.url as favicon,
t.top as top_folder,
coalesce(s.title, f.title) as title,
- coalesce((articles - hidden - marked + hidden_marked), articles) as unread
+ coalesce((articles - hidden - marked), articles) as unread
FROM arsse_subscriptions as s
left join topmost as t on t.f_id = s.folder
join arsse_feeds as f on f.id = s.feed
@@ -757,9 +761,8 @@ class Database {
left join (
select
subscription,
- sum(cast((\"read\" = 1 and hidden = 0) as integer)) as marked,
- sum(cast((\"read\" = 0 and hidden = 1) as integer)) as hidden,
- sum(cast((\"read\" = 1 and hidden = 1) as integer)) as hidden_marked
+ sum(hidden) as hidden,
+ sum(cast((\"read\" = 1 and hidden = 0) as integer)) as marked
from arsse_marks group by subscription
) as mark_stats on mark_stats.subscription = s.id"
);
@@ -1206,12 +1209,11 @@ class Database {
* Each record includes the following keys:
*
* - "owner": The user for whom to apply the filters
- * - "sub": The subscription ID which ties the user to the feed
* - "keep": The "keep" rule; any articles which fail to match this rule are hidden
* - "block": The block rule; any article which matches this rule are hidden
*/
public function feedRulesGet(int $feedID): Db\Result {
- return $this->db->prepare("SELECT owner, id as sub, keep_rule as keep, block_rule as block from arsse_subscriptions where feed = ? and (coalesce(keep_rule, '') || coalesce(block_rule, '')) <> ''", "int")->run($feedID);
+ return $this->db->prepare("SELECT owner, keep_rule as keep, block_rule as block from arsse_subscriptions where feed = ? and (coalesce(keep_rule, '') || coalesce(block_rule, '')) <> ''", "int")->run($feedID);
}
/** Retrieves various identifiers for the latest $count articles in the given newsfeed. The identifiers are:
@@ -1310,6 +1312,7 @@ class Database {
return [
'id' => "arsse_articles.id",
'edition' => "latest_editions.edition",
+ 'latest_edition' => "max(latest_editions.edition)",
'url' => "arsse_articles.url",
'title' => "arsse_articles.title",
'author' => "arsse_articles.author",
@@ -1385,6 +1388,7 @@ class Database {
}
$outColumns = implode(",", $outColumns);
}
+ assert(strlen($outColumns) > 0, new \Exception("No input columns matched whitelist"));
// define the basic query, to which we add lots of stuff where necessary
$q = new Query(
"SELECT
@@ -1895,14 +1899,8 @@ class Database {
/** Returns the numeric identifier of the most recent edition of an article matching the given context */
public function editionLatest(string $user, Context $context = null): int {
$context = $context ?? new Context;
- $q = new Query("SELECT max(arsse_editions.id) from arsse_editions left join arsse_articles on article = arsse_articles.id join arsse_subscriptions on arsse_articles.feed = arsse_subscriptions.feed and arsse_subscriptions.owner = ?", "str", $user);
- if ($context->subscription()) {
- // if a subscription is specified, make sure it exists
- $this->subscriptionValidateId($user, $context->subscription);
- // a simple WHERE clause is required here
- $q->setWhere("arsse_subscriptions.id = ?", "int", $context->subscription);
- }
- return (int) $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues())->getValue();
+ $q = $this->articleQuery($user, $context, ["latest_edition"]);
+ return (int) $this->db->prepare((string) $q, $q->getTypes())->run($q->getValues())->getValue();
}
/** Returns a map between all the given edition identifiers and their associated article identifiers */
@@ -1945,14 +1943,19 @@ class Database {
return $this->db->prepare(
"SELECT * FROM (
SELECT
- id,name,coalesce(articles,0) as articles,coalesce(marked,0) as \"read\"
+ id,
+ name,
+ coalesce(articles - coalesce(hidden, 0), 0) as articles,
+ coalesce(marked, 0) as \"read\"
from arsse_labels
left join (
SELECT label, sum(assigned) as articles from arsse_label_members group by label
) as label_stats on label_stats.label = arsse_labels.id
left join (
- SELECT
- label, sum(\"read\") as marked
+ SELECT
+ label,
+ sum(hidden) as hidden,
+ sum(cast((\"read\" = 1 and hidden = 0) as integer)) as marked
from arsse_marks
join arsse_subscriptions on arsse_subscriptions.id = arsse_marks.subscription
join arsse_label_members on arsse_label_members.article = arsse_marks.article
@@ -2007,14 +2010,19 @@ class Database {
$type = $byName ? "str" : "int";
$out = $this->db->prepare(
"SELECT
- id,name,coalesce(articles,0) as articles,coalesce(marked,0) as \"read\"
+ id,
+ name,
+ coalesce(articles - coalesce(hidden, 0), 0) as articles,
+ coalesce(marked, 0) as \"read\"
FROM arsse_labels
left join (
SELECT label, sum(assigned) as articles from arsse_label_members group by label
) as label_stats on label_stats.label = arsse_labels.id
left join (
- SELECT
- label, sum(\"read\") as marked
+ SELECT
+ label,
+ sum(hidden) as hidden,
+ sum(cast((\"read\" = 1 and hidden = 0) as integer)) as marked
from arsse_marks
join arsse_subscriptions on arsse_subscriptions.id = arsse_marks.subscription
join arsse_label_members on arsse_label_members.article = arsse_marks.article
@@ -2069,19 +2077,25 @@ class Database {
* @param boolean $byName Whether to interpret the $id parameter as the label's name (true) or identifier (false)
*/
public function labelArticlesGet(string $user, $id, bool $byName = false): array {
- // just do a syntactic check on the label ID
- $this->labelValidateId($user, $id, $byName, false);
- $field = !$byName ? "id" : "name";
- $type = !$byName ? "int" : "str";
- $out = $this->db->prepare("SELECT article from arsse_label_members join arsse_labels on label = id where assigned = 1 and $field = ? and owner = ? order by article", $type, "str")->run($id, $user)->getAll();
+ $c = (new Context)->hidden(false);
+ if ($byName) {
+ $c->labelName($id);
+ } else {
+ $c->label($id);
+ }
+ try {
+ $q = $this->articleQuery($user, $c);
+ $out = $this->db->prepare((string) $q, $q->getTypes())->run($q->getValues())->getAll();
+ } catch (Db\ExceptionInput $e) {
+ if ($e->getCode() === 10235) {
+ throw new Db\ExceptionInput("subjectMissing", ["action" => __FUNCTION__, "field" => "label", 'id' => $id]);
+ }
+ throw $e;
+ }
if (!$out) {
- // if no results were returned, do a full validation on the label ID
- $this->labelValidateId($user, $id, $byName, true, true);
- // if the validation passes, return the empty result
return $out;
} else {
- // flatten the result to return just the article IDs in a simple array
- return array_column($out, "article");
+ return array_column($out, "id");
}
}
diff --git a/tests/cases/Database/SeriesArticle.php b/tests/cases/Database/SeriesArticle.php
index 0653b580..033bbfde 100644
--- a/tests/cases/Database/SeriesArticle.php
+++ b/tests/cases/Database/SeriesArticle.php
@@ -251,7 +251,7 @@ trait SeriesArticle {
[12, 3,0,1,'2017-01-01 00:00:00','ack',0],
[12, 4,1,1,'2017-01-01 00:00:00','ach',0],
[1, 2,0,0,'2010-01-01 00:00:00','Some Note',0],
- [3, 5,0,0,'2000-01-01 00:00:00','',1],
+ [3, 6,0,0,'2000-01-01 00:00:00','',1],
[6, 1,0,1,'2010-01-01 00:00:00','',1],
[6, 2,1,0,'2010-01-01 00:00:00','',1],
],
@@ -447,8 +447,8 @@ trait SeriesArticle {
'Starred and Read in subscription' => [(new Context)->starred(true)->unread(false)->subscription(5), []],
'Annotated' => [(new Context)->annotated(true), [2]],
'Not annotated' => [(new Context)->annotated(false), [1,3,4,5,6,7,8,19,20]],
- 'Hidden' => [(new Context)->hidden(true), [5]],
- 'Not hidden' => [(new Context)->hidden(false), [1,2,3,4,6,7,8,19,20]],
+ 'Hidden' => [(new Context)->hidden(true), [6]],
+ 'Not hidden' => [(new Context)->hidden(false), [1,2,3,4,5,7,8,19,20]],
'Labelled' => [(new Context)->labelled(true), [1,5,8,19,20]],
'Not labelled' => [(new Context)->labelled(false), [2,3,4,6,7]],
'Not after edition 999' => [(new Context)->subscription(5)->latestEdition(999), [19]],
@@ -985,6 +985,7 @@ trait SeriesArticle {
public function testFetchLatestEdition(): void {
$this->assertSame(1001, Arsse::$db->editionLatest($this->user));
$this->assertSame(4, Arsse::$db->editionLatest($this->user, (new Context)->subscription(12)));
+ $this->assertSame(5, Arsse::$db->editionLatest("john.doe@example.com", (new Context)->subscription(3)->hidden(false)));
}
public function testFetchLatestEditionOfMissingSubscription(): void {
diff --git a/tests/cases/Database/SeriesLabel.php b/tests/cases/Database/SeriesLabel.php
index 58f3c979..ec82613c 100644
--- a/tests/cases/Database/SeriesLabel.php
+++ b/tests/cases/Database/SeriesLabel.php
@@ -194,20 +194,22 @@ trait SeriesLabel {
'read' => "bool",
'starred' => "bool",
'modified' => "datetime",
+ 'hidden' => "bool",
],
'rows' => [
- [1, 1,1,1,'2000-01-01 00:00:00'],
- [5, 19,1,0,'2000-01-01 00:00:00'],
- [5, 20,0,1,'2010-01-01 00:00:00'],
- [7, 20,1,0,'2010-01-01 00:00:00'],
- [8, 102,1,0,'2000-01-02 02:00:00'],
- [9, 103,0,1,'2000-01-03 03:00:00'],
- [9, 104,1,1,'2000-01-04 04:00:00'],
- [10,105,0,0,'2000-01-05 05:00:00'],
- [11, 19,0,0,'2017-01-01 00:00:00'],
- [11, 20,1,0,'2017-01-01 00:00:00'],
- [12, 3,0,1,'2017-01-01 00:00:00'],
- [12, 4,1,1,'2017-01-01 00:00:00'],
+ [1, 1,1,1,'2000-01-01 00:00:00',0],
+ [5, 19,1,0,'2000-01-01 00:00:00',0],
+ [5, 20,0,1,'2010-01-01 00:00:00',0],
+ [7, 20,1,0,'2010-01-01 00:00:00',0],
+ [8, 102,1,0,'2000-01-02 02:00:00',0],
+ [9, 103,0,1,'2000-01-03 03:00:00',0],
+ [9, 104,1,1,'2000-01-04 04:00:00',0],
+ [10,105,0,0,'2000-01-05 05:00:00',0],
+ [11, 19,0,0,'2017-01-01 00:00:00',0],
+ [11, 20,1,0,'2017-01-01 00:00:00',0],
+ [12, 3,0,1,'2017-01-01 00:00:00',0],
+ [12, 4,1,1,'2017-01-01 00:00:00',0],
+ [4, 8,0,0,'2000-01-02 02:00:00',1]
],
],
'arsse_labels' => [
@@ -237,6 +239,7 @@ trait SeriesLabel {
[2,20,5,1],
[1, 5,3,0],
[2, 5,3,1],
+ [2, 8,4,1],
],
],
];
From b2fae336e8dbb5175d47db58dbae6bed71a1a781 Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Sun, 20 Dec 2020 17:42:28 -0500
Subject: [PATCH 080/366] Adjust Nextcloud News to ignore hidden items
---
lib/REST/NextcloudNews/V1_2.php | 10 +-
tests/cases/REST/NextcloudNews/TestV1_2.php | 109 ++++++++++----------
2 files changed, 57 insertions(+), 62 deletions(-)
diff --git a/lib/REST/NextcloudNews/V1_2.php b/lib/REST/NextcloudNews/V1_2.php
index 5c5a9443..32405f87 100644
--- a/lib/REST/NextcloudNews/V1_2.php
+++ b/lib/REST/NextcloudNews/V1_2.php
@@ -333,7 +333,7 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler {
return new EmptyResponse(422);
}
// build the context
- $c = new Context;
+ $c = (new Context)->hidden(false);
$c->latestEdition((int) $data['newestItemId']);
$c->folder((int) $url[1]);
// perform the operation
@@ -400,7 +400,7 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler {
$feed = Arsse::$db->subscriptionPropertiesGet(Arsse::$user->id, $id);
$feed = $this->feedTranslate($feed);
$out = ['feeds' => [$feed]];
- $newest = Arsse::$db->editionLatest(Arsse::$user->id, (new Context)->subscription($id));
+ $newest = Arsse::$db->editionLatest(Arsse::$user->id, (new Context)->subscription($id)->hidden(false));
if ($newest) {
$out['newestItemId'] = $newest;
}
@@ -482,7 +482,7 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler {
return new EmptyResponse(422);
}
// build the context
- $c = new Context;
+ $c = (new Context)->hidden(false);
$c->latestEdition((int) $data['newestItemId']);
$c->subscription((int) $url[1]);
// perform the operation
@@ -498,7 +498,7 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler {
// list articles and their properties
protected function articleList(array $url, array $data): ResponseInterface {
// set the context options supplied by the client
- $c = new Context;
+ $c = (new Context)->hidden(false);
// set the batch size
if ($data['batchSize'] > 0) {
$c->limit($data['batchSize']);
@@ -578,7 +578,7 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler {
return new EmptyResponse(422);
}
// build the context
- $c = new Context;
+ $c = (new Context)->hidden(false);
$c->latestEdition((int) $data['newestItemId']);
// perform the operation
Arsse::$db->articleMark(Arsse::$user->id, ['read' => true], $c);
diff --git a/tests/cases/REST/NextcloudNews/TestV1_2.php b/tests/cases/REST/NextcloudNews/TestV1_2.php
index 5e8c7d13..a88eb81e 100644
--- a/tests/cases/REST/NextcloudNews/TestV1_2.php
+++ b/tests/cases/REST/NextcloudNews/TestV1_2.php
@@ -524,7 +524,7 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest {
\Phake::verify(Arsse::$db, \Phake::times(0))->editionLatest;
} else {
\Phake::verify(Arsse::$db)->subscriptionPropertiesGet(Arsse::$user->id, $id);
- \Phake::verify(Arsse::$db)->editionLatest(Arsse::$user->id, (new Context)->subscription($id));
+ \Phake::verify(Arsse::$db)->editionLatest(Arsse::$user->id, (new Context)->subscription($id)->hidden(false));
if ($input['folderId'] ?? 0) {
\Phake::verify(Arsse::$db)->subscriptionPropertiesSet(Arsse::$user->id, $id, ['folder' => (int) $input['folderId']]);
} else {
@@ -650,65 +650,60 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest {
$this->assertMessage($exp, $this->req("GET", "/feeds/update", json_encode($in[4])));
}
- public function testListArticles(): void {
- $t = new \DateTime;
- $in = [
- ['type' => 0, 'id' => 42], // type=0 => subscription/feed
- ['type' => 1, 'id' => 2112], // type=1 => folder
- ['type' => 0, 'id' => -1], // type=0 => subscription/feed; invalid ID
- ['type' => 1, 'id' => -1], // type=1 => folder; invalid ID
- ['type' => 2, 'id' => 0], // type=2 => starred
- ['type' => 3, 'id' => 0], // type=3 => all (default); base context
- ['oldestFirst' => true, 'batchSize' => 10, 'offset' => 5],
- ['oldestFirst' => false, 'batchSize' => 5, 'offset' => 5],
- ['getRead' => true], // base context
- ['getRead' => false],
- ['lastModified' => $t->getTimestamp()],
- ['oldestFirst' => false, 'batchSize' => 5, 'offset' => 0], // offset=0 should not set the latestEdition context
+ /** @dataProvider provideArticleQueries */
+ public function testListArticles(string $url, array $in, Context $c, $out, ResponseInterface $exp): void {
+ if ($out instanceof \Exception) {
+ \Phake::when(Arsse::$db)->articleList->thenThrow($out);
+ } else {
+ \Phake::when(Arsse::$db)->articleList->thenReturn($out);
+ }
+ $this->assertMessage($exp, $this->req("GET", $url, $in));
+ $columns = ["edition", "guid", "id", "url", "title", "author", "edited_date", "content", "media_type", "media_url", "subscription", "unread", "starred", "modified_date", "fingerprint"];
+ $order = ($in['oldestFirst'] ?? false) ? "edition" : "edition desc";
+ \Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, $c, $columns, [$order]);
+ }
+
+ public function provideArticleQueries(): iterable {
+ $c = (new Context)->hidden(false);
+ $t = Date::normalize(time());
+ $out = new Result($this->v($this->articles['db']));
+ $r200 = new Response(['items' => $this->articles['rest']]);
+ $r422 = new EmptyResponse(422);
+ return [
+ ["/items", [], clone $c, $out, $r200],
+ ["/items", ['type' => 0, 'id' => 42], (clone $c)->subscription(42), new ExceptionInput("idMissing"), $r422],
+ ["/items", ['type' => 1, 'id' => 2112], (clone $c)->folder(2112), new ExceptionInput("idMissing"), $r422],
+ ["/items", ['type' => 0, 'id' => -1], (clone $c)->subscription(-1), new ExceptionInput("typeViolation"), $r422],
+ ["/items", ['type' => 1, 'id' => -1], (clone $c)->folder(-1), new ExceptionInput("typeViolation"), $r422],
+ ["/items", ['type' => 2, 'id' => 0], (clone $c)->starred(true), $out, $r200],
+ ["/items", ['type' => 3, 'id' => 0], clone $c, $out, $r200],
+ ["/items", ['getRead' => true], clone $c, $out, $r200],
+ ["/items", ['getRead' => false], (clone $c)->unread(true), $out, $r200],
+ ["/items", ['lastModified' => $t->getTimestamp()], (clone $c)->markedSince($t), $out, $r200],
+ ["/items", ['oldestFirst' => true, 'batchSize' => 10, 'offset' => 5], (clone $c)->oldestEdition(6)->limit(10), $out, $r200],
+ ["/items", ['oldestFirst' => false, 'batchSize' => 5, 'offset' => 5], (clone $c)->latestEdition(4)->limit(5), $out, $r200],
+ ["/items", ['oldestFirst' => false, 'batchSize' => 5, 'offset' => 0], (clone $c)->limit(5), $out, $r200],
+ ["/items/updated", [], clone $c, $out, $r200],
+ ["/items/updated", ['type' => 0, 'id' => 42], (clone $c)->subscription(42), new ExceptionInput("idMissing"), $r422],
+ ["/items/updated", ['type' => 1, 'id' => 2112], (clone $c)->folder(2112), new ExceptionInput("idMissing"), $r422],
+ ["/items/updated", ['type' => 0, 'id' => -1], (clone $c)->subscription(-1), new ExceptionInput("typeViolation"), $r422],
+ ["/items/updated", ['type' => 1, 'id' => -1], (clone $c)->folder(-1), new ExceptionInput("typeViolation"), $r422],
+ ["/items/updated", ['type' => 2, 'id' => 0], (clone $c)->starred(true), $out, $r200],
+ ["/items/updated", ['type' => 3, 'id' => 0], clone $c, $out, $r200],
+ ["/items/updated", ['getRead' => true], clone $c, $out, $r200],
+ ["/items/updated", ['getRead' => false], (clone $c)->unread(true), $out, $r200],
+ ["/items/updated", ['lastModified' => $t->getTimestamp()], (clone $c)->markedSince($t), $out, $r200],
+ ["/items/updated", ['oldestFirst' => true, 'batchSize' => 10, 'offset' => 5], (clone $c)->oldestEdition(6)->limit(10), $out, $r200],
+ ["/items/updated", ['oldestFirst' => false, 'batchSize' => 5, 'offset' => 5], (clone $c)->latestEdition(4)->limit(5), $out, $r200],
+ ["/items/updated", ['oldestFirst' => false, 'batchSize' => 5, 'offset' => 0], (clone $c)->limit(5), $out, $r200],
];
- \Phake::when(Arsse::$db)->articleList->thenReturn(new Result($this->v($this->articles['db'])));
- \Phake::when(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->subscription(42), $this->anything(), ["edition desc"])->thenThrow(new ExceptionInput("idMissing"));
- \Phake::when(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->folder(2112), $this->anything(), ["edition desc"])->thenThrow(new ExceptionInput("idMissing"));
- \Phake::when(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->subscription(-1), $this->anything(), ["edition desc"])->thenThrow(new ExceptionInput("typeViolation"));
- \Phake::when(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->folder(-1), $this->anything(), ["edition desc"])->thenThrow(new ExceptionInput("typeViolation"));
- $exp = new Response(['items' => $this->articles['rest']]);
- // check the contents of the response
- $this->assertMessage($exp, $this->req("GET", "/items")); // first instance of base context
- $this->assertMessage($exp, $this->req("GET", "/items/updated")); // second instance of base context
- // check error conditions
- $exp = new EmptyResponse(422);
- $this->assertMessage($exp, $this->req("GET", "/items", json_encode($in[0])));
- $this->assertMessage($exp, $this->req("GET", "/items", json_encode($in[1])));
- $this->assertMessage($exp, $this->req("GET", "/items", json_encode($in[2])));
- $this->assertMessage($exp, $this->req("GET", "/items", json_encode($in[3])));
- // simply run through the remainder of the input for later method verification
- $this->req("GET", "/items", json_encode($in[4]));
- $this->req("GET", "/items", json_encode($in[5])); // third instance of base context
- $this->req("GET", "/items", json_encode($in[6]));
- $this->req("GET", "/items", json_encode($in[7]));
- $this->req("GET", "/items", json_encode($in[8])); // fourth instance of base context
- $this->req("GET", "/items", json_encode($in[9]));
- $this->req("GET", "/items", json_encode($in[10]));
- $this->req("GET", "/items", json_encode($in[11]));
- // perform method verifications
- \Phake::verify(Arsse::$db, \Phake::times(4))->articleList(Arsse::$user->id, new Context, $this->anything(), ["edition desc"]);
- \Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->subscription(42), $this->anything(), ["edition desc"]);
- \Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->folder(2112), $this->anything(), ["edition desc"]);
- \Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->subscription(-1), $this->anything(), ["edition desc"]);
- \Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->folder(-1), $this->anything(), ["edition desc"]);
- \Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->starred(true), $this->anything(), ["edition desc"]);
- \Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->limit(10)->oldestEdition(6), $this->anything(), ["edition"]); // offset is one more than specified
- \Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->limit(5)->latestEdition(4), $this->anything(), ["edition desc"]); // offset is one less than specified
- \Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->unread(true), $this->anything(), ["edition desc"]);
- \Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, $this->equalTo((new Context)->markedSince($t), 2), $this->anything(), ["edition desc"]);
- \Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->limit(5), $this->anything(), ["edition desc"]);
}
public function testMarkAFolderRead(): void {
$read = ['read' => true];
$in = json_encode(['newestItemId' => 2112]);
- \Phake::when(Arsse::$db)->articleMark(Arsse::$user->id, $read, (new Context)->folder(1)->latestEdition(2112))->thenReturn(42);
- \Phake::when(Arsse::$db)->articleMark(Arsse::$user->id, $read, (new Context)->folder(42)->latestEdition(2112))->thenThrow(new ExceptionInput("idMissing")); // folder doesn't exist
+ \Phake::when(Arsse::$db)->articleMark(Arsse::$user->id, $read, (new Context)->folder(1)->latestEdition(2112)->hidden(false))->thenReturn(42);
+ \Phake::when(Arsse::$db)->articleMark(Arsse::$user->id, $read, (new Context)->folder(42)->latestEdition(2112)->hidden(false))->thenThrow(new ExceptionInput("idMissing")); // folder doesn't exist
$exp = new EmptyResponse(204);
$this->assertMessage($exp, $this->req("PUT", "/folders/1/read", $in));
$this->assertMessage($exp, $this->req("PUT", "/folders/1/read?newestItemId=2112"));
@@ -722,8 +717,8 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest {
public function testMarkASubscriptionRead(): void {
$read = ['read' => true];
$in = json_encode(['newestItemId' => 2112]);
- \Phake::when(Arsse::$db)->articleMark(Arsse::$user->id, $read, (new Context)->subscription(1)->latestEdition(2112))->thenReturn(42);
- \Phake::when(Arsse::$db)->articleMark(Arsse::$user->id, $read, (new Context)->subscription(42)->latestEdition(2112))->thenThrow(new ExceptionInput("idMissing")); // subscription doesn't exist
+ \Phake::when(Arsse::$db)->articleMark(Arsse::$user->id, $read, (new Context)->subscription(1)->latestEdition(2112)->hidden(false))->thenReturn(42);
+ \Phake::when(Arsse::$db)->articleMark(Arsse::$user->id, $read, (new Context)->subscription(42)->latestEdition(2112)->hidden(false))->thenThrow(new ExceptionInput("idMissing")); // subscription doesn't exist
$exp = new EmptyResponse(204);
$this->assertMessage($exp, $this->req("PUT", "/feeds/1/read", $in));
$this->assertMessage($exp, $this->req("PUT", "/feeds/1/read?newestItemId=2112"));
@@ -890,6 +885,6 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest {
$url = "/items?type=2";
\Phake::when(Arsse::$db)->articleList->thenReturn(new Result([]));
$this->req("GET", $url, json_encode($in));
- \Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->starred(true), $this->anything(), ["edition"]);
+ \Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->starred(true)->hidden(false), $this->anything(), ["edition"]);
}
}
From b7ce6f5c790f0aa60dd16af29c9cf8e806c2172d Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Sun, 20 Dec 2020 19:32:07 -0500
Subject: [PATCH 081/366] Adjust Fever to ignore hidden items
---
lib/REST/Fever/API.php | 23 ++++++------
tests/cases/REST/Fever/TestAPI.php | 60 +++++++++++++++---------------
2 files changed, 42 insertions(+), 41 deletions(-)
diff --git a/lib/REST/Fever/API.php b/lib/REST/Fever/API.php
index 3382e6cb..2d5fcc1c 100644
--- a/lib/REST/Fever/API.php
+++ b/lib/REST/Fever/API.php
@@ -161,17 +161,17 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler {
}
if ($G['items']) {
$out['items'] = $this->getItems($G);
- $out['total_items'] = Arsse::$db->articleCount(Arsse::$user->id);
+ $out['total_items'] = Arsse::$db->articleCount(Arsse::$user->id, (new Context)->hidden(false));
}
if ($G['links']) {
// TODO: implement hot links
$out['links'] = [];
}
if ($G['unread_item_ids'] || $listUnread) {
- $out['unread_item_ids'] = $this->getItemIds((new Context)->unread(true));
+ $out['unread_item_ids'] = $this->getItemIds((new Context)->unread(true)->hidden(false));
}
if ($G['saved_item_ids'] || $listSaved) {
- $out['saved_item_ids'] = $this->getItemIds((new Context)->starred(true));
+ $out['saved_item_ids'] = $this->getItemIds((new Context)->starred(true)->hidden(false));
}
return $out;
}
@@ -263,17 +263,18 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler {
case "group":
if ($id > 0) {
// concrete groups
- $c->tag($id);
+ $c->tag($id)->hidden(false);
} elseif ($id < 0) {
// group negative-one is the "Sparks" supergroup i.e. no feeds
$c->not->folder(0);
} else {
// group zero is the "Kindling" supergroup i.e. all feeds
- // nothing need to be done for this
+ // only exclude hidden articles
+ $c->hidden(false);
}
break;
case "feed":
- $c->subscription($id);
+ $c->subscription($id)->hidden(false);
break;
default:
return $listSaved;
@@ -308,7 +309,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler {
}
protected function setUnread(): void {
- $lastUnread = Arsse::$db->articleList(Arsse::$user->id, (new Context)->limit(1), ["marked_date"], ["marked_date desc"])->getValue();
+ $lastUnread = Arsse::$db->articleList(Arsse::$user->id, (new Context)->hidden(false)->limit(1), ["marked_date"], ["marked_date desc"])->getValue();
if (!$lastUnread) {
// there are no articles
return;
@@ -316,7 +317,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler {
// Fever takes the date of the last read article less fifteen seconds as a cut-off.
// We take the date of last mark (whether it be read, unread, saved, unsaved), which
// may not actually signify a mark, but we'll otherwise also count back fifteen seconds
- $c = new Context;
+ $c = (new Context)->hidden(false);
$lastUnread = Date::normalize($lastUnread, "sql");
$since = Date::sub("PT15S", $lastUnread);
$c->unread(false)->markedSince($since);
@@ -373,11 +374,11 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler {
}
protected function getItems(array $G): array {
- $c = (new Context)->limit(50);
+ $c = (new Context)->hidden(false)->limit(50);
$reverse = false;
// handle the standard options
if ($G['with_ids']) {
- $c->articles(explode(",", $G['with_ids']));
+ $c->articles(explode(",", $G['with_ids']))->hidden(null);
} elseif ($G['max_id']) {
$c->latestArticle($G['max_id'] - 1);
$reverse = true;
@@ -410,7 +411,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler {
return $out;
}
- protected function getItemIds(Context $c = null): string {
+ protected function getItemIds(Context $c): string {
$out = [];
foreach (Arsse::$db->articleList(Arsse::$user->id, $c) as $r) {
$out[] = (int) $r['id'];
diff --git a/tests/cases/REST/Fever/TestAPI.php b/tests/cases/REST/Fever/TestAPI.php
index d0632c97..1aa77ba3 100644
--- a/tests/cases/REST/Fever/TestAPI.php
+++ b/tests/cases/REST/Fever/TestAPI.php
@@ -303,7 +303,7 @@ class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest {
$fields = ["id", "subscription", "title", "author", "content", "url", "starred", "unread", "published_date"];
$order = [$desc ? "id desc" : "id"];
\Phake::when(Arsse::$db)->articleList->thenReturn(new Result($this->articles['db']));
- \Phake::when(Arsse::$db)->articleCount(Arsse::$user->id)->thenReturn(1024);
+ \Phake::when(Arsse::$db)->articleCount(Arsse::$user->id, (new Context)->hidden(false))->thenReturn(1024);
$exp = new JsonResponse([
'items' => $this->articles['rest'],
'total_items' => 1024,
@@ -316,24 +316,24 @@ class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest {
public function provideItemListContexts(): iterable {
$c = (new Context)->limit(50);
return [
- ["items", (clone $c), false],
- ["items&group_ids=1,2,3,4", (clone $c)->tags([1,2,3,4]), false],
- ["items&feed_ids=1,2,3,4", (clone $c)->subscriptions([1,2,3,4]), false],
+ ["items", (clone $c)->hidden(false), false],
+ ["items&group_ids=1,2,3,4", (clone $c)->tags([1,2,3,4])->hidden(false), false],
+ ["items&feed_ids=1,2,3,4", (clone $c)->subscriptions([1,2,3,4])->hidden(false), false],
["items&with_ids=1,2,3,4", (clone $c)->articles([1,2,3,4]), false],
- ["items&since_id=1", (clone $c)->oldestArticle(2), false],
- ["items&max_id=2", (clone $c)->latestArticle(1), true],
+ ["items&since_id=1", (clone $c)->oldestArticle(2)->hidden(false), false],
+ ["items&max_id=2", (clone $c)->latestArticle(1)->hidden(false), true],
["items&with_ids=1,2,3,4&max_id=6", (clone $c)->articles([1,2,3,4]), false],
["items&with_ids=1,2,3,4&since_id=6", (clone $c)->articles([1,2,3,4]), false],
- ["items&max_id=3&since_id=6", (clone $c)->latestArticle(2), true],
- ["items&feed_ids=1,2,3,4&since_id=6", (clone $c)->subscriptions([1,2,3,4])->oldestArticle(7), false],
+ ["items&max_id=3&since_id=6", (clone $c)->latestArticle(2)->hidden(false), true],
+ ["items&feed_ids=1,2,3,4&since_id=6", (clone $c)->subscriptions([1,2,3,4])->oldestArticle(7)->hidden(false), false],
];
}
public function testListItemIds(): void {
$saved = [['id' => 1],['id' => 2],['id' => 3]];
$unread = [['id' => 4],['id' => 5],['id' => 6]];
- \Phake::when(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->starred(true))->thenReturn(new Result($saved));
- \Phake::when(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->unread(true))->thenReturn(new Result($unread));
+ \Phake::when(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->starred(true)->hidden(false))->thenReturn(new Result($saved));
+ \Phake::when(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->unread(true)->hidden(false))->thenReturn(new Result($unread));
$exp = new JsonResponse(['saved_item_ids' => "1,2,3"]);
$this->assertMessage($exp, $this->h->dispatch($this->req("api&saved_item_ids")));
$exp = new JsonResponse(['unread_item_ids' => "4,5,6"]);
@@ -350,8 +350,8 @@ class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest {
public function testSetMarks(string $post, Context $c, array $data, array $out): void {
$saved = [['id' => 1],['id' => 2],['id' => 3]];
$unread = [['id' => 4],['id' => 5],['id' => 6]];
- \Phake::when(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->starred(true))->thenReturn(new Result($saved));
- \Phake::when(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->unread(true))->thenReturn(new Result($unread));
+ \Phake::when(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->starred(true)->hidden(false))->thenReturn(new Result($saved));
+ \Phake::when(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->unread(true)->hidden(false))->thenReturn(new Result($unread));
\Phake::when(Arsse::$db)->articleMark->thenReturn(0);
\Phake::when(Arsse::$db)->articleMark(Arsse::$user->id, $this->anything(), (new Context)->article(2112))->thenThrow(new \JKingWeb\Arsse\Db\ExceptionInput("subjectMissing"));
$exp = new JsonResponse($out);
@@ -368,8 +368,8 @@ class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest {
public function testSetMarksWithQuery(string $get, Context $c, array $data, array $out): void {
$saved = [['id' => 1],['id' => 2],['id' => 3]];
$unread = [['id' => 4],['id' => 5],['id' => 6]];
- \Phake::when(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->starred(true))->thenReturn(new Result($saved));
- \Phake::when(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->unread(true))->thenReturn(new Result($unread));
+ \Phake::when(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->starred(true)->hidden(false))->thenReturn(new Result($saved));
+ \Phake::when(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->unread(true)->hidden(false))->thenReturn(new Result($unread));
\Phake::when(Arsse::$db)->articleMark->thenReturn(0);
\Phake::when(Arsse::$db)->articleMark(Arsse::$user->id, $this->anything(), (new Context)->article(2112))->thenThrow(new \JKingWeb\Arsse\Db\ExceptionInput("subjectMissing"));
$exp = new JsonResponse($out);
@@ -395,20 +395,20 @@ class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest {
["mark=item&as=read&id=2112", (new Context)->article(2112), $markRead, $listUnread], // article doesn't exist
["mark=item&as=saved&id=5", (new Context)->article(5), $markSaved, $listSaved],
["mark=item&as=unsaved&id=42", (new Context)->article(42), $markUnsaved, $listSaved],
- ["mark=feed&as=read&id=5", (new Context)->subscription(5), $markRead, $listUnread],
- ["mark=feed&as=unread&id=42", (new Context)->subscription(42), $markUnread, $listUnread],
- ["mark=feed&as=saved&id=5", (new Context)->subscription(5), $markSaved, $listSaved],
- ["mark=feed&as=unsaved&id=42", (new Context)->subscription(42), $markUnsaved, $listSaved],
- ["mark=group&as=read&id=5", (new Context)->tag(5), $markRead, $listUnread],
- ["mark=group&as=unread&id=42", (new Context)->tag(42), $markUnread, $listUnread],
- ["mark=group&as=saved&id=5", (new Context)->tag(5), $markSaved, $listSaved],
- ["mark=group&as=unsaved&id=42", (new Context)->tag(42), $markUnsaved, $listSaved],
+ ["mark=feed&as=read&id=5", (new Context)->subscription(5)->hidden(false), $markRead, $listUnread],
+ ["mark=feed&as=unread&id=42", (new Context)->subscription(42)->hidden(false), $markUnread, $listUnread],
+ ["mark=feed&as=saved&id=5", (new Context)->subscription(5)->hidden(false), $markSaved, $listSaved],
+ ["mark=feed&as=unsaved&id=42", (new Context)->subscription(42)->hidden(false), $markUnsaved, $listSaved],
+ ["mark=group&as=read&id=5", (new Context)->tag(5)->hidden(false), $markRead, $listUnread],
+ ["mark=group&as=unread&id=42", (new Context)->tag(42)->hidden(false), $markUnread, $listUnread],
+ ["mark=group&as=saved&id=5", (new Context)->tag(5)->hidden(false), $markSaved, $listSaved],
+ ["mark=group&as=unsaved&id=42", (new Context)->tag(42)->hidden(false), $markUnsaved, $listSaved],
["mark=item&as=invalid&id=42", new Context, [], []],
["mark=invalid&as=unread&id=42", new Context, [], []],
- ["mark=group&as=read&id=0", (new Context), $markRead, $listUnread],
- ["mark=group&as=unread&id=0", (new Context), $markUnread, $listUnread],
- ["mark=group&as=saved&id=0", (new Context), $markSaved, $listSaved],
- ["mark=group&as=unsaved&id=0", (new Context), $markUnsaved, $listSaved],
+ ["mark=group&as=read&id=0", (new Context)->hidden(false), $markRead, $listUnread],
+ ["mark=group&as=unread&id=0", (new Context)->hidden(false), $markUnread, $listUnread],
+ ["mark=group&as=saved&id=0", (new Context)->hidden(false), $markSaved, $listSaved],
+ ["mark=group&as=unsaved&id=0", (new Context)->hidden(false), $markUnsaved, $listSaved],
["mark=group&as=read&id=-1", (new Context)->not->folder(0), $markRead, $listUnread],
["mark=group&as=unread&id=-1", (new Context)->not->folder(0), $markUnread, $listUnread],
["mark=group&as=saved&id=-1", (new Context)->not->folder(0), $markSaved, $listSaved],
@@ -466,14 +466,14 @@ class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest {
public function testUndoReadMarks(): void {
$unread = [['id' => 4],['id' => 5],['id' => 6]];
$out = ['unread_item_ids' => "4,5,6"];
- \Phake::when(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->limit(1), ["marked_date"], ["marked_date desc"])->thenReturn(new Result([['marked_date' => "2000-01-01 00:00:00"]]));
- \Phake::when(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->unread(true))->thenReturn(new Result($unread));
+ \Phake::when(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->limit(1)->hidden(false), ["marked_date"], ["marked_date desc"])->thenReturn(new Result([['marked_date' => "2000-01-01 00:00:00"]]));
+ \Phake::when(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->unread(true)->hidden(false))->thenReturn(new Result($unread));
\Phake::when(Arsse::$db)->articleMark->thenReturn(0);
$exp = new JsonResponse($out);
$act = $this->h->dispatch($this->req("api", ['unread_recently_read' => 1]));
$this->assertMessage($exp, $act);
- \Phake::verify(Arsse::$db)->articleMark(Arsse::$user->id, ['read' => false], (new Context)->unread(false)->markedSince("1999-12-31T23:59:45Z"));
- \Phake::when(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->limit(1), ["marked_date"], ["marked_date desc"])->thenReturn(new Result([]));
+ \Phake::verify(Arsse::$db)->articleMark(Arsse::$user->id, ['read' => false], (new Context)->unread(false)->markedSince("1999-12-31T23:59:45Z")->hidden(false));
+ \Phake::when(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->limit(1)->hidden(false), ["marked_date"], ["marked_date desc"])->thenReturn(new Result([]));
$act = $this->h->dispatch($this->req("api", ['unread_recently_read' => 1]));
$this->assertMessage($exp, $act);
\Phake::verify(Arsse::$db)->articleMark; // only called one time, above
From f33359f3e3d45800f99561364234adeef4cbc985 Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Sun, 20 Dec 2020 22:30:59 -0500
Subject: [PATCH 082/366] Move some Miniflux features to abstract handler
---
lib/REST/AbstractHandler.php | 10 ++++++++++
lib/REST/Miniflux/V1.php | 10 ----------
2 files changed, 10 insertions(+), 10 deletions(-)
diff --git a/lib/REST/AbstractHandler.php b/lib/REST/AbstractHandler.php
index 6060da42..f0e39e79 100644
--- a/lib/REST/AbstractHandler.php
+++ b/lib/REST/AbstractHandler.php
@@ -6,6 +6,7 @@
declare(strict_types=1);
namespace JKingWeb\Arsse\REST;
+use JKingWeb\Arsse\Arsse;
use JKingWeb\Arsse\Misc\Date;
use JKingWeb\Arsse\Misc\ValueInfo;
use Psr\Http\Message\ServerRequestInterface;
@@ -15,6 +16,15 @@ abstract class AbstractHandler implements Handler {
abstract public function __construct();
abstract public function dispatch(ServerRequestInterface $req): ResponseInterface;
+ /** @codeCoverageIgnore */
+ protected function now(): \DateTimeImmutable {
+ return Date::normalize("now");
+ }
+
+ protected function isAdmin(): bool {
+ return (bool) Arsse::$user->propertiesGet(Arsse::$user->id, false)['admin'];
+ }
+
protected function fieldMapNames(array $data, array $map): array {
$out = [];
foreach ($map as $to => $from) {
diff --git a/lib/REST/Miniflux/V1.php b/lib/REST/Miniflux/V1.php
index 99c6a9b2..5474dc0b 100644
--- a/lib/REST/Miniflux/V1.php
+++ b/lib/REST/Miniflux/V1.php
@@ -116,11 +116,6 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
public function __construct() {
}
- /** @codeCoverageIgnore */
- protected function now(): \DateTimeImmutable {
- return Date::normalize("now");
- }
-
protected function authenticate(ServerRequestInterface $req): bool {
// first check any tokens; this is what Miniflux does
if ($req->hasHeader("X-Auth-Token")) {
@@ -143,11 +138,6 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
return false;
}
- protected function isAdmin(): bool {
- return (bool) Arsse::$user->propertiesGet(Arsse::$user->id, false)['admin'];
- }
-
-
public function dispatch(ServerRequestInterface $req): ResponseInterface {
// try to authenticate
if (!$this->authenticate($req)) {
From ade04022106f8a1ff99535ccd1d0a21649b13b2e Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Mon, 21 Dec 2020 21:49:57 -0500
Subject: [PATCH 083/366] Adjust TT-RSS to ignore hidden items
---
lib/REST/TinyTinyRSS/API.php | 42 +--
tests/cases/REST/TinyTinyRSS/TestAPI.php | 317 ++++++++---------------
2 files changed, 130 insertions(+), 229 deletions(-)
diff --git a/lib/REST/TinyTinyRSS/API.php b/lib/REST/TinyTinyRSS/API.php
index 2df402a0..9f8ea590 100644
--- a/lib/REST/TinyTinyRSS/API.php
+++ b/lib/REST/TinyTinyRSS/API.php
@@ -26,6 +26,7 @@ use Laminas\Diactoros\Response\EmptyResponse;
class API extends \JKingWeb\Arsse\REST\AbstractHandler {
public const LEVEL = 14; // emulated API level
public const VERSION = "17.4"; // emulated TT-RSS version
+
protected const LABEL_OFFSET = 1024; // offset below zero at which labels begin, counting down
protected const LIMIT_ARTICLES = 200; // maximum number of articles returned by getHeadlines
protected const LIMIT_EXCERPT = 100; // maximum length of excerpts in getHeadlines, counted in grapheme units
@@ -81,6 +82,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler {
'mode' => ValueInfo::T_INT, // whether to set, clear, or toggle the selected state in `updateArticle`
'data' => ValueInfo::T_STRING, // note text in `updateArticle` if setting a note
];
+ protected const VIEW_MODES = ["all_articles", "adaptive", "unread", "marked", "has_note", "published"];
// generic error construct
protected const FATAL_ERR = [
'seq' => null,
@@ -234,7 +236,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler {
public function opGetCounters(array $data): array {
$user = Arsse::$user->id;
$starred = Arsse::$db->articleStarred($user);
- $fresh = Arsse::$db->articleCount($user, (new Context)->unread(true)->modifiedSince(Date::sub("PT24H")));
+ $fresh = Arsse::$db->articleCount($user, (new Context)->unread(true)->modifiedSince(Date::sub("PT24H", $this->now()))->hidden(false));
$countAll = 0;
$countSubs = 0;
$feeds = [];
@@ -339,7 +341,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler {
'id' => "FEED:".self::FEED_FRESH,
'bare_id' => self::FEED_FRESH,
'icon' => "images/fresh.png",
- 'unread' => Arsse::$db->articleCount($user, (new Context)->unread(true)->modifiedSince(Date::sub("PT24H"))),
+ 'unread' => Arsse::$db->articleCount($user, (new Context)->unread(true)->modifiedSince(Date::sub("PT24H", $this->now()))->hidden(false)),
], $tSpecial),
array_merge([ // Starred articles
'name' => Arsse::$lang->msg("API.TTRSS.Feed.Starred"),
@@ -391,7 +393,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler {
];
$unread += ($l['articles'] - $l['read']);
}
- // if there are labels, all the label category,
+ // if there are labels, add the "Labels" category,
if ($items) {
$out[] = [
'name' => Arsse::$lang->msg("API.TTRSS.Category.Labels"),
@@ -523,7 +525,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler {
// FIXME: this is pretty inefficient
$f = $map[self::CAT_SPECIAL];
$cats[$f]['unread'] += Arsse::$db->articleStarred($user)['unread']; // starred
- $cats[$f]['unread'] += Arsse::$db->articleCount($user, (new Context)->unread(true)->modifiedSince(Date::sub("PT24H"))); // fresh
+ $cats[$f]['unread'] += Arsse::$db->articleCount($user, (new Context)->unread(true)->modifiedSince(Date::sub("PT24H", $this->now()))->hidden(false)); // fresh
if (!$read) {
// if we're only including unread entries, remove any categories with zero unread items (this will by definition also exclude empties)
$count = sizeof($cats);
@@ -675,8 +677,8 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler {
if ($cat == self::CAT_ALL || $cat == self::CAT_SPECIAL) {
// gather some statistics
$starred = Arsse::$db->articleStarred($user)['unread'];
- $fresh = Arsse::$db->articleCount($user, (new Context)->unread(true)->modifiedSince(Date::sub("PT24H")));
- $global = Arsse::$db->articleCount($user, (new Context)->unread(true));
+ $fresh = Arsse::$db->articleCount($user, (new Context)->unread(true)->modifiedSince(Date::sub("PT24H", $this->now()))->hidden(false));
+ $global = Arsse::$db->articleCount($user, (new Context)->unread(true)->hidden(false));
$published = 0; // TODO: if the Published feed is implemented, the getFeeds method needs to be adjusted accordingly
$archived = 0; // the archived feed is non-functional in the TT-RSS protocol itself
// build the list; exclude anything with zero unread if requested
@@ -737,7 +739,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler {
// NOTE: the list is a flat one: it includes children, but not other descendents
foreach (Arsse::$db->folderList($user, $cat, false) as $c) {
// get the number of unread for the category and its descendents; those with zero unread are excluded in "unread-only" mode
- $count = Arsse::$db->articleCount($user, (new Context)->unread(true)->folder((int) $c['id']));
+ $count = Arsse::$db->articleCount($user, (new Context)->unread(true)->folder((int) $c['id'])->hidden(false));
if (!$unread || $count) {
$out[] = [
'id' => (int) $c['id'],
@@ -1037,7 +1039,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler {
$cat = $data['is_cat'] ?? false;
$out = ['status' => "OK"];
// first prepare the context; unsupported contexts simply return early
- $c = new Context;
+ $c = (new Context)->hidden(false);
if ($cat) { // categories
switch ($id) {
case self::CAT_SPECIAL:
@@ -1073,7 +1075,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler {
// TODO: if the Published feed is implemented, the catchup function needs to be modified accordingly
return $out;
case self::FEED_FRESH:
- $c->modifiedSince(Date::sub("PT24H"));
+ $c->modifiedSince(Date::sub("PT24H", $this->now()));
break;
case self::FEED_ALL:
// no context needed here
@@ -1188,6 +1190,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler {
"id",
"guid",
"title",
+ "author",
"url",
"unread",
"starred",
@@ -1296,10 +1299,14 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler {
"subscription",
"subscription_title",
"note",
- ($data['show_content'] || $data['show_excerpt']) ? "content" : "",
- ($data['include_attachments']) ? "media_url": "",
- ($data['include_attachments']) ? "media_type": "",
];
+ if ($data['show_content'] || $data['show_excerpt']) {
+ $columns[] = "content";
+ }
+ if ($data['include_attachments']) {
+ $columns[] = "media_url";
+ $columns[] = "media_type";
+ }
foreach ($this->fetchArticles($data, $columns) as $article) {
$row = [
'id' => (int) $article['id'],
@@ -1387,9 +1394,10 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler {
$id = $data['feed_id'];
$cat = $data['is_cat'] ?? false;
$shallow = !($data['include_nested'] ?? false);
- $viewMode = in_array($data['view_mode'], ["all_articles", "adaptive", "unread", "marked", "has_note", "published"]) ? $data['view_mode'] : "all_articles";
+ $viewMode = in_array($data['view_mode'], self::VIEW_MODES) ? $data['view_mode'] : "all_articles";
+ assert(in_array($viewMode, self::VIEW_MODES), new \JKingWeb\Arsse\Exception("constantUnknown", $viewMode));
// prepare the context; unsupported, invalid, or inherently empty contexts return synthetic empty result sets
- $c = new Context;
+ $c = (new Context)->hidden(false);
$tr = Arsse::$db->begin();
// start with the feed or category ID
if ($cat) { // categories
@@ -1433,13 +1441,13 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler {
// TODO: if the Published feed is implemented, the headline function needs to be modified accordingly
return new ResultEmpty;
case self::FEED_FRESH:
- $c->modifiedSince(Date::sub("PT24H"))->unread(true);
+ $c->modifiedSince(Date::sub("PT24H", $this->now()))->unread(true);
break;
case self::FEED_ALL:
// no context needed here
break;
case self::FEED_READ:
- $c->markedSince(Date::sub("PT24H"))->unread(false); // FIXME: this selects any recently touched (read, starred, annotated) article which is read, not necessarily a recently read one
+ $c->markedSince(Date::sub("PT24H", $this->now()))->unread(false); // FIXME: this selects any recently touched (read, starred, annotated) article which is read, not necessarily a recently read one
break;
default:
// any actual feed
@@ -1477,8 +1485,6 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler {
// not implemented
// TODO: if the Published feed is implemented, the headline function needs to be modified accordingly
return new ResultEmpty;
- default:
- throw new \JKingWeb\Arsse\Exception("constantUnknown", $viewMode); // @codeCoverageIgnore
}
// handle the search string, if any
if (isset($data['search'])) {
diff --git a/tests/cases/REST/TinyTinyRSS/TestAPI.php b/tests/cases/REST/TinyTinyRSS/TestAPI.php
index d5ca279f..abcbdd9b 100644
--- a/tests/cases/REST/TinyTinyRSS/TestAPI.php
+++ b/tests/cases/REST/TinyTinyRSS/TestAPI.php
@@ -23,6 +23,8 @@ use Laminas\Diactoros\Response\EmptyResponse;
/** @covers \JKingWeb\Arsse\REST\TinyTinyRSS\API
* @covers \JKingWeb\Arsse\REST\TinyTinyRSS\Exception */
class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest {
+ protected const NOW = "2020-12-21T23:09:17.189065Z";
+
protected $h;
protected $folders = [
['id' => 5, 'parent' => 3, 'children' => 0, 'feeds' => 1, 'name' => "Local"],
@@ -1113,7 +1115,7 @@ LONG_STRING;
\Phake::when(Arsse::$db)->folderList($this->anything(), null, false)->thenReturn(new Result($this->v($this->topFolders)));
\Phake::when(Arsse::$db)->subscriptionList($this->anything())->thenReturn(new Result($this->v($this->subscriptions)));
\Phake::when(Arsse::$db)->labelList($this->anything())->thenReturn(new Result($this->v($this->labels)));
- \Phake::when(Arsse::$db)->articleCount($this->anything(), $this->equalTo((new Context)->unread(true)->modifiedSince(Date::sub("PT24H")), 2))->thenReturn(7);
+ \Phake::when(Arsse::$db)->articleCount($this->anything(), $this->equalTo((new Context)->hidden(false)->unread(true)->modifiedSince(Date::sub("PT24H")), 2))->thenReturn(7);
\Phake::when(Arsse::$db)->articleStarred($this->anything())->thenReturn($this->v($this->starred));
$exp = [
[
@@ -1177,7 +1179,7 @@ LONG_STRING;
\Phake::when(Arsse::$db)->folderList($this->anything())->thenReturn(new Result($this->v($this->folders)));
\Phake::when(Arsse::$db)->subscriptionList($this->anything())->thenReturn(new Result($this->v($this->subscriptions)));
\Phake::when(Arsse::$db)->labelList($this->anything(), false)->thenReturn(new Result($this->v($this->usedLabels)));
- \Phake::when(Arsse::$db)->articleCount($this->anything(), $this->equalTo((new Context)->unread(true)->modifiedSince(Date::sub("PT24H")), 2))->thenReturn(7);
+ \Phake::when(Arsse::$db)->articleCount($this->anything(), $this->equalTo((new Context)->hidden(false)->unread(true)->modifiedSince(Date::sub("PT24H")), 2))->thenReturn(7);
\Phake::when(Arsse::$db)->articleStarred($this->anything())->thenReturn($this->v($this->starred));
$exp = [
['id' => "global-unread", 'counter' => 35],
@@ -1298,7 +1300,7 @@ LONG_STRING;
\Phake::when(Arsse::$db)->folderList($this->anything(), null, true)->thenReturn(new Result($this->v($this->folders)));
\Phake::when(Arsse::$db)->subscriptionList($this->anything())->thenReturn(new Result($this->v($this->subscriptions)));
\Phake::when(Arsse::$db)->labelList($this->anything(), true)->thenReturn(new Result($this->v($this->labels)));
- \Phake::when(Arsse::$db)->articleCount($this->anything(), $this->equalTo((new Context)->unread(true)->modifiedSince(Date::sub("PT24H")), 2))->thenReturn(7);
+ \Phake::when(Arsse::$db)->articleCount($this->anything(), $this->equalTo((new Context)->hidden(false)->unread(true)->modifiedSince(Date::sub("PT24H")), 2))->thenReturn(7);
\Phake::when(Arsse::$db)->articleStarred($this->anything())->thenReturn($this->v($this->starred));
// the expectations are packed tightly since they're very verbose; one can use var_export() (or convert to JSON) to pretty-print them
$exp = ['categories' => ['identifier' => 'id','label' => 'name','items' => [['name' => 'Special','id' => 'CAT:-1','bare_id' => -1,'type' => 'category','unread' => 0,'items' => [['name' => 'All articles','id' => 'FEED:-4','bare_id' => -4,'icon' => 'images/folder.png','unread' => 35,'type' => 'feed','auxcounter' => 0,'error' => '','updated' => ''],['name' => 'Fresh articles','id' => 'FEED:-3','bare_id' => -3,'icon' => 'images/fresh.png','unread' => 7,'type' => 'feed','auxcounter' => 0,'error' => '','updated' => ''],['name' => 'Starred articles','id' => 'FEED:-1','bare_id' => -1,'icon' => 'images/star.png','unread' => 4,'type' => 'feed','auxcounter' => 0,'error' => '','updated' => ''],['name' => 'Published articles','id' => 'FEED:-2','bare_id' => -2,'icon' => 'images/feed.png','unread' => 0,'type' => 'feed','auxcounter' => 0,'error' => '','updated' => ''],['name' => 'Archived articles','id' => 'FEED:0','bare_id' => 0,'icon' => 'images/archive.png','unread' => 0,'type' => 'feed','auxcounter' => 0,'error' => '','updated' => ''],['name' => 'Recently read','id' => 'FEED:-6','bare_id' => -6,'icon' => 'images/time.png','unread' => 0,'type' => 'feed','auxcounter' => 0,'error' => '','updated' => '']]],['name' => 'Labels','id' => 'CAT:-2','bare_id' => -2,'type' => 'category','unread' => 6,'items' => [['name' => 'Fascinating','id' => 'FEED:-1027','bare_id' => -1027,'unread' => 0,'icon' => 'images/label.png','type' => 'feed','auxcounter' => 0,'error' => '','updated' => '','fg_color' => '','bg_color' => ''],['name' => 'Interesting','id' => 'FEED:-1029','bare_id' => -1029,'unread' => 0,'icon' => 'images/label.png','type' => 'feed','auxcounter' => 0,'error' => '','updated' => '','fg_color' => '','bg_color' => ''],['name' => 'Logical','id' => 'FEED:-1025','bare_id' => -1025,'unread' => 0,'icon' => 'images/label.png','type' => 'feed','auxcounter' => 0,'error' => '','updated' => '','fg_color' => '','bg_color' => '']]],['name' => 'Photography','id' => 'CAT:4','bare_id' => 4,'parent_id' => null,'type' => 'category','auxcounter' => 0,'unread' => 0,'child_unread' => 0,'checkbox' => false,'param' => '(0 feeds)','items' => []],['name' => 'Politics','id' => 'CAT:3','bare_id' => 3,'parent_id' => null,'type' => 'category','auxcounter' => 0,'unread' => 0,'child_unread' => 0,'checkbox' => false,'param' => '(3 feeds)','items' => [['name' => 'Local','id' => 'CAT:5','bare_id' => 5,'parent_id' => 3,'type' => 'category','auxcounter' => 0,'unread' => 0,'child_unread' => 0,'checkbox' => false,'param' => '(1 feed)','items' => [['name' => 'Toronto Star','id' => 'FEED:2','bare_id' => 2,'icon' => 'feed-icons/2.ico','error' => 'oops','param' => '2011-11-11T11:11:11Z','unread' => 0,'auxcounter' => 0,'checkbox' => false]]],['name' => 'National','id' => 'CAT:6','bare_id' => 6,'parent_id' => 3,'type' => 'category','auxcounter' => 0,'unread' => 0,'child_unread' => 0,'checkbox' => false,'param' => '(2 feeds)','items' => [['name' => 'CBC News','id' => 'FEED:4','bare_id' => 4,'icon' => 'feed-icons/4.ico','error' => '','param' => '2017-10-09T15:58:34Z','unread' => 0,'auxcounter' => 0,'checkbox' => false],['name' => 'Ottawa Citizen','id' => 'FEED:5','bare_id' => 5,'icon' => false,'error' => '','param' => '2017-07-07T17:07:17Z','unread' => 0,'auxcounter' => 0,'checkbox' => false]]]]],['name' => 'Science','id' => 'CAT:1','bare_id' => 1,'parent_id' => null,'type' => 'category','auxcounter' => 0,'unread' => 0,'child_unread' => 0,'checkbox' => false,'param' => '(2 feeds)','items' => [['name' => 'Rocketry','id' => 'CAT:2','bare_id' => 2,'parent_id' => 1,'type' => 'category','auxcounter' => 0,'unread' => 0,'child_unread' => 0,'checkbox' => false,'param' => '(1 feed)','items' => [['name' => 'NASA JPL','id' => 'FEED:1','bare_id' => 1,'icon' => false,'error' => '','param' => '2017-09-15T22:54:16Z','unread' => 0,'auxcounter' => 0,'checkbox' => false]]],['name' => 'Ars Technica','id' => 'FEED:3','bare_id' => 3,'icon' => 'feed-icons/3.ico','error' => 'argh','param' => '2016-05-23T06:40:02Z','unread' => 0,'auxcounter' => 0,'checkbox' => false]]],['name' => 'Uncategorized','id' => 'CAT:0','bare_id' => 0,'type' => 'category','auxcounter' => 0,'unread' => 0,'child_unread' => 0,'checkbox' => false,'parent_id' => null,'param' => '(1 feed)','items' => [['name' => 'Eurogamer','id' => 'FEED:6','bare_id' => 6,'icon' => 'feed-icons/6.ico','error' => '','param' => '2010-02-12T20:08:47Z','unread' => 0,'auxcounter' => 0,'checkbox' => false]]]]]];
@@ -1343,19 +1345,19 @@ LONG_STRING;
for ($a = 0; $a < sizeof($in2); $a++) {
$this->assertMessage($exp, $this->req($in2[$a]), "Test $a failed");
}
- \Phake::verify(Arsse::$db)->articleMark($this->anything(), ['read' => true], new Context);
- \Phake::verify(Arsse::$db)->articleMark($this->anything(), ['read' => true], (new Context)->starred(true));
- \Phake::verify(Arsse::$db)->articleMark($this->anything(), ['read' => true], (new Context)->label(1088));
- \Phake::verify(Arsse::$db)->articleMark($this->anything(), ['read' => true], (new Context)->subscription(2112));
- \Phake::verify(Arsse::$db)->articleMark($this->anything(), ['read' => true], (new Context)->folder(42));
- \Phake::verify(Arsse::$db)->articleMark($this->anything(), ['read' => true], (new Context)->folderShallow(0));
- \Phake::verify(Arsse::$db)->articleMark($this->anything(), ['read' => true], (new Context)->labelled(true));
+ \Phake::verify(Arsse::$db)->articleMark($this->anything(), ['read' => true], (new Context)->hidden(false));
+ \Phake::verify(Arsse::$db)->articleMark($this->anything(), ['read' => true], (new Context)->starred(true)->hidden(false));
+ \Phake::verify(Arsse::$db)->articleMark($this->anything(), ['read' => true], (new Context)->label(1088)->hidden(false));
+ \Phake::verify(Arsse::$db)->articleMark($this->anything(), ['read' => true], (new Context)->subscription(2112)->hidden(false));
+ \Phake::verify(Arsse::$db)->articleMark($this->anything(), ['read' => true], (new Context)->folder(42)->hidden(false));
+ \Phake::verify(Arsse::$db)->articleMark($this->anything(), ['read' => true], (new Context)->folderShallow(0)->hidden(false));
+ \Phake::verify(Arsse::$db)->articleMark($this->anything(), ['read' => true], (new Context)->labelled(true)->hidden(false));
// verify the time-based mock
$t = Date::sub("PT24H");
for ($a = 0; $a < sizeof($in3); $a++) {
$this->assertMessage($exp, $this->req($in3[$a]), "Test $a failed");
}
- \Phake::verify(Arsse::$db)->articleMark($this->anything(), ['read' => true], $this->equalTo((new Context)->modifiedSince($t), 2)); // within two seconds
+ \Phake::verify(Arsse::$db)->articleMark($this->anything(), ['read' => true], $this->equalTo((new Context)->hidden(false)->modifiedSince($t), 2)); // within two seconds
}
public function testRetrieveFeedList(): void {
@@ -1385,8 +1387,8 @@ LONG_STRING;
];
// statistical mocks
\Phake::when(Arsse::$db)->articleStarred($this->anything())->thenReturn($this->v($this->starred));
- \Phake::when(Arsse::$db)->articleCount($this->anything(), $this->equalTo((new Context)->unread(true)->modifiedSince(Date::sub("PT24H")), 2))->thenReturn(7);
- \Phake::when(Arsse::$db)->articleCount($this->anything(), (new Context)->unread(true))->thenReturn(35);
+ \Phake::when(Arsse::$db)->articleCount($this->anything(), $this->equalTo((new Context)->unread(true)->hidden(false)->modifiedSince(Date::sub("PT24H")), 2))->thenReturn(7);
+ \Phake::when(Arsse::$db)->articleCount($this->anything(), (new Context)->unread(true)->hidden(false))->thenReturn(35);
// label mocks
\Phake::when(Arsse::$db)->labelList($this->anything())->thenReturn(new Result($this->v($this->labels)));
\Phake::when(Arsse::$db)->labelList($this->anything(), false)->thenReturn(new Result($this->v($this->usedLabels)));
@@ -1400,7 +1402,7 @@ LONG_STRING;
\Phake::when(Arsse::$db)->folderList($this->anything(), null, false)->thenReturn(new Result($this->v($this->filterFolders(null))));
foreach ($this->folders as $f) {
\Phake::when(Arsse::$db)->folderList($this->anything(), $f['id'], false)->thenReturn(new Result($this->v($this->filterFolders($f['id']))));
- \Phake::when(Arsse::$db)->articleCount($this->anything(), (new Context)->unread(true)->folder($f['id']))->thenReturn($this->reduceFolders($f['id']));
+ \Phake::when(Arsse::$db)->articleCount($this->anything(), (new Context)->unread(true)->hidden(false)->folder($f['id']))->thenReturn($this->reduceFolders($f['id']));
\Phake::when(Arsse::$db)->subscriptionList($this->anything(), $f['id'], false)->thenReturn(new Result($this->v($this->filterSubs($f['id']))));
}
$exp = [
@@ -1694,206 +1696,99 @@ LONG_STRING;
$this->assertMessage($this->respGood([$exp[0]]), $this->req($in[5]));
}
- public function testRetrieveCompactHeadlines(): void {
- $in1 = [
- // erroneous input
- ['op' => "getCompactHeadlines", 'sid' => "PriestsOfSyrinx"],
- // empty results
- ['op' => "getCompactHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => 0],
- ['op' => "getCompactHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -2],
- ['op' => "getCompactHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -2, 'is_cat' => true], // is_cat is not used in getCompactHeadlines
- ['op' => "getCompactHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => 2112],
- ['op' => "getCompactHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -4, 'view_mode' => "published"],
- ['op' => "getCompactHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -6, 'view_mode' => "unread"],
- // non-empty results
- ['op' => "getCompactHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -4],
- ['op' => "getCompactHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -1],
- ['op' => "getCompactHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -2112],
- ['op' => "getCompactHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -4, 'view_mode' => "adaptive"],
- ['op' => "getCompactHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -2112, 'view_mode' => "adaptive"],
- ['op' => "getCompactHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -2112, 'view_mode' => "unread"],
- ['op' => "getCompactHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => 42, 'view_mode' => "marked"],
- ['op' => "getCompactHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => 42, 'view_mode' => "has_note"],
- ['op' => "getCompactHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -4, 'limit' => 5],
- ['op' => "getCompactHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -4, 'skip' => 2],
- ['op' => "getCompactHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -4, 'limit' => 5, 'skip' => 2],
- ['op' => "getCompactHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -4, 'since_id' => 47],
- ];
- $in2 = [
- // time-based contexts, handled separately
- ['op' => "getCompactHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -6],
- ['op' => "getCompactHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -6, 'view_mode' => "adaptive"],
- ['op' => "getCompactHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -3],
- ['op' => "getCompactHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -3, 'view_mode' => "marked"],
- ];
- \Phake::when(Arsse::$db)->articleList->thenReturn(new Result($this->v([['id' => 0]])));
- \Phake::when(Arsse::$db)->articleCount->thenReturn(0);
- \Phake::when(Arsse::$db)->articleCount($this->anything(), (new Context)->unread(true))->thenReturn(1);
- $c = (new Context);
- \Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->subscription(2112), ["id"], ["edited_date desc"])->thenThrow(new ExceptionInput("subjectMissing"));
- \Phake::when(Arsse::$db)->articleList($this->anything(), $c, ["id"], ["edited_date desc"])->thenReturn(new Result($this->v($this->articles)));
- \Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->starred(true), ["id"], ["marked_date desc"])->thenReturn(new Result($this->v([['id' => 1]])));
- \Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->label(1088), ["id"], ["edited_date desc"])->thenReturn(new Result($this->v([['id' => 2]])));
- \Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->unread(true), ["id"], ["edited_date desc"])->thenReturn(new Result($this->v([['id' => 3]])));
- \Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->label(1088)->unread(true), ["id"], ["edited_date desc"])->thenReturn(new Result($this->v([['id' => 4]])));
- \Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->subscription(42)->starred(true), ["id"], ["edited_date desc"])->thenReturn(new Result($this->v([['id' => 5]])));
- \Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->subscription(42)->annotated(true), ["id"], ["edited_date desc"])->thenReturn(new Result($this->v([['id' => 6]])));
- \Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->limit(5), ["id"], ["edited_date desc"])->thenReturn(new Result($this->v([['id' => 7]])));
- \Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->offset(2), ["id"], ["edited_date desc"])->thenReturn(new Result($this->v([['id' => 8]])));
- \Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->limit(5)->offset(2), ["id"], ["edited_date desc"])->thenReturn(new Result($this->v([['id' => 9]])));
- \Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->oldestArticle(48), ["id"], ["edited_date desc"])->thenReturn(new Result($this->v([['id' => 10]])));
- $out1 = [
- $this->respErr("INCORRECT_USAGE"),
- $this->respGood([]),
- $this->respGood([]),
- $this->respGood([]),
- $this->respGood([]),
- $this->respGood([]),
- $this->respGood([]),
- $this->respGood([['id' => 101],['id' => 102]]),
- $this->respGood([['id' => 1]]),
- $this->respGood([['id' => 2]]),
- $this->respGood([['id' => 3]]),
- $this->respGood([['id' => 2]]), // the result is 2 rather than 4 because there are no unread, so the unread context is not used
- $this->respGood([['id' => 4]]),
- $this->respGood([['id' => 5]]),
- $this->respGood([['id' => 6]]),
- $this->respGood([['id' => 7]]),
- $this->respGood([['id' => 8]]),
- $this->respGood([['id' => 9]]),
- $this->respGood([['id' => 10]]),
- ];
- $out2 = [
- $this->respGood([['id' => 1001]]),
- $this->respGood([['id' => 1001]]),
- $this->respGood([['id' => 1002]]),
- $this->respGood([['id' => 1003]]),
- ];
- for ($a = 0; $a < sizeof($in1); $a++) {
- $this->assertMessage($out1[$a], $this->req($in1[$a]), "Test $a failed");
- }
- for ($a = 0; $a < sizeof($in2); $a++) {
- \Phake::when(Arsse::$db)->articleList($this->anything(), $this->equalTo((clone $c)->unread(false)->markedSince(Date::sub("PT24H")), 2), ["id"], ["marked_date desc"])->thenReturn(new Result($this->v([['id' => 1001]])));
- \Phake::when(Arsse::$db)->articleList($this->anything(), $this->equalTo((clone $c)->unread(true)->modifiedSince(Date::sub("PT24H")), 2), ["id"], ["edited_date desc"])->thenReturn(new Result($this->v([['id' => 1002]])));
- \Phake::when(Arsse::$db)->articleList($this->anything(), $this->equalTo((clone $c)->unread(true)->modifiedSince(Date::sub("PT24H"))->starred(true), 2), ["id"], ["edited_date desc"])->thenReturn(new Result($this->v([['id' => 1003]])));
- $this->assertMessage($out2[$a], $this->req($in2[$a]), "Test $a failed");
- }
- }
-
- public function testRetrieveFullHeadlines(): void {
- $in1 = [
- // empty results
- ['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => 0],
- ['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -2],
- ['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -1, 'is_cat' => true],
- ['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -4, 'view_mode' => "published"],
- ['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -6, 'view_mode' => "unread"],
- ['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => 2112],
- ['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => 42, 'view_mode' => "unread", 'search' => "unread:false"],
- ['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => 42, 'search' => "pub:true"],
- ];
- $in2 = [
- // simple context tests
- ['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx"],
- ['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -4],
- ['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -1],
- ['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -2112],
- ['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -4, 'view_mode' => "adaptive"],
- ['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -2112, 'view_mode' => "adaptive"],
- ['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -2112, 'view_mode' => "unread"],
- ['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => 42, 'view_mode' => "marked"],
- ['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => 42, 'view_mode' => "has_note"],
- ['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -4, 'limit' => 5],
- ['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -4, 'skip' => 2],
- ['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -4, 'limit' => 5, 'skip' => 2],
- ['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -4, 'since_id' => 47],
- ['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -3, 'is_cat' => true],
- ['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -4, 'is_cat' => true],
- ['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -2, 'is_cat' => true],
- ['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => 0, 'is_cat' => true],
- ['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => 42, 'is_cat' => true],
- ['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => 42, 'is_cat' => true, 'include_nested' => true],
- ['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -4, 'order_by' => "feed_dates"],
- ['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -4, 'order_by' => "date_reverse"],
- ['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => 42, 'search' => "interesting"],
- ];
- $in3 = [
- // time-based context tests
- ['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -6],
- ['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -6, 'view_mode' => "adaptive"],
- ['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -3],
- ['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -3, 'view_mode' => "marked"],
- ];
- \Phake::when(Arsse::$db)->labelList($this->anything())->thenReturn(new Result($this->v($this->labels)));
+ /** @dataProvider provideHeadlines */
+ public function testRetrieveHeadlines(bool $full, array $in, $out, Context $c, array $fields, array $order, ResponseInterface $exp): void {
+ $base = ['op' => $full ? "getHeadlines" : "getCompactHeadlines", 'sid' => "PriestsOfSyrinx"];
+ $in = array_merge($base, $in);
+ $this->h = \Phake::partialMock(API::class);
+ \Phake::when($this->h)->now->thenReturn(Date::normalize(self::NOW));
+ \Phake::when(Arsse::$db)->labelList->thenReturn(new Result($this->v($this->labels)));
\Phake::when(Arsse::$db)->labelList($this->anything(), false)->thenReturn(new Result($this->v($this->usedLabels)));
\Phake::when(Arsse::$db)->articleLabelsGet->thenReturn([]);
\Phake::when(Arsse::$db)->articleLabelsGet($this->anything(), 2112)->thenReturn($this->v([1,3]));
\Phake::when(Arsse::$db)->articleCategoriesGet->thenReturn([]);
\Phake::when(Arsse::$db)->articleCategoriesGet($this->anything(), 2112)->thenReturn(["Boring","Illogical"]);
- \Phake::when(Arsse::$db)->articleList->thenReturn($this->generateHeadlines(0));
- \Phake::when(Arsse::$db)->articleCount->thenReturn(0);
- \Phake::when(Arsse::$db)->articleCount($this->anything(), (new Context)->unread(true))->thenReturn(1);
- $c = (new Context)->limit(200);
- \Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->subscription(2112), $this->anything(), ["edited_date desc"])->thenThrow(new ExceptionInput("subjectMissing"));
- \Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->starred(true), $this->anything(), ["marked_date desc"])->thenReturn($this->generateHeadlines(1));
- \Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->label(1088), $this->anything(), ["edited_date desc"])->thenReturn($this->generateHeadlines(2));
- \Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->unread(true), $this->anything(), ["edited_date desc"])->thenReturn($this->generateHeadlines(3));
- \Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->label(1088)->unread(true), $this->anything(), ["edited_date desc"])->thenReturn($this->generateHeadlines(4));
- \Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->subscription(42)->starred(true), $this->anything(), ["edited_date desc"])->thenReturn($this->generateHeadlines(5));
- \Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->subscription(42)->annotated(true), $this->anything(), ["edited_date desc"])->thenReturn($this->generateHeadlines(6));
- \Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->limit(5), $this->anything(), ["edited_date desc"])->thenReturn($this->generateHeadlines(7));
- \Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->offset(2), $this->anything(), ["edited_date desc"])->thenReturn($this->generateHeadlines(8));
- \Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->limit(5)->offset(2), $this->anything(), ["edited_date desc"])->thenReturn($this->generateHeadlines(9));
- \Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->oldestArticle(48), $this->anything(), ["edited_date desc"])->thenReturn($this->generateHeadlines(10));
- \Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c), $this->anything(), ["edited_date desc"])->thenReturn($this->generateHeadlines(11));
- \Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->labelled(true), $this->anything(), ["edited_date desc"])->thenReturn($this->generateHeadlines(12));
- \Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->folderShallow(0), $this->anything(), ["edited_date desc"])->thenReturn($this->generateHeadlines(13));
- \Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->folderShallow(42), $this->anything(), ["edited_date desc"])->thenReturn($this->generateHeadlines(14));
- \Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->folder(42), $this->anything(), ["edited_date desc"])->thenReturn($this->generateHeadlines(15));
- \Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c), $this->anything(), ["edited_date"])->thenReturn($this->generateHeadlines(16));
- \Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->subscription(42)->searchTerms(["interesting"]), $this->anything(), ["edited_date desc"])->thenReturn($this->generateHeadlines(17));
- $out2 = [
- $this->respErr("INCORRECT_USAGE"),
- $this->outputHeadlines(11),
- $this->outputHeadlines(1),
- $this->outputHeadlines(2),
- $this->outputHeadlines(3),
- $this->outputHeadlines(2), // the result is 2 rather than 4 because there are no unread, so the unread context is not used
- $this->outputHeadlines(4),
- $this->outputHeadlines(5),
- $this->outputHeadlines(6),
- $this->outputHeadlines(7),
- $this->outputHeadlines(8),
- $this->outputHeadlines(9),
- $this->outputHeadlines(10),
- $this->outputHeadlines(11),
- $this->outputHeadlines(11),
- $this->outputHeadlines(12),
- $this->outputHeadlines(13),
- $this->outputHeadlines(14),
- $this->outputHeadlines(15),
- $this->outputHeadlines(11), // defaulting sorting is not fully implemented
- $this->outputHeadlines(16),
- $this->outputHeadlines(17),
+ \Phake::when(Arsse::$db)->articleCount->thenReturn(2);
+ if ($out instanceof \Exception) {
+ \Phake::when(Arsse::$db)->articleList->thenThrow($out);
+ } else {
+ \Phake::when(Arsse::$db)->articleList->thenReturn($out);
+ }
+ $this->assertMessage($exp, $this->req($in));
+ if ($out) {
+ \Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, $c, $fields, $order);
+ } else {
+ \Phake::verify(Arsse::$db, \Phake::times(0))->articleList;
+ }
+ }
+
+ public function provideHeadlines(): iterable {
+ $t = Date::normalize(self::NOW);
+ $c = (new Context)->hidden(false)->limit(200);
+ $out = $this->generateHeadlines(47);
+ $gone = new ExceptionInput("idMissing");
+ $comp = new Result($this->v([['id' => 47], ['id' => 2112]]));
+ $expFull = $this->outputHeadlines(47);
+ $expComp = $this->respGood([['id' => 47], ['id' => 2112]]);
+ $fields = ["id", "guid", "title", "author", "url", "unread", "starred", "edited_date", "published_date", "subscription", "subscription_title", "note"];
+ $sort = ["edited_date desc"];
+ return [
+ [true, [], null, $c, [], [], $this->respErr("INCORRECT_USAGE")],
+ [true, ['feed_id' => 0], null, $c, [], [], $this->respGood([])],
+ [true, ['feed_id' => -1], $out, (clone $c)->starred(true), $fields, ["marked_date desc"], $expFull],
+ [true, ['feed_id' => -2], null, $c, [], [], $this->respGood([])],
+ [true, ['feed_id' => -4], $out, $c, $fields, $sort, $expFull],
+ [true, ['feed_id' => 2112], $gone, (clone $c)->subscription(2112), $fields, $sort, $this->respGood([])],
+ [true, ['feed_id' => -2112], $out, (clone $c)->label(1088), $fields, $sort, $expFull],
+ [true, ['feed_id' => -4, 'view_mode' => "adaptive"], $out, (clone $c)->unread(true), $fields, $sort, $expFull],
+ [true, ['feed_id' => -4, 'view_mode' => "published"], null, $c, [], [], $this->respGood([])],
+ [true, ['feed_id' => -2112, 'view_mode' => "adaptive"], $out, (clone $c)->label(1088)->unread(true), $fields, $sort, $expFull],
+ [true, ['feed_id' => -2112, 'view_mode' => "unread"], $out, (clone $c)->label(1088)->unread(true), $fields, $sort, $expFull],
+ [true, ['feed_id' => 42, 'view_mode' => "marked"], $out, (clone $c)->subscription(42)->starred(true), $fields, $sort, $expFull],
+ [true, ['feed_id' => 42, 'view_mode' => "has_note"], $out, (clone $c)->subscription(42)->annotated(true), $fields, $sort, $expFull],
+ [true, ['feed_id' => 42, 'view_mode' => "unread", 'search' => "unread:false"], null, $c, [], [], $this->respGood([])],
+ [true, ['feed_id' => 42, 'search' => "pub:true"], null, $c, [], [], $this->respGood([])],
+ [true, ['feed_id' => -4, 'limit' => 5], $out, (clone $c)->limit(5), $fields, $sort, $expFull],
+ [true, ['feed_id' => -4, 'skip' => 2], $out, (clone $c)->offset(2), $fields, $sort, $expFull],
+ [true, ['feed_id' => -4, 'limit' => 5, 'skip' => 2], $out, (clone $c)->limit(5)->offset(2), $fields, $sort, $expFull],
+ [true, ['feed_id' => -4, 'since_id' => 47], $out, (clone $c)->oldestArticle(48), $fields, $sort, $expFull],
+ [true, ['feed_id' => -3, 'is_cat' => true], $out, $c, $fields, $sort, $expFull],
+ [true, ['feed_id' => -4, 'is_cat' => true], $out, $c, $fields, $sort, $expFull],
+ [true, ['feed_id' => -2, 'is_cat' => true], $out, (clone $c)->labelled(true), $fields, $sort, $expFull],
+ [true, ['feed_id' => -1, 'is_cat' => true], null, $c, [], [], $this->respGood([])],
+ [true, ['feed_id' => 0, 'is_cat' => true], $out, (clone $c)->folderShallow(0), $fields, $sort, $expFull],
+ [true, ['feed_id' => 0, 'is_cat' => true, 'include_nested' => true], $out, (clone $c)->folderShallow(0), $fields, $sort, $expFull],
+ [true, ['feed_id' => 42, 'is_cat' => true], $out, (clone $c)->folderShallow(42), $fields, $sort, $expFull],
+ [true, ['feed_id' => 42, 'is_cat' => true, 'include_nested' => true], $out, (clone $c)->folder(42), $fields, $sort, $expFull],
+ [true, ['feed_id' => -4, 'order_by' => "feed_dates"], $out, $c, $fields, $sort, $expFull],
+ [true, ['feed_id' => -4, 'order_by' => "date_reverse"], $out, $c, $fields, ["edited_date"], $expFull],
+ [true, ['feed_id' => 42, 'search' => "interesting"], $out, (clone $c)->subscription(42)->searchTerms(["interesting"]), $fields, $sort, $expFull],
+ [true, ['feed_id' => -6], $out, (clone $c)->unread(false)->markedSince(Date::sub("PT24H", $t)), $fields, ["marked_date desc"], $expFull],
+ [true, ['feed_id' => -6, 'view_mode' => "unread"], null, $c, $fields, $sort, $this->respGood([])],
+ [true, ['feed_id' => -3], $out, (clone $c)->unread(true)->modifiedSince(Date::sub("PT24H", $t)), $fields, $sort, $expFull],
+ [true, ['feed_id' => -3, 'view_mode' => "marked"], $out, (clone $c)->unread(true)->starred(true)->modifiedSince(Date::sub("PT24H", $t)), $fields, $sort, $expFull],
+ [false, [], null, (clone $c)->limit(null), [], [], $this->respErr("INCORRECT_USAGE")],
+ [false, ['feed_id' => 0], null, (clone $c)->limit(null), [], [], $this->respGood([])],
+ [false, ['feed_id' => -1], $comp, (clone $c)->limit(null)->starred(true), ["id"], ["marked_date desc"], $expComp],
+ [false, ['feed_id' => -2], null, (clone $c)->limit(null), [], [], $this->respGood([])],
+ [false, ['feed_id' => -4], $comp, (clone $c)->limit(null), ["id"], $sort, $expComp],
+ [false, ['feed_id' => 2112], $gone, (clone $c)->limit(null)->subscription(2112), ["id"], $sort, $this->respGood([])],
+ [false, ['feed_id' => -2112], $comp, (clone $c)->limit(null)->label(1088), ["id"], $sort, $expComp],
+ [false, ['feed_id' => -4, 'view_mode' => "adaptive"], $comp, (clone $c)->limit(null)->unread(true), ["id"], $sort, $expComp],
+ [false, ['feed_id' => -4, 'view_mode' => "published"], null, (clone $c)->limit(null), [], [], $this->respGood([])],
+ [false, ['feed_id' => -2112, 'view_mode' => "adaptive"], $comp, (clone $c)->limit(null)->label(1088)->unread(true), ["id"], $sort, $expComp],
+ [false, ['feed_id' => -2112, 'view_mode' => "unread"], $comp, (clone $c)->limit(null)->label(1088)->unread(true), ["id"], $sort, $expComp],
+ [false, ['feed_id' => 42, 'view_mode' => "marked"], $comp, (clone $c)->limit(null)->subscription(42)->starred(true), ["id"], $sort, $expComp],
+ [false, ['feed_id' => 42, 'view_mode' => "has_note"], $comp, (clone $c)->limit(null)->subscription(42)->annotated(true), ["id"], $sort, $expComp],
+ [false, ['feed_id' => -4, 'limit' => 5], $comp, (clone $c)->limit(5), ["id"], $sort, $expComp],
+ [false, ['feed_id' => -4, 'skip' => 2], $comp, (clone $c)->limit(null)->offset(2), ["id"], $sort, $expComp],
+ [false, ['feed_id' => -4, 'limit' => 5, 'skip' => 2], $comp, (clone $c)->limit(5)->offset(2), ["id"], $sort, $expComp],
+ [false, ['feed_id' => -4, 'since_id' => 47], $comp, (clone $c)->limit(null)->oldestArticle(48), ["id"], $sort, $expComp],
+ [false, ['feed_id' => -6], $comp, (clone $c)->limit(null)->unread(false)->markedSince(Date::sub("PT24H", $t)), ["id"], ["marked_date desc"], $expComp],
+ [false, ['feed_id' => -6, 'view_mode' => "unread"], null, (clone $c)->limit(null), ["id"], $sort, $this->respGood([])],
+ [false, ['feed_id' => -3], $comp, (clone $c)->limit(null)->unread(true)->modifiedSince(Date::sub("PT24H", $t)), ["id"], $sort, $expComp],
+ [false, ['feed_id' => -3, 'view_mode' => "marked"], $comp, (clone $c)->limit(null)->unread(true)->starred(true)->modifiedSince(Date::sub("PT24H", $t)), ["id"], $sort, $expComp],
];
- $out3 = [
- $this->outputHeadlines(1001),
- $this->outputHeadlines(1001),
- $this->outputHeadlines(1002),
- $this->outputHeadlines(1003),
- ];
- for ($a = 0; $a < sizeof($in1); $a++) {
- $this->assertMessage($this->respGood([]), $this->req($in1[$a]), "Test $a failed");
- }
- for ($a = 0; $a < sizeof($in2); $a++) {
- $this->assertMessage($out2[$a], $this->req($in2[$a]), "Test $a failed");
- }
- for ($a = 0; $a < sizeof($in3); $a++) {
- \Phake::when(Arsse::$db)->articleList($this->anything(), $this->equalTo((clone $c)->unread(false)->markedSince(Date::sub("PT24H")), 2), $this->anything(), ["marked_date desc"])->thenReturn($this->generateHeadlines(1001));
- \Phake::when(Arsse::$db)->articleList($this->anything(), $this->equalTo((clone $c)->unread(true)->modifiedSince(Date::sub("PT24H")), 2), $this->anything(), ["edited_date desc"])->thenReturn($this->generateHeadlines(1002));
- \Phake::when(Arsse::$db)->articleList($this->anything(), $this->equalTo((clone $c)->unread(true)->modifiedSince(Date::sub("PT24H"))->starred(true), 2), $this->anything(), ["edited_date desc"])->thenReturn($this->generateHeadlines(1003));
- $this->assertMessage($out3[$a], $this->req($in3[$a]), "Test $a failed");
- }
}
public function testRetrieveFullHeadlinesCheckingExtraFields(): void {
@@ -1919,7 +1814,7 @@ LONG_STRING;
\Phake::when(Arsse::$db)->articleCategoriesGet($this->anything(), 2112)->thenReturn(["Boring","Illogical"]);
\Phake::when(Arsse::$db)->articleList->thenReturn($this->generateHeadlines(1));
\Phake::when(Arsse::$db)->articleCount->thenReturn(0);
- \Phake::when(Arsse::$db)->articleCount($this->anything(), (new Context)->unread(true))->thenReturn(1);
+ \Phake::when(Arsse::$db)->articleCount($this->anything(), (new Context)->unread(true)->hidden(false))->thenReturn(1);
// sanity check; this makes sure extra fields are not included in default situations
$test = $this->req($in[0]);
$this->assertMessage($this->outputHeadlines(1), $test);
@@ -1970,7 +1865,7 @@ LONG_STRING;
]);
$this->assertMessage($exp, $test);
// test 'include_header' with an erroneous result
- \Phake::when(Arsse::$db)->articleList($this->anything(), (new Context)->limit(200)->subscription(2112), $this->anything(), ["edited_date desc"])->thenThrow(new ExceptionInput("subjectMissing"));
+ \Phake::when(Arsse::$db)->articleList($this->anything(), (new Context)->limit(200)->subscription(2112)->hidden(false), $this->anything(), ["edited_date desc"])->thenThrow(new ExceptionInput("subjectMissing"));
$test = $this->req($in[6]);
$exp = $this->respGood([
['id' => 2112, 'is_cat' => false, 'first_id' => 0],
@@ -1985,7 +1880,7 @@ LONG_STRING;
]);
$this->assertMessage($exp, $test);
// test 'include_header' with skip
- \Phake::when(Arsse::$db)->articleList($this->anything(), (new Context)->limit(1)->subscription(42), $this->anything(), ["edited_date desc"])->thenReturn($this->generateHeadlines(1867));
+ \Phake::when(Arsse::$db)->articleList($this->anything(), (new Context)->limit(1)->subscription(42)->hidden(false), $this->anything(), ["edited_date desc"])->thenReturn($this->generateHeadlines(1867));
$test = $this->req($in[8]);
$exp = $this->respGood([
['id' => 42, 'is_cat' => false, 'first_id' => 1867],
From a81760e39d0caadf7d26506856123b0a4d554a8d Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Tue, 22 Dec 2020 15:17:18 -0500
Subject: [PATCH 084/366] Aggressivly clean up hidden articles
Notably, starred articles are cleaned up if hidden
---
lib/Database.php | 54 +++++++++++++++++++-------
tests/cases/Database/SeriesCleanup.php | 16 ++++----
2 files changed, 50 insertions(+), 20 deletions(-)
diff --git a/lib/Database.php b/lib/Database.php
index 32e88d33..d193b576 100644
--- a/lib/Database.php
+++ b/lib/Database.php
@@ -1809,21 +1809,49 @@ class Database {
/** Deletes from the database articles which are beyond the configured clean-up threshold */
public function articleCleanup(): bool {
- $query = $this->db->prepare(
+ $query = $this->db->prepareArray(
"WITH RECURSIVE
- exempt_articles as (SELECT id from arsse_articles join (SELECT article, max(id) as edition from arsse_editions group by article) as latest_editions on arsse_articles.id = latest_editions.article where feed = ? order by edition desc limit ?),
- target_articles as (
- select id from arsse_articles
- left join (select article, sum(starred) as starred, sum(\"read\") as \"read\", max(arsse_marks.modified) as marked_date from arsse_marks join arsse_subscriptions on arsse_subscriptions.id = arsse_marks.subscription group by article) as mark_stats on mark_stats.article = arsse_articles.id
- left join (select feed, count(*) as subs from arsse_subscriptions group by feed) as feed_stats on feed_stats.feed = arsse_articles.feed
- where arsse_articles.feed = ? and coalesce(starred,0) = 0 and (coalesce(marked_date,modified) <= ? or (coalesce(\"read\",0) = coalesce(subs,0) and coalesce(marked_date,modified) <= ?))
- )
+ exempt_articles as (
+ SELECT
+ id
+ from arsse_articles join (
+ SELECT article, max(id) as edition from arsse_editions group by article
+ ) as latest_editions on arsse_articles.id = latest_editions.article
+ where feed = ? order by edition desc limit ?
+ ),
+ target_articles as (
+ SELECT
+ id
+ from arsse_articles
+ join (
+ select
+ feed,
+ count(*) as subs
+ from arsse_subscriptions
+ where feed = ?
+ group by feed
+ ) as feed_stats on feed_stats.feed = arsse_articles.feed
+ left join (
+ select
+ article,
+ sum(cast((starred = 1 and hidden = 0) as integer)) as starred,
+ sum(cast((\"read\" = 1 or hidden = 1) as integer)) as \"read\",
+ max(arsse_marks.modified) as marked_date
+ from arsse_marks
+ group by article
+ ) as mark_stats on mark_stats.article = arsse_articles.id
+ where
+ coalesce(starred,0) = 0
+ and (
+ coalesce(marked_date,modified) <= ?
+ or (
+ coalesce(\"read\",0) = coalesce(subs,0)
+ and coalesce(marked_date,modified) <= ?
+ )
+ )
+ )
DELETE FROM arsse_articles WHERE id not in (select id from exempt_articles) and id in (select id from target_articles)",
- "int",
- "int",
- "int",
- "datetime",
- "datetime"
+ ["int", "int", "int", "datetime", "datetime"]
);
$limitRead = null;
$limitUnread = null;
diff --git a/tests/cases/Database/SeriesCleanup.php b/tests/cases/Database/SeriesCleanup.php
index cdbb66a0..d863a644 100644
--- a/tests/cases/Database/SeriesCleanup.php
+++ b/tests/cases/Database/SeriesCleanup.php
@@ -148,16 +148,18 @@ trait SeriesCleanup {
'subscription' => "int",
'read' => "bool",
'starred' => "bool",
+ 'hidden' => "bool",
'modified' => "datetime",
],
'rows' => [
- [3,1,0,1,$weeksago],
- [4,1,1,0,$daysago],
- [6,1,1,0,$nowish],
- [6,2,1,0,$weeksago],
- [8,1,1,0,$weeksago],
- [9,1,1,0,$daysago],
- [9,2,1,0,$daysago],
+ [3,1,0,1,0,$weeksago],
+ [4,1,1,0,0,$daysago],
+ [6,1,1,0,0,$nowish],
+ [6,2,1,0,0,$weeksago],
+ [7,2,0,1,1,$weeksago], // hidden takes precedence over starred
+ [8,1,1,0,0,$weeksago],
+ [9,1,1,0,0,$daysago],
+ [9,2,0,0,1,$daysago], // hidden is the same as read for the purposes of cleanup
],
],
];
From d66cf32c1f12f4ebb9f0a986d207fa229f7befdc Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Tue, 22 Dec 2020 16:13:12 -0500
Subject: [PATCH 085/366] Style fixes
---
lib/Database.php | 149 ++++++++------------
lib/REST/Fever/API.php | 2 +-
lib/REST/Miniflux/V1.php | 8 +-
tests/cases/Database/SeriesArticle.php | 2 +-
tests/cases/Database/SeriesLabel.php | 2 +-
tests/cases/Database/SeriesSubscription.php | 2 +-
tests/cases/Database/SeriesUser.php | 2 +-
tests/cases/REST/Miniflux/TestV1.php | 6 +-
tests/cases/REST/TinyTinyRSS/TestAPI.php | 2 +-
tests/cases/User/TestUser.php | 3 +-
10 files changed, 73 insertions(+), 105 deletions(-)
diff --git a/lib/Database.php b/lib/Database.php
index d193b576..6663e0ff 100644
--- a/lib/Database.php
+++ b/lib/Database.php
@@ -327,7 +327,7 @@ class Database {
settype($meta['num'], "integer");
settype($meta['admin'], "integer");
return $meta;
- }
+ }
public function userPropertiesSet(string $user, array $data): bool {
if (!$this->userExists($user)) {
@@ -660,20 +660,27 @@ class Database {
// make sure both that the prospective parent exists, and that the it is not one of its children (a circular dependence);
// also make sure that a folder with the same prospective name and parent does not already exist: if the parent is null,
// SQL will happily accept duplicates (null is not unique), so we must do this check ourselves
- $p = $this->db->prepare(
+ $p = $this->db->prepareArray(
"WITH RECURSIVE
- target as (select ? as userid, ? as source, ? as dest, ? as new_name),
- folders as (SELECT id from arsse_folders join target on owner = userid and coalesce(parent,0) = source union all select arsse_folders.id as id from arsse_folders join folders on arsse_folders.parent=folders.id)
- ".
- "SELECT
- case when ((select dest from target) is null or exists(select id from arsse_folders join target on owner = userid and coalesce(id,0) = coalesce(dest,0))) then 1 else 0 end as extant,
- case when not exists(select id from folders where id = coalesce((select dest from target),0)) then 1 else 0 end as valid,
- case when not exists(select id from arsse_folders join target on coalesce(parent,0) = coalesce(dest,0) and name = coalesce((select new_name from target),(select name from arsse_folders join target on id = source))) then 1 else 0 end as available
- ",
- "str",
- "strict int",
- "int",
- "str"
+ target as (
+ SELECT ? as userid, ? as source, ? as dest, ? as new_name
+ ),
+ folders as (
+ SELECT id from arsse_folders join target on owner = userid and coalesce(parent,0) = source
+ union all
+ select arsse_folders.id as id from arsse_folders join folders on arsse_folders.parent=folders.id
+ )
+ SELECT
+ case when
+ ((select dest from target) is null or exists(select id from arsse_folders join target on owner = userid and coalesce(id,0) = coalesce(dest,0)))
+ then 1 else 0 end as extant,
+ case when
+ not exists(select id from folders where id = coalesce((select dest from target),0))
+ then 1 else 0 end as valid,
+ case when
+ not exists(select id from arsse_folders join target on coalesce(parent,0) = coalesce(dest,0) and name = coalesce((select new_name from target),(select name from arsse_folders join target on id = source)))
+ then 1 else 0 end as available",
+ ["str", "strict int", "int", "str"]
)->run($user, $id, $parent, $name)->getRow();
if (!$p['extant']) {
// if the parent doesn't exist or doesn't below to the user, throw an exception
@@ -757,7 +764,13 @@ class Database {
left join topmost as t on t.f_id = s.folder
join arsse_feeds as f on f.id = s.feed
left join arsse_icons as i on i.id = f.icon
- left join (select feed, count(*) as articles from arsse_articles group by feed) as article_stats on article_stats.feed = s.feed
+ left join (
+ select
+ feed,
+ count(*) as articles
+ from arsse_articles
+ group by feed
+ ) as article_stats on article_stats.feed = s.feed
left join (
select
subscription,
@@ -1042,11 +1055,9 @@ class Database {
}
} catch (Feed\Exception $e) {
// update the database with the resultant error and the next fetch time, incrementing the error count
- $this->db->prepare(
+ $this->db->prepareArray(
"UPDATE arsse_feeds SET updated = CURRENT_TIMESTAMP, next_fetch = ?, err_count = err_count + 1, err_msg = ? WHERE id = ?",
- 'datetime',
- 'str',
- 'int'
+ ['datetime', 'str', 'int']
)->run(Feed::nextFetchOnError($f['err_count']), $e->getMessage(), $feedID);
if ($throwError) {
throw $e;
@@ -1060,38 +1071,18 @@ class Database {
$qInsertEdition = $this->db->prepare("INSERT INTO arsse_editions(article) values(?)", 'int');
}
if (sizeof($feed->newItems)) {
- $qInsertArticle = $this->db->prepare(
+ $qInsertArticle = $this->db->prepareArray(
"INSERT INTO arsse_articles(url,title,author,published,edited,guid,content,url_title_hash,url_content_hash,title_content_hash,feed) values(?,?,?,?,?,?,?,?,?,?,?)",
- 'str',
- 'str',
- 'str',
- 'datetime',
- 'datetime',
- 'str',
- 'str',
- 'str',
- 'str',
- 'str',
- 'int'
+ ['str', 'str', 'str', 'datetime', 'datetime', 'str', 'str', 'str', 'str', 'str', 'int']
);
}
if (sizeof($feed->changedItems)) {
$qDeleteEnclosures = $this->db->prepare("DELETE FROM arsse_enclosures WHERE article = ?", 'int');
$qDeleteCategories = $this->db->prepare("DELETE FROM arsse_categories WHERE article = ?", 'int');
$qClearReadMarks = $this->db->prepare("UPDATE arsse_marks SET \"read\" = 0, modified = CURRENT_TIMESTAMP WHERE article = ? and \"read\" = 1", 'int');
- $qUpdateArticle = $this->db->prepare(
+ $qUpdateArticle = $this->db->prepareArray(
"UPDATE arsse_articles SET url = ?, title = ?, author = ?, published = ?, edited = ?, modified = CURRENT_TIMESTAMP, guid = ?, content = ?, url_title_hash = ?, url_content_hash = ?, title_content_hash = ? WHERE id = ?",
- 'str',
- 'str',
- 'str',
- 'datetime',
- 'datetime',
- 'str',
- 'str',
- 'str',
- 'str',
- 'str',
- 'int'
+ ['str', 'str', 'str', 'datetime', 'datetime', 'str', 'str', 'str', 'str', 'str', 'int']
);
}
// determine if the feed icon needs to be updated, and update it if appropriate
@@ -1159,16 +1150,9 @@ class Database {
$qClearReadMarks->run($articleID);
}
// lastly update the feed database itself with updated information.
- $this->db->prepare(
+ $this->db->prepareArray(
"UPDATE arsse_feeds SET title = ?, source = ?, updated = CURRENT_TIMESTAMP, modified = ?, etag = ?, err_count = 0, err_msg = '', next_fetch = ?, size = ?, icon = ? WHERE id = ?",
- 'str',
- 'str',
- 'datetime',
- 'strict str',
- 'datetime',
- 'int',
- 'int',
- 'int'
+ ['str', 'str', 'datetime', 'strict str', 'datetime', 'int', 'int', 'int']
)->run(
$feed->data->title,
$feed->data->siteUrl,
@@ -1205,9 +1189,9 @@ class Database {
}
/** Retrieves the set of filters users have applied to a given feed
- *
+ *
* Each record includes the following keys:
- *
+ *
* - "owner": The user for whom to apply the filters
* - "keep": The "keep" rule; any articles which fail to match this rule are hidden
* - "block": The block rule; any article which matches this rule are hidden
@@ -1258,13 +1242,9 @@ class Database {
[$cHashUC, $tHashUC, $vHashUC] = $this->generateIn($hashesUC, "str");
[$cHashTC, $tHashTC, $vHashTC] = $this->generateIn($hashesTC, "str");
// perform the query
- return $this->db->prepare(
+ return $this->db->prepareArray(
"SELECT id, edited, guid, url_title_hash, url_content_hash, title_content_hash FROM arsse_articles WHERE feed = ? and (guid in($cId) or url_title_hash in($cHashUT) or url_content_hash in($cHashUC) or title_content_hash in($cHashTC))",
- 'int',
- $tId,
- $tHashUT,
- $tHashUC,
- $tHashTC
+ ['int', $tId, $tHashUT, $tHashUC, $tHashTC]
)->run($feedID, $vId, $vHashUT, $vHashUC, $vHashTC);
}
@@ -1880,15 +1860,14 @@ class Database {
if (!V::id($id)) {
throw new Db\ExceptionInput("typeViolation", ["action" => $this->caller(), "field" => "article", 'type' => "int > 0"]); // @codeCoverageIgnore
}
- $out = $this->db->prepare(
+ $out = $this->db->prepareArray(
"SELECT articles.article as article, max(arsse_editions.id) as edition from (
select arsse_articles.id as article
FROM arsse_articles
join arsse_subscriptions on arsse_subscriptions.feed = arsse_articles.feed
WHERE arsse_articles.id = ? and arsse_subscriptions.owner = ?
) as articles join arsse_editions on arsse_editions.article = articles.article group by articles.article",
- "int",
- "str"
+ ["int", "str"]
)->run($id, $user)->getRow();
if (!$out) {
throw new Db\ExceptionInput("subjectMissing", ["action" => $this->caller(), "field" => "article", 'id' => $id]);
@@ -1907,7 +1886,7 @@ class Database {
if (!V::id($id)) {
throw new Db\ExceptionInput("typeViolation", ["action" => $this->caller(), "field" => "edition", 'type' => "int > 0"]); // @codeCoverageIgnore
}
- $out = $this->db->prepare(
+ $out = $this->db->prepareArray(
"SELECT
arsse_editions.id, arsse_editions.article, edition_stats.edition as current
from arsse_editions
@@ -1915,8 +1894,7 @@ class Database {
join arsse_subscriptions on arsse_subscriptions.feed = arsse_articles.feed
join (select article, max(id) as edition from arsse_editions group by article) as edition_stats on edition_stats.article = arsse_editions.article
where arsse_editions.id = ? and arsse_subscriptions.owner = ?",
- "int",
- "str"
+ ["int", "str"]
)->run($id, $user)->getRow();
if (!$out) {
throw new Db\ExceptionInput("subjectMissing", ["action" => $this->caller(), "field" => "edition", 'id' => $id]);
@@ -1968,7 +1946,7 @@ class Database {
* @param boolean $includeEmpty Whether to include (true) or supress (false) labels which have no articles assigned to them
*/
public function labelList(string $user, bool $includeEmpty = true): Db\Result {
- return $this->db->prepare(
+ return $this->db->prepareArray(
"SELECT * FROM (
SELECT
id,
@@ -1992,11 +1970,8 @@ class Database {
) as mark_stats on mark_stats.label = arsse_labels.id
WHERE owner = ?
) as label_data
- where articles >= ? order by name
- ",
- "str",
- "str",
- "int"
+ where articles >= ? order by name",
+ ["str", "str", "int"]
)->run($user, $user, !$includeEmpty);
}
@@ -2036,7 +2011,7 @@ class Database {
$this->labelValidateId($user, $id, $byName, false);
$field = $byName ? "name" : "id";
$type = $byName ? "str" : "int";
- $out = $this->db->prepare(
+ $out = $this->db->prepareArray(
"SELECT
id,
name,
@@ -2057,11 +2032,8 @@ class Database {
where arsse_subscriptions.owner = ?
group by label
) as mark_stats on mark_stats.label = arsse_labels.id
- WHERE $field = ? and owner = ?
- ",
- "str",
- $type,
- "str"
+ WHERE $field = ? and owner = ?",
+ ["str", $type, "str"]
)->run($user, $id, $user)->getRow();
if (!$out) {
throw new Db\ExceptionInput("subjectMissing", ["action" => __FUNCTION__, "field" => "label", 'id' => $id]);
@@ -2113,6 +2085,7 @@ class Database {
}
try {
$q = $this->articleQuery($user, $c);
+ $q->setOrder("id");
$out = $this->db->prepare((string) $q, $q->getTypes())->run($q->getValues())->getAll();
} catch (Db\ExceptionInput $e) {
if ($e->getCode() === 10235) {
@@ -2261,7 +2234,7 @@ class Database {
* @param boolean $includeEmpty Whether to include (true) or supress (false) tags which have no subscriptions assigned to them
*/
public function tagList(string $user, bool $includeEmpty = true): Db\Result {
- return $this->db->prepare(
+ return $this->db->prepareArray(
"SELECT * FROM (
SELECT
id,name,coalesce(subscriptions,0) as subscriptions
@@ -2269,10 +2242,8 @@ class Database {
left join (SELECT tag, sum(assigned) as subscriptions from arsse_tag_members group by tag) as tag_stats on tag_stats.tag = arsse_tags.id
WHERE owner = ?
) as tag_data
- where subscriptions >= ? order by name
- ",
- "str",
- "int"
+ where subscriptions >= ? order by name",
+ ["str", "int"]
)->run($user, !$includeEmpty);
}
@@ -2288,7 +2259,7 @@ class Database {
* @param string $user The user whose tags are to be listed
*/
public function tagSummarize(string $user): Db\Result {
- return $this->db->prepare(
+ return $this->db->prepareArray(
"SELECT
arsse_tags.id as id,
arsse_tags.name as name,
@@ -2296,7 +2267,7 @@ class Database {
FROM arsse_tag_members
join arsse_tags on arsse_tags.id = arsse_tag_members.tag
WHERE arsse_tags.owner = ? and assigned = 1",
- "str"
+ ["str"]
)->run($user);
}
@@ -2335,15 +2306,13 @@ class Database {
$this->tagValidateId($user, $id, $byName, false);
$field = $byName ? "name" : "id";
$type = $byName ? "str" : "int";
- $out = $this->db->prepare(
+ $out = $this->db->prepareArray(
"SELECT
id,name,coalesce(subscriptions,0) as subscriptions
FROM arsse_tags
left join (SELECT tag, sum(assigned) as subscriptions from arsse_tag_members group by tag) as tag_stats on tag_stats.tag = arsse_tags.id
- WHERE $field = ? and owner = ?
- ",
- $type,
- "str"
+ WHERE $field = ? and owner = ?",
+ [$type, "str"]
)->run($id, $user)->getRow();
if (!$out) {
throw new Db\ExceptionInput("subjectMissing", ["action" => __FUNCTION__, "field" => "tag", 'id' => $id]);
diff --git a/lib/REST/Fever/API.php b/lib/REST/Fever/API.php
index 2d5fcc1c..8c94a8dd 100644
--- a/lib/REST/Fever/API.php
+++ b/lib/REST/Fever/API.php
@@ -270,7 +270,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler {
} else {
// group zero is the "Kindling" supergroup i.e. all feeds
// only exclude hidden articles
- $c->hidden(false);
+ $c->hidden(false);
}
break;
case "feed":
diff --git a/lib/REST/Miniflux/V1.php b/lib/REST/Miniflux/V1.php
index 5474dc0b..bf3da2ea 100644
--- a/lib/REST/Miniflux/V1.php
+++ b/lib/REST/Miniflux/V1.php
@@ -130,7 +130,7 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
return true;
}
}
- // next check HTTP auth
+ // next check HTTP auth
if ($req->getAttribute("authenticated", false)) {
Arsse::$user->id = $req->getAttribute("authenticatedUser");
return true;
@@ -318,7 +318,7 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
return new ErrorResponse($msg, 500);
}
$out = [];
- foreach($list as $url) {
+ foreach ($list as $url) {
// TODO: This needs to be refined once PicoFeed is replaced
$out[] = ['title' => "Feed", 'type' => "rss", 'url' => $url];
}
@@ -345,7 +345,7 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
return new ErrorResponse("404", 404);
}
}
-
+
protected function getCurrentUser(): ResponseInterface {
return new Response($this->listUsers([Arsse::$user->id], false)[0] ?? new \stdClass);
}
@@ -411,7 +411,7 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
Arsse::$db->folderRemove(Arsse::$user->id, $folder);
} else {
// if we're deleting from the root folder, delete each child subscription individually
- // otherwise we'd be deleting the entire tree
+ // otherwise we'd be deleting the entire tree
$tr = Arsse::$db->begin();
foreach (Arsse::$db->subscriptionList(Arsse::$user->id, null, false) as $sub) {
Arsse::$db->subscriptionRemove(Arsse::$user->id, $sub['id']);
diff --git a/tests/cases/Database/SeriesArticle.php b/tests/cases/Database/SeriesArticle.php
index 033bbfde..c444977f 100644
--- a/tests/cases/Database/SeriesArticle.php
+++ b/tests/cases/Database/SeriesArticle.php
@@ -1147,4 +1147,4 @@ trait SeriesArticle {
$state = $this->primeExpectations($this->data, $this->checkTables);
$this->compareExpectations(static::$drv, $state);
}
-}
\ No newline at end of file
+}
diff --git a/tests/cases/Database/SeriesLabel.php b/tests/cases/Database/SeriesLabel.php
index ec82613c..4a4fac66 100644
--- a/tests/cases/Database/SeriesLabel.php
+++ b/tests/cases/Database/SeriesLabel.php
@@ -209,7 +209,7 @@ trait SeriesLabel {
[11, 20,1,0,'2017-01-01 00:00:00',0],
[12, 3,0,1,'2017-01-01 00:00:00',0],
[12, 4,1,1,'2017-01-01 00:00:00',0],
- [4, 8,0,0,'2000-01-02 02:00:00',1]
+ [4, 8,0,0,'2000-01-02 02:00:00',1],
],
],
'arsse_labels' => [
diff --git a/tests/cases/Database/SeriesSubscription.php b/tests/cases/Database/SeriesSubscription.php
index 0e14a7bf..7fe700a8 100644
--- a/tests/cases/Database/SeriesSubscription.php
+++ b/tests/cases/Database/SeriesSubscription.php
@@ -23,7 +23,7 @@ trait SeriesSubscription {
'rows' => [
["jane.doe@example.com", "", 1],
["john.doe@example.com", "", 2],
- ["jill.doe@example.com", "", 3]
+ ["jill.doe@example.com", "", 3],
],
],
'arsse_folders' => [
diff --git a/tests/cases/Database/SeriesUser.php b/tests/cases/Database/SeriesUser.php
index 7ed01822..af591d48 100644
--- a/tests/cases/Database/SeriesUser.php
+++ b/tests/cases/Database/SeriesUser.php
@@ -157,7 +157,7 @@ trait SeriesUser {
public function testSetNoMetadata(): void {
$in = [
- 'num' => 2112,
+ 'num' => 2112,
'stylesheet' => "body {background:lightgray}",
];
$this->assertTrue(Arsse::$db->userPropertiesSet("john.doe@example.com", $in));
diff --git a/tests/cases/REST/Miniflux/TestV1.php b/tests/cases/REST/Miniflux/TestV1.php
index 3778a547..918a3da6 100644
--- a/tests/cases/REST/Miniflux/TestV1.php
+++ b/tests/cases/REST/Miniflux/TestV1.php
@@ -62,7 +62,7 @@ class TestV1 extends \JKingWeb\Arsse\Test\AbstractTest {
'extra' => [
'custom_css' => "",
],
- ]
+ ],
];
protected function req(string $method, string $target, $data = "", array $headers = [], ?string $user = "john.doe@example.com", bool $body = true): ResponseInterface {
@@ -185,8 +185,8 @@ class TestV1 extends \JKingWeb\Arsse\Test\AbstractTest {
/** @dataProvider provideUserQueries */
public function testQueryUsers(bool $admin, string $route, ResponseInterface $exp): void {
$u = [
- ['num'=> 1, 'admin' => true, 'theme' => "custom", 'lang' => "fr_CA", 'tz' => "Asia/Gaza", 'sort_asc' => true, 'page_size' => 200, 'shortcuts' => false, 'reading_time' => false, 'swipe' => false, 'stylesheet' => "p {}"],
- ['num'=> 2, 'admin' => false, 'theme' => null, 'lang' => null, 'tz' => null, 'sort_asc' => null, 'page_size' => null, 'shortcuts' => null, 'reading_time' => null, 'swipe' => null, 'stylesheet' => null],
+ ['num' => 1, 'admin' => true, 'theme' => "custom", 'lang' => "fr_CA", 'tz' => "Asia/Gaza", 'sort_asc' => true, 'page_size' => 200, 'shortcuts' => false, 'reading_time' => false, 'swipe' => false, 'stylesheet' => "p {}"],
+ ['num' => 2, 'admin' => false, 'theme' => null, 'lang' => null, 'tz' => null, 'sort_asc' => null, 'page_size' => null, 'shortcuts' => null, 'reading_time' => null, 'swipe' => null, 'stylesheet' => null],
new ExceptionConflict("doesNotExist"),
];
$user = $admin ? "john.doe@example.com" : "jane.doe@example.com";
diff --git a/tests/cases/REST/TinyTinyRSS/TestAPI.php b/tests/cases/REST/TinyTinyRSS/TestAPI.php
index abcbdd9b..923380dd 100644
--- a/tests/cases/REST/TinyTinyRSS/TestAPI.php
+++ b/tests/cases/REST/TinyTinyRSS/TestAPI.php
@@ -1716,7 +1716,7 @@ LONG_STRING;
}
$this->assertMessage($exp, $this->req($in));
if ($out) {
- \Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, $c, $fields, $order);
+ \Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, $c, $fields, $order);
} else {
\Phake::verify(Arsse::$db, \Phake::times(0))->articleList;
}
diff --git a/tests/cases/User/TestUser.php b/tests/cases/User/TestUser.php
index b7d1266e..597a1583 100644
--- a/tests/cases/User/TestUser.php
+++ b/tests/cases/User/TestUser.php
@@ -98,7 +98,6 @@ class TestUser extends \JKingWeb\Arsse\Test\AbstractTest {
\Phake::verify(Arsse::$db)->userLookup(2112);
}
-
public function testAddAUser(): void {
$user = "john.doe@example.com";
$pass = "secret";
@@ -439,7 +438,7 @@ class TestUser extends \JKingWeb\Arsse\Test\AbstractTest {
[['tz' => false], new ExceptionInput("invalidValue")],
[['lang' => "en-ca"], ['lang' => "en-CA"]],
[['lang' => null], ['lang' => null]],
- [['page_size' => 0], new ExceptionInput("invalidNonZeroInteger")]
+ [['page_size' => 0], new ExceptionInput("invalidNonZeroInteger")],
];
}
From 88cf3c6dae2cbb66ba9042ea59d791479a5c7c68 Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Wed, 23 Dec 2020 09:38:22 -0500
Subject: [PATCH 086/366] Test filter rule retrieval
---
lib/Database.php | 2 +-
tests/cases/Database/SeriesFeed.php | 36 +++++++++++++++++++++--------
2 files changed, 28 insertions(+), 10 deletions(-)
diff --git a/lib/Database.php b/lib/Database.php
index 6663e0ff..1d5405aa 100644
--- a/lib/Database.php
+++ b/lib/Database.php
@@ -1197,7 +1197,7 @@ class Database {
* - "block": The block rule; any article which matches this rule are hidden
*/
public function feedRulesGet(int $feedID): Db\Result {
- return $this->db->prepare("SELECT owner, keep_rule as keep, block_rule as block from arsse_subscriptions where feed = ? and (coalesce(keep_rule, '') || coalesce(block_rule, '')) <> ''", "int")->run($feedID);
+ return $this->db->prepare("SELECT owner, coalesce(keep_rule, '') as keep, coalesce(block_rule, '') as block from arsse_subscriptions where feed = ? and (keep || block) <> '' order by owner", "int")->run($feedID);
}
/** Retrieves various identifiers for the latest $count articles in the given newsfeed. The identifiers are:
diff --git a/tests/cases/Database/SeriesFeed.php b/tests/cases/Database/SeriesFeed.php
index f79c8cc9..8f17694a 100644
--- a/tests/cases/Database/SeriesFeed.php
+++ b/tests/cases/Database/SeriesFeed.php
@@ -7,6 +7,7 @@ declare(strict_types=1);
namespace JKingWeb\Arsse\TestCase\Database;
use JKingWeb\Arsse\Arsse;
+use JKingWeb\Arsse\Test\Result;
trait SeriesFeed {
protected function setUpSeriesFeed(): void {
@@ -67,17 +68,19 @@ trait SeriesFeed {
],
'arsse_subscriptions' => [
'columns' => [
- 'id' => "int",
- 'owner' => "str",
- 'feed' => "int",
+ 'id' => "int",
+ 'owner' => "str",
+ 'feed' => "int",
+ 'keep_rule' => "str",
+ 'block_rule' => "str",
],
'rows' => [
- [1,'john.doe@example.com',1],
- [2,'john.doe@example.com',2],
- [3,'john.doe@example.com',3],
- [4,'john.doe@example.com',4],
- [5,'john.doe@example.com',5],
- [6,'jane.doe@example.com',1],
+ [1,'john.doe@example.com',1,null,'^Sport$'],
+ [2,'john.doe@example.com',2,null,null],
+ [3,'john.doe@example.com',3,'\w+',null],
+ [4,'john.doe@example.com',4,null,null],
+ [5,'john.doe@example.com',5,null,'and/or'],
+ [6,'jane.doe@example.com',1,'^(?i)[a-z]+','bluberry'],
],
],
'arsse_articles' => [
@@ -200,6 +203,21 @@ trait SeriesFeed {
$this->assertResult([['id' => 1]], Arsse::$db->feedMatchIds(1, ['e433653cef2e572eee4215fa299a4a5af9137b2cefd6283c85bd69a32915beda'])); // this ID appears in both feed 1 and feed 2; only one result should be returned
}
+ /** @dataProvider provideFilterRules */
+ public function testGetRules(int $in, array $exp): void {
+ $this->assertResult($exp, Arsse::$db->feedRulesGet($in));
+ }
+
+ public function provideFilterRules(): iterable {
+ return [
+ [1, [['owner' => "john.doe@example.com", 'keep' => "", 'block' => "^Sport$"], ['owner' => "jane.doe@example.com", 'keep' => "^(?i)[a-z]+", 'block' => "bluberry"]]],
+ [2, []],
+ [3, [['owner' => "john.doe@example.com", 'keep' => '\w+', 'block' => ""]]],
+ [4, []],
+ [5, [['owner' => "john.doe@example.com", 'keep' => "", 'block' => "and/or"]]],
+ ];
+ }
+
public function testUpdateAFeed(): void {
// update a valid feed with both new and changed items
Arsse::$db->feedUpdate(1);
From 5ec04d33c621f201db40eb25af34e1084b6c2b09 Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Fri, 25 Dec 2020 17:47:36 -0500
Subject: [PATCH 087/366] Add backend functionality to rename users
---
lib/Database.php | 14 ++++++++
lib/User.php | 27 ++++++++++++++++
lib/User/Driver.php | 9 +++++-
lib/User/Internal/Driver.php | 23 ++++++++++---
tests/cases/Database/SeriesUser.php | 25 +++++++++++++++
tests/cases/User/TestInternal.php | 24 +++++++++++++-
tests/cases/User/TestUser.php | 50 +++++++++++++++++++++++++++--
7 files changed, 164 insertions(+), 8 deletions(-)
diff --git a/lib/Database.php b/lib/Database.php
index 1d5405aa..95ccc611 100644
--- a/lib/Database.php
+++ b/lib/Database.php
@@ -273,6 +273,20 @@ class Database {
return true;
}
+ public function userRename(string $user, string $name): bool {
+ if ($user === $name) {
+ return false;
+ }
+ try {
+ if (!$this->db->prepare("UPDATE arsse_users set id = ? where id = ?", "str", "str")->run($name, $user)->changes()) {
+ throw new User\ExceptionConflict("doesNotExist", ["action" => __FUNCTION__, "user" => $user]);
+ }
+ } catch (Db\ExceptionInput $e) {
+ throw new User\ExceptionConflict("alreadyExists", ["action" => __FUNCTION__, "user" => $name], $e);
+ }
+ return true;
+ }
+
/** Removes a user from the database */
public function userRemove(string $user): bool {
if ($this->db->prepare("DELETE from arsse_users where id = ?", "str")->run($user)->changes() < 1) {
diff --git a/lib/User.php b/lib/User.php
index df9a49d9..1c7979bc 100644
--- a/lib/User.php
+++ b/lib/User.php
@@ -42,6 +42,21 @@ class User {
return (string) $this->id;
}
+ public function begin(): Db\Transaction {
+ /* TODO: A proper implementation of this would return a meta-transaction
+ object which would contain both a user-manager transaction (when
+ applicable) and a database transaction, and commit or roll back both
+ as the situation calls.
+
+ In theory, an external user driver would probably have to implement its
+ own approximation of atomic transactions and rollback. In practice the
+ only driver is the internal one, which is always backed by an ACID
+ database; the added complexity is thus being deferred until such time
+ as it is actually needed for a concrete implementation.
+ */
+ return Arsse::$db->begin();
+ }
+
public function auth(string $user, string $password): bool {
$prevUser = $this->id;
$this->id = $user;
@@ -89,6 +104,18 @@ class User {
return $out;
}
+ public function rename(string $user, string $newName): bool {
+ if ($this->u->userRename($user, $newName)) {
+ if (!Arsse::$db->userExists($user)) {
+ Arsse::$db->userAdd($newName, null);
+ return true;
+ } else {
+ return Arsse::$db->userRename($user, $newName);
+ }
+ }
+ return false;
+ }
+
public function remove(string $user): bool {
try {
$out = $this->u->userRemove($user);
diff --git a/lib/User/Driver.php b/lib/User/Driver.php
index e0d949c7..d4b73706 100644
--- a/lib/User/Driver.php
+++ b/lib/User/Driver.php
@@ -27,6 +27,13 @@ interface Driver {
*/
public function userAdd(string $user, string $password = null): ?string;
+ /** Renames a user
+ *
+ * The implementation must retain all user metadata as well as the
+ * user's password
+ */
+ public function userRename(string $user, string $newName): bool;
+
/** Removes a user */
public function userRemove(string $user): bool;
@@ -44,7 +51,7 @@ interface Driver {
* @param string|null $password The cleartext password to assign to the user, or null to generate a random password
* @param string|null $oldPassword The user's previous password, if known
*/
- public function userPasswordSet(string $user, ?string $newPassword, string $oldPassword = null);
+ public function userPasswordSet(string $user, ?string $newPassword, string $oldPassword = null): ?string;
/** Removes a user's password; this makes authentication fail unconditionally
*
diff --git a/lib/User/Internal/Driver.php b/lib/User/Internal/Driver.php
index 27486fb1..80f16bb3 100644
--- a/lib/User/Internal/Driver.php
+++ b/lib/User/Internal/Driver.php
@@ -40,6 +40,16 @@ class Driver implements \JKingWeb\Arsse\User\Driver {
return $password;
}
+ public function userRename(string $user, string $newName): bool {
+ // do nothing: the internal database is updated regardless of what the driver does (assuming it does not throw an exception)
+ // throw an exception if the user does not exist
+ if (!$this->userExists($user)) {
+ throw new ExceptionConflict("doesNotExist", ['action' => __FUNCTION__, 'user' => $user]);
+ } else {
+ return !($user === $newName);
+ }
+ }
+
public function userRemove(string $user): bool {
return Arsse::$db->userRemove($user);
}
@@ -50,14 +60,19 @@ class Driver implements \JKingWeb\Arsse\User\Driver {
public function userPasswordSet(string $user, ?string $newPassword, string $oldPassword = null): ?string {
// do nothing: the internal database is updated regardless of what the driver does (assuming it does not throw an exception)
- return $newPassword;
+ // throw an exception if the user does not exist
+ if (!$this->userExists($user)) {
+ throw new ExceptionConflict("doesNotExist", ['action' => __FUNCTION__, 'user' => $user]);
+ } else {
+ return $newPassword;
+ }
}
public function userPasswordUnset(string $user, string $oldPassword = null): bool {
// do nothing: the internal database is updated regardless of what the driver does (assuming it does not throw an exception)
// throw an exception if the user does not exist
if (!$this->userExists($user)) {
- throw new ExceptionConflict("doesNotExist", ['action' => "userPasswordUnset", 'user' => $user]);
+ throw new ExceptionConflict("doesNotExist", ['action' => __FUNCTION__, 'user' => $user]);
} else {
return true;
}
@@ -74,7 +89,7 @@ class Driver implements \JKingWeb\Arsse\User\Driver {
public function userPropertiesGet(string $user, bool $includeLarge = true): array {
// do nothing: the internal database will retrieve everything for us
if (!$this->userExists($user)) {
- throw new ExceptionConflict("doesNotExist", ['action' => "userPasswordUnset", 'user' => $user]);
+ throw new ExceptionConflict("doesNotExist", ['action' => __FUNCTION__, 'user' => $user]);
} else {
return [];
}
@@ -83,7 +98,7 @@ class Driver implements \JKingWeb\Arsse\User\Driver {
public function userPropertiesSet(string $user, array $data): array {
// do nothing: the internal database will set everything for us
if (!$this->userExists($user)) {
- throw new ExceptionConflict("doesNotExist", ['action' => "userPasswordUnset", 'user' => $user]);
+ throw new ExceptionConflict("doesNotExist", ['action' => __FUNCTION__, 'user' => $user]);
} else {
return $data;
}
diff --git a/tests/cases/Database/SeriesUser.php b/tests/cases/Database/SeriesUser.php
index af591d48..0cd4ffb7 100644
--- a/tests/cases/Database/SeriesUser.php
+++ b/tests/cases/Database/SeriesUser.php
@@ -180,4 +180,29 @@ trait SeriesUser {
$this->assertException("doesNotExist", "User", "ExceptionConflict");
Arsse::$db->userLookup(2112);
}
+
+ public function testRenameAUser(): void {
+ $this->assertTrue(Arsse::$db->userRename("john.doe@example.com", "juan.doe@example.com"));
+ $state = $this->primeExpectations($this->data, [
+ 'arsse_users' => ['id', 'num'],
+ 'arsse_user_meta' => ["owner", "key", "value"]
+ ]);
+ $state['arsse_users']['rows'][2][0] = "juan.doe@example.com";
+ $state['arsse_user_meta']['rows'][6][0] = "juan.doe@example.com";
+ $this->compareExpectations(static::$drv, $state);
+ }
+
+ public function testRenameAUserToTheSameName(): void {
+ $this->assertFalse(Arsse::$db->userRename("john.doe@example.com", "john.doe@example.com"));
+ }
+
+ public function testRenameAMissingUser(): void {
+ $this->assertException("doesNotExist", "User", "ExceptionConflict");
+ Arsse::$db->userRename("juan.doe@example.com", "john.doe@example.com");
+ }
+
+ public function testRenameAUserToADuplicateName(): void {
+ $this->assertException("alreadyExists", "User", "ExceptionConflict");
+ Arsse::$db->userRename("john.doe@example.com", "jane.doe@example.com");
+ }
}
diff --git a/tests/cases/User/TestInternal.php b/tests/cases/User/TestInternal.php
index c7038352..858a8765 100644
--- a/tests/cases/User/TestInternal.php
+++ b/tests/cases/User/TestInternal.php
@@ -9,6 +9,7 @@ namespace JKingWeb\Arsse\TestCase\User;
use JKingWeb\Arsse\Arsse;
use JKingWeb\Arsse\Database;
use JKingWeb\Arsse\User\Driver as DriverInterface;
+use JKingWeb\Arsse\User\ExceptionConflict;
use JKingWeb\Arsse\User\Internal\Driver;
/** @covers \JKingWeb\Arsse\User\Internal\Driver */
@@ -88,6 +89,21 @@ class TestInternal extends \JKingWeb\Arsse\Test\AbstractTest {
\Phake::verify(Arsse::$db)->userAdd;
}
+ public function testRenameAUser(): void {
+ $john = "john.doe@example.com";
+ \Phake::when(Arsse::$db)->userExists->thenReturn(true);
+ $this->assertTrue((new Driver)->userRename($john, "jane.doe@example.com"));
+ $this->assertFalse((new Driver)->userRename($john, $john));
+ \Phake::verify(Arsse::$db, \Phake::times(2))->userExists($john);
+ }
+
+ public function testRenameAMissingUser(): void {
+ $john = "john.doe@example.com";
+ \Phake::when(Arsse::$db)->userExists->thenReturn(false);
+ $this->assertException("doesNotExist", "User", "ExceptionConflict");
+ (new Driver)->userRename($john, "jane.doe@example.com");
+ }
+
public function testRemoveAUser(): void {
$john = "john.doe@example.com";
\Phake::when(Arsse::$db)->userRemove->thenReturn(true)->thenThrow(new \JKingWeb\Arsse\User\ExceptionConflict("doesNotExist"));
@@ -104,12 +120,18 @@ class TestInternal extends \JKingWeb\Arsse\Test\AbstractTest {
public function testSetAPassword(): void {
$john = "john.doe@example.com";
- \Phake::verifyNoFurtherInteraction(Arsse::$db);
+ \Phake::when(Arsse::$db)->userExists->thenReturn(true);
$this->assertSame("superman", (new Driver)->userPasswordSet($john, "superman"));
$this->assertSame(null, (new Driver)->userPasswordSet($john, null));
\Phake::verify(Arsse::$db, \Phake::times(0))->userPasswordSet;
}
+ public function testSetAPasswordForAMssingUser(): void {
+ \Phake::when(Arsse::$db)->userExists->thenReturn(false);
+ $this->assertException("doesNotExist", "User", "ExceptionConflict");
+ (new Driver)->userPasswordSet("john.doe@example.com", "secret");
+ }
+
public function testUnsetAPassword(): void {
\Phake::when(Arsse::$db)->userExists->thenReturn(true);
$this->assertTrue((new Driver)->userPasswordUnset("john.doe@example.com"));
diff --git a/tests/cases/User/TestUser.php b/tests/cases/User/TestUser.php
index 597a1583..e42832e9 100644
--- a/tests/cases/User/TestUser.php
+++ b/tests/cases/User/TestUser.php
@@ -9,6 +9,7 @@ namespace JKingWeb\Arsse\TestCase\User;
use JKingWeb\Arsse\Arsse;
use JKingWeb\Arsse\Database;
use JKingWeb\Arsse\User;
+use JKingWeb\Arsse\Db\Transaction;
use JKingWeb\Arsse\User\ExceptionConflict;
use JKingWeb\Arsse\User\ExceptionInput;
use JKingWeb\Arsse\User\Driver;
@@ -43,6 +44,13 @@ class TestUser extends \JKingWeb\Arsse\Test\AbstractTest {
$this->assertSame("", (string) $u);
}
+ public function testStartATransaction(): void {
+ \Phake::when(Arsse::$db)->begin->thenReturn(\Phake::mock(Transaction::class));
+ $u = new User($this->drv);
+ $this->assertInstanceOf(Transaction::class, $u->begin());
+ \Phake::verify(Arsse::$db)->begin();
+ }
+
public function testGeneratePasswords(): void {
$u = new User($this->drv);
$pass1 = $u->generatePassword();
@@ -174,9 +182,48 @@ class TestUser extends \JKingWeb\Arsse\Test\AbstractTest {
\Phake::verify(Arsse::$db)->userExists($user);
}
+ public function testRenameAUser(): void {
+ \Phake::when(Arsse::$db)->userExists->thenReturn(true);
+ \Phake::when(Arsse::$db)->userAdd->thenReturn(true);
+ \Phake::when(Arsse::$db)->userRename->thenReturn(true);
+ \Phake::when($this->drv)->userRename->thenReturn(true);
+ $u = new User($this->drv);
+ $old = "john.doe@example.com";
+ $new = "jane.doe@example.com";
+ $this->assertTrue($u->rename($old, $new));
+ \Phake::verify($this->drv)->userRename($old, $new);
+ \Phake::verify(Arsse::$db)->userExists($old);
+ \Phake::verify(Arsse::$db)->userRename($old, $new);
+ }
+
+ public function testRenameAUserWeDoNotKnow(): void {
+ \Phake::when(Arsse::$db)->userExists->thenReturn(false);
+ \Phake::when(Arsse::$db)->userAdd->thenReturn(true);
+ \Phake::when(Arsse::$db)->userRename->thenReturn(true);
+ \Phake::when($this->drv)->userRename->thenReturn(true);
+ $u = new User($this->drv);
+ $old = "john.doe@example.com";
+ $new = "jane.doe@example.com";
+ $this->assertTrue($u->rename($old, $new));
+ \Phake::verify($this->drv)->userRename($old, $new);
+ \Phake::verify(Arsse::$db)->userExists($old);
+ \Phake::verify(Arsse::$db)->userAdd($new, null);
+ }
+
+ public function testRenameAUserWithoutEffect(): void {
+ \Phake::when(Arsse::$db)->userExists->thenReturn(false);
+ \Phake::when(Arsse::$db)->userAdd->thenReturn(true);
+ \Phake::when(Arsse::$db)->userRename->thenReturn(true);
+ \Phake::when($this->drv)->userRename->thenReturn(false);
+ $u = new User($this->drv);
+ $old = "john.doe@example.com";
+ $new = "jane.doe@example.com";
+ $this->assertFalse($u->rename($old, $old));
+ \Phake::verify($this->drv)->userRename($old, $old);
+ }
+
public function testRemoveAUser(): void {
$user = "john.doe@example.com";
- $pass = "secret";
$u = new User($this->drv);
\Phake::when($this->drv)->userRemove->thenReturn(true);
\Phake::when(Arsse::$db)->userExists->thenReturn(true);
@@ -188,7 +235,6 @@ class TestUser extends \JKingWeb\Arsse\Test\AbstractTest {
public function testRemoveAUserWeDoNotKnow(): void {
$user = "john.doe@example.com";
- $pass = "secret";
$u = new User($this->drv);
\Phake::when($this->drv)->userRemove->thenReturn(true);
\Phake::when(Arsse::$db)->userExists->thenReturn(false);
From 405f3af257f3ef4645e560fbb62697d2330a50a5 Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Fri, 25 Dec 2020 22:22:37 -0500
Subject: [PATCH 088/366] Invalidate sessions and Fever passwords when renaming
users
---
lib/User.php | 9 +++++++--
tests/cases/User/TestUser.php | 26 ++++++++++++++++++++------
2 files changed, 27 insertions(+), 8 deletions(-)
diff --git a/lib/User.php b/lib/User.php
index 1c7979bc..d0bbbf80 100644
--- a/lib/User.php
+++ b/lib/User.php
@@ -106,12 +106,17 @@ class User {
public function rename(string $user, string $newName): bool {
if ($this->u->userRename($user, $newName)) {
+ $tr = Arsse::$db->begin();
if (!Arsse::$db->userExists($user)) {
Arsse::$db->userAdd($newName, null);
- return true;
} else {
- return Arsse::$db->userRename($user, $newName);
+ Arsse::$db->userRename($user, $newName);
+ // invalidate any sessions and Fever passwords
+ Arsse::$db->sessionDestroy($newName);
+ Arsse::$db->tokenRevoke($newName, "fever.login");
}
+ $tr->commit();
+ return true;
}
return false;
}
diff --git a/tests/cases/User/TestUser.php b/tests/cases/User/TestUser.php
index e42832e9..c2a2645d 100644
--- a/tests/cases/User/TestUser.php
+++ b/tests/cases/User/TestUser.php
@@ -183,6 +183,8 @@ class TestUser extends \JKingWeb\Arsse\Test\AbstractTest {
}
public function testRenameAUser(): void {
+ $tr = \Phake::mock(Transaction::class);
+ \Phake::when(Arsse::$db)->begin->thenReturn($tr);
\Phake::when(Arsse::$db)->userExists->thenReturn(true);
\Phake::when(Arsse::$db)->userAdd->thenReturn(true);
\Phake::when(Arsse::$db)->userRename->thenReturn(true);
@@ -191,12 +193,20 @@ class TestUser extends \JKingWeb\Arsse\Test\AbstractTest {
$old = "john.doe@example.com";
$new = "jane.doe@example.com";
$this->assertTrue($u->rename($old, $new));
- \Phake::verify($this->drv)->userRename($old, $new);
- \Phake::verify(Arsse::$db)->userExists($old);
- \Phake::verify(Arsse::$db)->userRename($old, $new);
+ \Phake::inOrder(
+ \Phake::verify($this->drv)->userRename($old, $new),
+ \Phake::verify(Arsse::$db)->begin(),
+ \Phake::verify(Arsse::$db)->userExists($old),
+ \Phake::verify(Arsse::$db)->userRename($old, $new),
+ \Phake::verify(Arsse::$db)->sessionDestroy($new),
+ \Phake::verify(Arsse::$db)->tokenRevoke($new, "fever.login"),
+ \Phake::verify($tr)->commit()
+ );
}
public function testRenameAUserWeDoNotKnow(): void {
+ $tr = \Phake::mock(Transaction::class);
+ \Phake::when(Arsse::$db)->begin->thenReturn($tr);
\Phake::when(Arsse::$db)->userExists->thenReturn(false);
\Phake::when(Arsse::$db)->userAdd->thenReturn(true);
\Phake::when(Arsse::$db)->userRename->thenReturn(true);
@@ -205,9 +215,13 @@ class TestUser extends \JKingWeb\Arsse\Test\AbstractTest {
$old = "john.doe@example.com";
$new = "jane.doe@example.com";
$this->assertTrue($u->rename($old, $new));
- \Phake::verify($this->drv)->userRename($old, $new);
- \Phake::verify(Arsse::$db)->userExists($old);
- \Phake::verify(Arsse::$db)->userAdd($new, null);
+ \Phake::inOrder(
+ \Phake::verify($this->drv)->userRename($old, $new),
+ \Phake::verify(Arsse::$db)->begin(),
+ \Phake::verify(Arsse::$db)->userExists($old),
+ \Phake::verify(Arsse::$db)->userAdd($new, null),
+ \Phake::verify($tr)->commit()
+ );
}
public function testRenameAUserWithoutEffect(): void {
From 2946d950f2559dea004465866681c00725adc60c Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Sun, 27 Dec 2020 10:08:00 -0500
Subject: [PATCH 089/366] Forbid more user names
- Control characters are now forbidden
- Controls and colons are now also forbidden when renaming
---
CHANGELOG | 9 +++++----
lib/User.php | 13 ++++++++++---
tests/cases/User/TestUser.php | 27 +++++++++++++++++++++------
3 files changed, 36 insertions(+), 13 deletions(-)
diff --git a/CHANGELOG b/CHANGELOG
index a3847c4f..8580d40b 100644
--- a/CHANGELOG
+++ b/CHANGELOG
@@ -1,13 +1,14 @@
Version 0.9.0 (????-??-??)
==========================
+New features:
+- Support for the Miniflux protocol (see manual for details)
+
Bug fixes:
- Use icons specified in Atom feeds when available
- Do not return null as subscription unread count
-
-Changes:
-- Explicitly forbid U+003A COLON in usernames, for compatibility with HTTP
- Basic authentication
+- Explicitly forbid U+003A COLON and control characters in usernames, for
+ compatibility with RFC 7617
Version 0.8.5 (2020-10-27)
==========================
diff --git a/lib/User.php b/lib/User.php
index d0bbbf80..accec103 100644
--- a/lib/User.php
+++ b/lib/User.php
@@ -84,10 +84,11 @@ class User {
}
public function add(string $user, ?string $password = null): string {
- // ensure the user name does not contain any U+003A COLON characters, as
+ // ensure the user name does not contain any U+003A COLON or control characters, as
// this is incompatible with HTTP Basic authentication
- if (strpos($user, ":") !== false) {
- throw new User\ExceptionInput("invalidUsername", "U+003A COLON");
+ if (preg_match("/[\x{00}-\x{1F}\x{7F}:]/", $user, $m)) {
+ $c = ord($m[0]);
+ throw new User\ExceptionInput("invalidUsername", "U+".str_pad((string) $c, 4, "0", \STR_PAD_LEFT)." ".\IntlChar::charName($c, \IntlChar::EXTENDED_CHAR_NAME));
}
try {
$out = $this->u->userAdd($user, $password) ?? $this->u->userAdd($user, $this->generatePassword());
@@ -105,6 +106,12 @@ class User {
}
public function rename(string $user, string $newName): bool {
+ // ensure the new user name does not contain any U+003A COLON or
+ // control characters, as this is incompatible with HTTP Basic authentication
+ if (preg_match("/[\x{00}-\x{1F}\x{7F}:]/", $newName, $m)) {
+ $c = ord($m[0]);
+ throw new User\ExceptionInput("invalidUsername", "U+".str_pad((string) $c, 4, "0", \STR_PAD_LEFT)." ".\IntlChar::charName($c, \IntlChar::EXTENDED_CHAR_NAME));
+ }
if ($this->u->userRename($user, $newName)) {
$tr = Arsse::$db->begin();
if (!Arsse::$db->userExists($user)) {
diff --git a/tests/cases/User/TestUser.php b/tests/cases/User/TestUser.php
index c2a2645d..7c87e0c3 100644
--- a/tests/cases/User/TestUser.php
+++ b/tests/cases/User/TestUser.php
@@ -160,13 +160,22 @@ class TestUser extends \JKingWeb\Arsse\Test\AbstractTest {
}
}
- public function testAddAnInvalidUser(): void {
- $user = "john:doe@example.com";
- $pass = "secret";
+ /** @dataProvider provideInvalidUserNames */
+ public function testAddAnInvalidUser(string $user): void {
$u = new User($this->drv);
- \Phake::when($this->drv)->userAdd->thenThrow(new ExceptionInput("invalidUsername"));
$this->assertException("invalidUsername", "User", "ExceptionInput");
- $u->add($user, $pass);
+ $u->add($user, "secret");
+ }
+
+ public function provideInvalidUserNames(): iterable {
+ // output names with control characters
+ foreach (array_merge(range(0x00, 0x1F), [0x7F]) as $ord) {
+ yield [chr($ord)];
+ yield ["john".chr($ord)."doe@example.com"];
+ }
+ // also handle colons
+ yield [":"];
+ yield ["john:doe@example.com"];
}
public function testAddAUserWithARandomPassword(): void {
@@ -231,11 +240,17 @@ class TestUser extends \JKingWeb\Arsse\Test\AbstractTest {
\Phake::when($this->drv)->userRename->thenReturn(false);
$u = new User($this->drv);
$old = "john.doe@example.com";
- $new = "jane.doe@example.com";
$this->assertFalse($u->rename($old, $old));
\Phake::verify($this->drv)->userRename($old, $old);
}
+ /** @dataProvider provideInvalidUserNames */
+ public function testRenameAUserToAnInvalidName(string $new): void {
+ $u = new User($this->drv);
+ $this->assertException("invalidUsername", "User", "ExceptionInput");
+ $u->rename("john.doe@example.com", $new);
+ }
+
public function testRemoveAUser(): void {
$user = "john.doe@example.com";
$u = new User($this->drv);
From f58005640a76c801a59b74f3fda6c70b14fc4660 Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Mon, 28 Dec 2020 08:12:30 -0500
Subject: [PATCH 090/366] Prototype user modification
---
lib/REST/Miniflux/V1.php | 126 +++++++++++++++++++++++----
locale/en.php | 6 ++
tests/cases/REST/Miniflux/TestV1.php | 15 ++--
3 files changed, 122 insertions(+), 25 deletions(-)
diff --git a/lib/REST/Miniflux/V1.php b/lib/REST/Miniflux/V1.php
index bf3da2ea..4822adfa 100644
--- a/lib/REST/Miniflux/V1.php
+++ b/lib/REST/Miniflux/V1.php
@@ -16,7 +16,8 @@ use JKingWeb\Arsse\Misc\HTTP;
use JKingWeb\Arsse\Misc\Date;
use JKingWeb\Arsse\Misc\ValueInfo as V;
use JKingWeb\Arsse\REST\Exception;
-use JKingWeb\Arsse\User\ExceptionConflict as UserException;
+use JKingWeb\Arsse\User\ExceptionConflict;
+use JKingWeb\Arsse\User\Exception as UserException;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\ResponseInterface;
use Laminas\Diactoros\Response\EmptyResponse;
@@ -29,12 +30,26 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
protected const ACCEPTED_TYPES_JSON = ["application/json"];
protected const TOKEN_LENGTH = 32;
protected const VALID_JSON = [
+ // user properties which map directly to Arsse user metadata are listed separately
'url' => "string",
'username' => "string",
'password' => "string",
'user_agent' => "string",
'title' => "string",
];
+ protected const USER_META_MAP = [
+ // Miniflux ID // Arsse ID Default value Extra
+ 'is_admin' => ["admin", false, false],
+ 'theme' => ["theme", "light_serif", false],
+ 'language' => ["lang", "en_US", false],
+ 'timezone' => ["tz", "UTC", false],
+ 'entry_sorting_direction' => ["sort_asc", false, false],
+ 'entries_per_page' => ["page_size", 100, false],
+ 'keyboard_shortcuts' => ["shortcuts", true, false],
+ 'show_reading_time' => ["reading_time", true, false],
+ 'entry_swipe' => ["swipe", true, false],
+ 'custom_css' => ["stylesheet", "", true],
+ ];
protected const CALLS = [ // handler method Admin Path Body Query
'/categories' => [
'GET' => ["getCategories", false, false, false, false],
@@ -102,7 +117,7 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
],
'/users/1' => [
'GET' => ["getUserByNum", true, true, false, false],
- 'PUT' => ["updateUserByNum", true, true, true, false],
+ 'PUT' => ["updateUserByNum", false, true, true, false], // requires admin for users other than self
'DELETE' => ["deleteUserByNum", true, true, false, false],
],
'/users/1/mark-all-as-read' => [
@@ -246,7 +261,17 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
if (!isset($body[$k])) {
$body[$k] = null;
} elseif (gettype($body[$k]) !== $t) {
- return new ErrorResponse(["InvalidInputType", 'field' => $k, 'expected' => $t, 'actual' => gettype($body[$k])]);
+ return new ErrorResponse(["InvalidInputType", 'field' => $k, 'expected' => $t, 'actual' => gettype($body[$k])], 422);
+ }
+ }
+ foreach (self::USER_META_MAP as $k => [,$d,]) {
+ $t = gettype($d);
+ if (!isset($body[$k])) {
+ $body[$k] = null;
+ } elseif (gettype($body[$k]) !== $t) {
+ return new ErrorResponse(["InvalidInputType", 'field' => $k, 'expected' => $t, 'actual' => gettype($body[$k])], 422);
+ } elseif ($k === "entry_sorting_direction" && !in_array($body[$k], ["asc", "desc"])) {
+ return new ErrorResponse(["InvalidInputValue", 'field' => $k], 422);
}
}
return $body;
@@ -285,23 +310,23 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
continue;
}
}
- $out[] = [
+ $entry = [
'id' => $info['num'],
'username' => $u,
- 'is_admin' => $info['admin'] ?? false,
- 'theme' => $info['theme'] ?? "light_serif",
- 'language' => $info['lang'] ?? "en_US",
- 'timezone' => $info['tz'] ?? "UTC",
- 'entry_sorting_direction' => ($info['sort_asc'] ?? false) ? "asc" : "desc",
- 'entries_per_page' => $info['page_size'] ?? 100,
- 'keyboard_shortcuts' => $info['shortcuts'] ?? true,
- 'show_reading_time' => $info['reading_time'] ?? true,
'last_login_at' => $now,
- 'entry_swipe' => $info['swipe'] ?? true,
- 'extra' => [
- 'custom_css' => $info['stylesheet'] ?? "",
- ],
];
+ foreach (self::USER_META_MAP as $ext => [$int, $default, $extra]) {
+ if (!$extra) {
+ $entry[$ext] = $info[$int] ?? $default;
+ } else {
+ if (!isset($entry['extra'])) {
+ $entry['extra'] = [];
+ }
+ $entry['extra'][$ext] = $info[$int] ?? $default;
+ }
+ }
+ $entry['entry_sorting_direction'] = ($entry['entry_sorting_direction']) ? "asc" : "desc";
+ $out[] = $entry;
}
return $out;
}
@@ -326,6 +351,7 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
}
protected function getUsers(): ResponseInterface {
+ $tr = Arsse::$user->begin();
return new Response($this->listUsers(Arsse::$user->list(), false));
}
@@ -350,6 +376,70 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
return new Response($this->listUsers([Arsse::$user->id], false)[0] ?? new \stdClass);
}
+ protected function updateUserByNum(array $data, array $path): ResponseInterface {
+ try {
+ if (!$this->isAdmin()) {
+ // this function is restricted to admins unless the affected user and calling user are the same
+ if (Arsse::$db->userLookup((int) $path[1]) !== Arsse::$user->id) {
+ return new ErrorResponse("403", 403);
+ } elseif ($data['is_admin']) {
+ // non-admins should not be able to set themselves as admin
+ return new ErrorResponse("InvalidElevation");
+ }
+ $user = Arsse::$user->id;
+ } else {
+ $user = Arsse::$db->userLookup((int) $path[1]);
+ }
+ } catch (ExceptionConflict $e) {
+ return new ErrorResponse("404", 404);
+ }
+ // map Miniflux properties to internal metadata properties
+ $in = [];
+ foreach (self::USER_META_MAP as $i => [$o,,]) {
+ if (isset($data[$i])) {
+ if ($i === "entry_sorting_direction") {
+ $in[$o] = $data[$i] === "asc";
+ } else {
+ $in[$o] = $data[$i];
+ }
+ }
+ }
+ // make any requested changes
+ try {
+ $tr = Arsse::$user->begin();
+ if (isset($data['username'])) {
+ Arsse::$user->rename($user, $data['username']);
+ $user = $data['username'];
+ }
+ if (isset($data['password'])) {
+ Arsse::$user->passwordSet($user, $data['password']);
+ }
+ if ($in) {
+ Arsse::$user->propertiesSet($user, $in);
+ }
+ // read out the newly-modified user and commit the changes
+ $out = $this->listUsers([$user], true)[0];
+ $tr->commit();
+ } catch (UserException $e) {
+ switch ($e->getCode()) {
+ case 10403:
+ return new ErrorResponse(["DuplicateUser", 'user' => $data['username']], 409);
+ case 20441:
+ return new ErrorResponse(["InvalidTimeone", 'tz' => $data['timezone']], 422);
+ case 10443:
+ return new ErrorResponse("InvalidPageSize", 422);
+ case 10444:
+ return new ErrorResponse(["InvalidUsername", $e->getMessage()], 422);
+ }
+ throw $e; // @codeCoverageIgnore
+ }
+ // add the input password if a password change was requested
+ if (isset($data['password'])) {
+ $out['password'] = $data['password'];
+ }
+ return new Response($out);
+ }
+
protected function getCategories(): ResponseInterface {
$out = [];
$meta = Arsse::$user->propertiesGet(Arsse::$user->id, false);
@@ -374,7 +464,7 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
}
}
$meta = Arsse::$user->propertiesGet(Arsse::$user->id, false);
- return new Response(['id' => $id + 1, 'title' => $data['title'], 'user_id' => $meta['num']]);
+ return new Response(['id' => $id + 1, 'title' => $data['title'], 'user_id' => $meta['num']], 201);
}
protected function updateCategory(array $path, array $data): ResponseInterface {
@@ -449,7 +539,7 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
public static function tokenList(string $user): array {
if (!Arsse::$db->userExists($user)) {
- throw new UserException("doesNotExist", ["action" => __FUNCTION__, "user" => $user]);
+ throw new ExceptionConflict("doesNotExist", ["action" => __FUNCTION__, "user" => $user]);
}
$out = [];
foreach (Arsse::$db->tokenList($user, "miniflux.login") as $r) {
diff --git a/locale/en.php b/locale/en.php
index acdaa601..b0cfe82d 100644
--- a/locale/en.php
+++ b/locale/en.php
@@ -13,12 +13,18 @@ return [
'API.Miniflux.Error.404' => 'Resource Not Found',
'API.Miniflux.Error.InvalidBodyJSON' => 'Invalid JSON payload: {0}',
'API.Miniflux.Error.InvalidInputType' => 'Input key "{field}" of type {actual} was expected as {expected}',
+ 'API.Miniflux.Error.InvalidInputValue' => 'Supplied value is not valid for input key "{field}"',
'API.Miniflux.Error.Fetch404' => 'Resource not found (404), this feed doesn\'t exists anymore, check the feed URL',
'API.Miniflux.Error.Fetch401' => 'You are not authorized to access this resource (invalid username/password)',
'API.Miniflux.Error.Fetch403' => 'Unable to fetch this resource (Status Code = 403)',
'API.Miniflux.Error.FetchOther' => 'Unable to fetch this resource',
'API.Miniflux.Error.DuplicateCategory' => 'Category "{title}" already exists',
'API.Miniflux.Error.InvalidCategory' => 'Invalid category title "{title}"',
+ 'API.Miniflux.Error.InvalidElevation' => 'Only administrators can change permissions of standard users',
+ 'API.Miniflux.Error.DuplicateUser' => 'The user name "{user}" already exists',
+ 'API.Miniflux.Error.InvalidUser' => '{0}',
+ 'API.Miniflux.Error.InvalidTimezone' => 'Specified time zone "{tz}" is invalid',
+ 'API.Miniflux.Error.InvalidPageSize' => 'Page size must be greater than zero',
'API.TTRSS.Category.Uncategorized' => 'Uncategorized',
'API.TTRSS.Category.Special' => 'Special',
diff --git a/tests/cases/REST/Miniflux/TestV1.php b/tests/cases/REST/Miniflux/TestV1.php
index 918a3da6..0b9f68d3 100644
--- a/tests/cases/REST/Miniflux/TestV1.php
+++ b/tests/cases/REST/Miniflux/TestV1.php
@@ -16,6 +16,7 @@ use JKingWeb\Arsse\Misc\Date;
use JKingWeb\Arsse\REST\Miniflux\V1;
use JKingWeb\Arsse\REST\Miniflux\ErrorResponse;
use JKingWeb\Arsse\User\ExceptionConflict;
+use JKingWeb\Arsse\User\Exception;
use Psr\Http\Message\ResponseInterface;
use Laminas\Diactoros\Response\JsonResponse as Response;
use Laminas\Diactoros\Response\EmptyResponse;
@@ -32,6 +33,7 @@ class TestV1 extends \JKingWeb\Arsse\Test\AbstractTest {
[
'id' => 1,
'username' => "john.doe@example.com",
+ 'last_login_at' => self::NOW,
'is_admin' => true,
'theme' => "custom",
'language' => "fr_CA",
@@ -40,7 +42,6 @@ class TestV1 extends \JKingWeb\Arsse\Test\AbstractTest {
'entries_per_page' => 200,
'keyboard_shortcuts' => false,
'show_reading_time' => false,
- 'last_login_at' => self::NOW,
'entry_swipe' => false,
'extra' => [
'custom_css' => "p {}",
@@ -49,6 +50,7 @@ class TestV1 extends \JKingWeb\Arsse\Test\AbstractTest {
[
'id' => 2,
'username' => "jane.doe@example.com",
+ 'last_login_at' => self::NOW,
'is_admin' => false,
'theme' => "light_serif",
'language' => "en_US",
@@ -57,7 +59,6 @@ class TestV1 extends \JKingWeb\Arsse\Test\AbstractTest {
'entries_per_page' => 100,
'keyboard_shortcuts' => true,
'show_reading_time' => true,
- 'last_login_at' => self::NOW,
'entry_swipe' => true,
'extra' => [
'custom_css' => "",
@@ -166,7 +167,7 @@ class TestV1 extends \JKingWeb\Arsse\Test\AbstractTest {
}
public function testRejectBadlyTypedData(): void {
- $exp = new ErrorResponse(["InvalidInputType", 'field' => "url", 'expected' => "string", 'actual' => "integer"], 400);
+ $exp = new ErrorResponse(["InvalidInputType", 'field' => "url", 'expected' => "string", 'actual' => "integer"], 422);
$this->assertMessage($exp, $this->req("POST", "/discover", ['url' => 2112]));
}
@@ -277,11 +278,11 @@ class TestV1 extends \JKingWeb\Arsse\Test\AbstractTest {
public function provideCategoryAdditions(): iterable {
return [
- ["New", new Response(['id' => 2112, 'title' => "New", 'user_id' => 42])],
+ ["New", new Response(['id' => 2112, 'title' => "New", 'user_id' => 42], 201)],
["Duplicate", new ErrorResponse(["DuplicateCategory", 'title' => "Duplicate"], 500)],
["", new ErrorResponse(["InvalidCategory", 'title' => ""], 500)],
[" ", new ErrorResponse(["InvalidCategory", 'title' => " "], 500)],
- [false, new ErrorResponse(["InvalidInputType", 'field' => "title", 'actual' => "boolean", 'expected' => "string"], 400)],
+ [false, new ErrorResponse(["InvalidInputType", 'field' => "title", 'actual' => "boolean", 'expected' => "string"],422)],
];
}
@@ -307,12 +308,12 @@ class TestV1 extends \JKingWeb\Arsse\Test\AbstractTest {
[2, "Duplicate", "constraintViolation", new ErrorResponse(["DuplicateCategory", 'title' => "Duplicate"], 500)],
[2, "", "missing", new ErrorResponse(["InvalidCategory", 'title' => ""], 500)],
[2, " ", "whitespace", new ErrorResponse(["InvalidCategory", 'title' => " "], 500)],
- [2, false, "subjectMissing", new ErrorResponse(["InvalidInputType", 'field' => "title", 'actual' => "boolean", 'expected' => "string"], 400)],
+ [2, false, "subjectMissing", new ErrorResponse(["InvalidInputType", 'field' => "title", 'actual' => "boolean", 'expected' => "string"],422)],
[1, "New", true, new Response(['id' => 1, 'title' => "New", 'user_id' => 42])],
[1, "Duplicate", "constraintViolation", new Response(['id' => 1, 'title' => "Duplicate", 'user_id' => 42])], // This is allowed because the name of the root folder is only a duplicate in circumstances where it is used
[1, "", "missing", new ErrorResponse(["InvalidCategory", 'title' => ""], 500)],
[1, " ", "whitespace", new ErrorResponse(["InvalidCategory", 'title' => " "], 500)],
- [1, false, false, new ErrorResponse(["InvalidInputType", 'field' => "title", 'actual' => "boolean", 'expected' => "string"], 400)],
+ [1, false, false, new ErrorResponse(["InvalidInputType", 'field' => "title", 'actual' => "boolean", 'expected' => "string"], 422)],
];
}
From 67f577d573423ba3fab0070b80cbd86ca763889d Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Mon, 28 Dec 2020 08:43:54 -0500
Subject: [PATCH 091/366] Bump emulated Miniflux version
---
lib/REST/Miniflux/V1.php | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/lib/REST/Miniflux/V1.php b/lib/REST/Miniflux/V1.php
index 4822adfa..a532304a 100644
--- a/lib/REST/Miniflux/V1.php
+++ b/lib/REST/Miniflux/V1.php
@@ -24,7 +24,7 @@ use Laminas\Diactoros\Response\EmptyResponse;
use Laminas\Diactoros\Response\JsonResponse as Response;
class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
- public const VERSION = "2.0.25";
+ public const VERSION = "2.0.26";
protected const ACCEPTED_TYPES_OPML = ["application/xml", "text/xml", "text/x-opml"];
protected const ACCEPTED_TYPES_JSON = ["application/json"];
From cc648e1c3a7c5ce8fb38907a0ae649d99a1817cf Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Mon, 28 Dec 2020 11:42:36 -0500
Subject: [PATCH 092/366] Update tooling
---
composer.lock | 154 +---------
vendor-bin/csfixer/composer.lock | 501 ++++---------------------------
vendor-bin/daux/composer.lock | 437 ++++-----------------------
vendor-bin/phpunit/composer.lock | 485 +++++++++---------------------
vendor-bin/robo/composer.lock | 411 ++++---------------------
5 files changed, 322 insertions(+), 1666 deletions(-)
diff --git a/composer.lock b/composer.lock
index f69d4dbb..b3e19e88 100644
--- a/composer.lock
+++ b/composer.lock
@@ -50,10 +50,6 @@
"cli",
"docs"
],
- "support": {
- "issues": "https://github.com/docopt/docopt.php/issues",
- "source": "https://github.com/docopt/docopt.php/tree/1.0.4"
- },
"time": "2019-12-03T02:48:46+00:00"
},
{
@@ -121,10 +117,6 @@
"rest",
"web service"
],
- "support": {
- "issues": "https://github.com/guzzle/guzzle/issues",
- "source": "https://github.com/guzzle/guzzle/tree/6.5"
- },
"time": "2020-06-16T21:01:06+00:00"
},
{
@@ -176,10 +168,6 @@
"keywords": [
"promise"
],
- "support": {
- "issues": "https://github.com/guzzle/promises/issues",
- "source": "https://github.com/guzzle/promises/tree/1.4.0"
- },
"time": "2020-09-30T07:37:28+00:00"
},
{
@@ -251,10 +239,6 @@
"uri",
"url"
],
- "support": {
- "issues": "https://github.com/guzzle/psr7/issues",
- "source": "https://github.com/guzzle/psr7/tree/1.7.0"
- },
"time": "2020-09-30T07:37:11+00:00"
},
{
@@ -295,10 +279,6 @@
}
],
"description": "Password generator for generating policy-compliant passwords.",
- "support": {
- "issues": "https://github.com/hosteurope/password-generator/issues",
- "source": "https://github.com/hosteurope/password-generator/tree/master"
- },
"time": "2016-12-08T09:32:12+00:00"
},
{
@@ -344,10 +324,6 @@
"keywords": [
"uuid"
],
- "support": {
- "issues": "https://github.com/JKingweb/DrUUID/issues",
- "source": "https://github.com/JKingweb/DrUUID/tree/3.0.0"
- },
"time": "2017-02-09T14:17:01+00:00"
},
{
@@ -423,10 +399,6 @@
"rfc7234",
"validation"
],
- "support": {
- "issues": "https://github.com/Kevinrob/guzzle-cache-middleware/issues",
- "source": "https://github.com/Kevinrob/guzzle-cache-middleware/tree/master"
- },
"time": "2017-08-17T12:23:43+00:00"
},
{
@@ -512,20 +484,6 @@
"psr-17",
"psr-7"
],
- "support": {
- "chat": "https://laminas.dev/chat",
- "docs": "https://docs.laminas.dev/laminas-diactoros/",
- "forum": "https://discourse.laminas.dev",
- "issues": "https://github.com/laminas/laminas-diactoros/issues",
- "rss": "https://github.com/laminas/laminas-diactoros/releases.atom",
- "source": "https://github.com/laminas/laminas-diactoros"
- },
- "funding": [
- {
- "url": "https://funding.communitybridge.org/projects/laminas-project",
- "type": "community_bridge"
- }
- ],
"time": "2020-09-03T14:29:41+00:00"
},
{
@@ -585,20 +543,6 @@
"psr-15",
"psr-7"
],
- "support": {
- "chat": "https://laminas.dev/chat",
- "docs": "https://docs.laminas.dev/laminas-httphandlerrunner/",
- "forum": "https://discourse.laminas.dev",
- "issues": "https://github.com/laminas/laminas-httphandlerrunner/issues",
- "rss": "https://github.com/laminas/laminas-httphandlerrunner/releases.atom",
- "source": "https://github.com/laminas/laminas-httphandlerrunner"
- },
- "funding": [
- {
- "url": "https://funding.communitybridge.org/projects/laminas-project",
- "type": "community_bridge"
- }
- ],
"time": "2020-06-03T15:52:17+00:00"
},
{
@@ -649,14 +593,6 @@
"security",
"xml"
],
- "support": {
- "chat": "https://laminas.dev/chat",
- "docs": "https://docs.laminas.dev/laminas-xml/",
- "forum": "https://discourse.laminas.dev",
- "issues": "https://github.com/laminas/laminas-xml/issues",
- "rss": "https://github.com/laminas/laminas-xml/releases.atom",
- "source": "https://github.com/laminas/laminas-xml"
- },
"time": "2019-12-31T18:05:42+00:00"
},
{
@@ -705,18 +641,6 @@
"laminas",
"zf"
],
- "support": {
- "forum": "https://discourse.laminas.dev/",
- "issues": "https://github.com/laminas/laminas-zendframework-bridge/issues",
- "rss": "https://github.com/laminas/laminas-zendframework-bridge/releases.atom",
- "source": "https://github.com/laminas/laminas-zendframework-bridge"
- },
- "funding": [
- {
- "url": "https://funding.communitybridge.org/projects/laminas-project",
- "type": "community_bridge"
- }
- ],
"time": "2020-09-14T14:23:00+00:00"
},
{
@@ -779,9 +703,6 @@
],
"description": "RSS/Atom parsing library",
"homepage": "https://github.com/nicolus/picoFeed",
- "support": {
- "source": "https://github.com/nicolus/picoFeed/tree/0.1.43"
- },
"time": "2020-09-15T07:28:23+00:00"
},
{
@@ -834,9 +755,6 @@
"request",
"response"
],
- "support": {
- "source": "https://github.com/php-fig/http-factory/tree/master"
- },
"time": "2019-04-30T12:38:16+00:00"
},
{
@@ -887,9 +805,6 @@
"request",
"response"
],
- "support": {
- "source": "https://github.com/php-fig/http-message/tree/master"
- },
"time": "2016-08-06T14:39:51+00:00"
},
{
@@ -943,10 +858,6 @@
"response",
"server"
],
- "support": {
- "issues": "https://github.com/php-fig/http-server-handler/issues",
- "source": "https://github.com/php-fig/http-server-handler/tree/master"
- },
"time": "2018-10-30T16:46:14+00:00"
},
{
@@ -994,9 +905,6 @@
"psr",
"psr-3"
],
- "support": {
- "source": "https://github.com/php-fig/log/tree/1.1.3"
- },
"time": "2020-03-23T09:12:05+00:00"
},
{
@@ -1037,10 +945,6 @@
}
],
"description": "A polyfill for getallheaders.",
- "support": {
- "issues": "https://github.com/ralouphie/getallheaders/issues",
- "source": "https://github.com/ralouphie/getallheaders/tree/develop"
- },
"time": "2019-03-08T08:55:37+00:00"
},
{
@@ -1111,23 +1015,6 @@
"portable",
"shim"
],
- "support": {
- "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.20.0"
- },
- "funding": [
- {
- "url": "https://symfony.com/sponsor",
- "type": "custom"
- },
- {
- "url": "https://github.com/fabpot",
- "type": "github"
- },
- {
- "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
- "type": "tidelift"
- }
- ],
"time": "2020-10-23T14:02:19+00:00"
},
{
@@ -1195,23 +1082,6 @@
"portable",
"shim"
],
- "support": {
- "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.20.0"
- },
- "funding": [
- {
- "url": "https://symfony.com/sponsor",
- "type": "custom"
- },
- {
- "url": "https://github.com/fabpot",
- "type": "github"
- },
- {
- "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
- "type": "tidelift"
- }
- ],
"time": "2020-10-23T14:02:19+00:00"
},
{
@@ -1271,23 +1141,6 @@
"portable",
"shim"
],
- "support": {
- "source": "https://github.com/symfony/polyfill-php72/tree/v1.20.0"
- },
- "funding": [
- {
- "url": "https://symfony.com/sponsor",
- "type": "custom"
- },
- {
- "url": "https://github.com/fabpot",
- "type": "github"
- },
- {
- "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
- "type": "tidelift"
- }
- ],
"time": "2020-10-23T14:02:19+00:00"
}
],
@@ -1336,10 +1189,6 @@
"isolation",
"tool"
],
- "support": {
- "issues": "https://github.com/bamarni/composer-bin-plugin/issues",
- "source": "https://github.com/bamarni/composer-bin-plugin/tree/master"
- },
"time": "2020-05-03T08:27:20+00:00"
}
],
@@ -1358,6 +1207,5 @@
"platform-dev": [],
"platform-overrides": {
"php": "7.1.33"
- },
- "plugin-api-version": "2.0.0"
+ }
}
diff --git a/vendor-bin/csfixer/composer.lock b/vendor-bin/csfixer/composer.lock
index f3fbaff0..17bfa2ef 100644
--- a/vendor-bin/csfixer/composer.lock
+++ b/vendor-bin/csfixer/composer.lock
@@ -72,20 +72,6 @@
"issues": "https://github.com/composer/semver/issues",
"source": "https://github.com/composer/semver/tree/3.2.4"
},
- "funding": [
- {
- "url": "https://packagist.com",
- "type": "custom"
- },
- {
- "url": "https://github.com/composer",
- "type": "github"
- },
- {
- "url": "https://tidelift.com/funding/github/packagist/composer/composer",
- "type": "tidelift"
- }
- ],
"time": "2020-11-13T08:59:24+00:00"
},
{
@@ -135,20 +121,6 @@
"issues": "https://github.com/composer/xdebug-handler/issues",
"source": "https://github.com/composer/xdebug-handler/tree/1.4.5"
},
- "funding": [
- {
- "url": "https://packagist.com",
- "type": "custom"
- },
- {
- "url": "https://github.com/composer",
- "type": "github"
- },
- {
- "url": "https://tidelift.com/funding/github/packagist/composer/composer",
- "type": "tidelift"
- }
- ],
"time": "2020-11-13T08:04:11+00:00"
},
{
@@ -286,38 +258,20 @@
"parser",
"php"
],
- "support": {
- "issues": "https://github.com/doctrine/lexer/issues",
- "source": "https://github.com/doctrine/lexer/tree/1.2.1"
- },
- "funding": [
- {
- "url": "https://www.doctrine-project.org/sponsorship.html",
- "type": "custom"
- },
- {
- "url": "https://www.patreon.com/phpdoctrine",
- "type": "patreon"
- },
- {
- "url": "https://tidelift.com/funding/github/packagist/doctrine%2Flexer",
- "type": "tidelift"
- }
- ],
"time": "2020-05-25T17:44:05+00:00"
},
{
"name": "friendsofphp/php-cs-fixer",
- "version": "v2.16.7",
+ "version": "v2.17.3",
"source": {
"type": "git",
"url": "https://github.com/FriendsOfPHP/PHP-CS-Fixer.git",
- "reference": "4e35806a6d7d8510d6842ae932e8832363d22c87"
+ "reference": "bd32f5dd72cdfc7b53f54077f980e144bfa2f595"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/FriendsOfPHP/PHP-CS-Fixer/zipball/4e35806a6d7d8510d6842ae932e8832363d22c87",
- "reference": "4e35806a6d7d8510d6842ae932e8832363d22c87",
+ "url": "https://api.github.com/repos/FriendsOfPHP/PHP-CS-Fixer/zipball/bd32f5dd72cdfc7b53f54077f980e144bfa2f595",
+ "reference": "bd32f5dd72cdfc7b53f54077f980e144bfa2f595",
"shasum": ""
},
"require": {
@@ -326,7 +280,7 @@
"doctrine/annotations": "^1.2",
"ext-json": "*",
"ext-tokenizer": "*",
- "php": "^7.1",
+ "php": "^5.6 || ^7.0 || ^8.0",
"php-cs-fixer/diff": "^1.3",
"symfony/console": "^3.4.43 || ^4.1.6 || ^5.0",
"symfony/event-dispatcher": "^3.0 || ^4.0 || ^5.0",
@@ -343,12 +297,15 @@
"justinrainbow/json-schema": "^5.0",
"keradus/cli-executor": "^1.4",
"mikey179/vfsstream": "^1.6",
- "php-coveralls/php-coveralls": "^2.4.1",
+ "php-coveralls/php-coveralls": "^2.4.2",
"php-cs-fixer/accessible-object": "^1.0",
"php-cs-fixer/phpunit-constraint-isidenticalstring": "^1.2",
"php-cs-fixer/phpunit-constraint-xmlmatchesxsd": "^1.2.1",
- "phpunit/phpunit": "^5.7.27 || ^6.5.14 || ^7.1",
+ "phpspec/prophecy-phpunit": "^1.1 || ^2.0",
+ "phpunit/phpunit": "^5.7.27 || ^6.5.14 || ^7.5.20 || ^8.5.13 || ^9.4.4 <9.5",
+ "phpunitgoodpractices/polyfill": "^1.5",
"phpunitgoodpractices/traits": "^1.9.1",
+ "sanmai/phpunit-legacy-adapter": "^6.4 || ^8.2.1",
"symfony/phpunit-bridge": "^5.1",
"symfony/yaml": "^3.0 || ^4.0 || ^5.0"
},
@@ -395,17 +352,7 @@
}
],
"description": "A tool to automatically fix PHP code style",
- "support": {
- "issues": "https://github.com/FriendsOfPHP/PHP-CS-Fixer/issues",
- "source": "https://github.com/FriendsOfPHP/PHP-CS-Fixer/tree/v2.16.7"
- },
- "funding": [
- {
- "url": "https://github.com/keradus",
- "type": "github"
- }
- ],
- "time": "2020-10-27T22:44:27+00:00"
+ "time": "2020-12-24T11:14:44+00:00"
},
{
"name": "php-cs-fixer/diff",
@@ -456,10 +403,6 @@
"keywords": [
"diff"
],
- "support": {
- "issues": "https://github.com/PHP-CS-Fixer/diff/issues",
- "source": "https://github.com/PHP-CS-Fixer/diff/tree/v1.3.1"
- },
"time": "2020-10-14T08:39:05+00:00"
},
{
@@ -509,10 +452,6 @@
"container-interop",
"psr"
],
- "support": {
- "issues": "https://github.com/php-fig/container/issues",
- "source": "https://github.com/php-fig/container/tree/master"
- },
"time": "2017-02-14T16:28:37+00:00"
},
{
@@ -559,10 +498,6 @@
"psr",
"psr-14"
],
- "support": {
- "issues": "https://github.com/php-fig/event-dispatcher/issues",
- "source": "https://github.com/php-fig/event-dispatcher/tree/1.0.0"
- },
"time": "2019-01-08T18:20:26+00:00"
},
{
@@ -610,23 +545,20 @@
"psr",
"psr-3"
],
- "support": {
- "source": "https://github.com/php-fig/log/tree/1.1.3"
- },
"time": "2020-03-23T09:12:05+00:00"
},
{
"name": "symfony/console",
- "version": "v5.1.8",
+ "version": "v5.2.1",
"source": {
"type": "git",
"url": "https://github.com/symfony/console.git",
- "reference": "e0b2c29c0fa6a69089209bbe8fcff4df2a313d0e"
+ "reference": "47c02526c532fb381374dab26df05e7313978976"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/console/zipball/e0b2c29c0fa6a69089209bbe8fcff4df2a313d0e",
- "reference": "e0b2c29c0fa6a69089209bbe8fcff4df2a313d0e",
+ "url": "https://api.github.com/repos/symfony/console/zipball/47c02526c532fb381374dab26df05e7313978976",
+ "reference": "47c02526c532fb381374dab26df05e7313978976",
"shasum": ""
},
"require": {
@@ -687,24 +619,13 @@
],
"description": "Symfony Console Component",
"homepage": "https://symfony.com",
- "support": {
- "source": "https://github.com/symfony/console/tree/v5.1.8"
- },
- "funding": [
- {
- "url": "https://symfony.com/sponsor",
- "type": "custom"
- },
- {
- "url": "https://github.com/fabpot",
- "type": "github"
- },
- {
- "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
- "type": "tidelift"
- }
+ "keywords": [
+ "cli",
+ "command line",
+ "console",
+ "terminal"
],
- "time": "2020-10-24T12:01:57+00:00"
+ "time": "2020-12-18T08:03:05+00:00"
},
{
"name": "symfony/deprecation-contracts",
@@ -754,37 +675,20 @@
],
"description": "A generic function and convention to trigger deprecation notices",
"homepage": "https://symfony.com",
- "support": {
- "source": "https://github.com/symfony/deprecation-contracts/tree/master"
- },
- "funding": [
- {
- "url": "https://symfony.com/sponsor",
- "type": "custom"
- },
- {
- "url": "https://github.com/fabpot",
- "type": "github"
- },
- {
- "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
- "type": "tidelift"
- }
- ],
"time": "2020-09-07T11:33:47+00:00"
},
{
"name": "symfony/event-dispatcher",
- "version": "v5.1.8",
+ "version": "v5.2.1",
"source": {
"type": "git",
"url": "https://github.com/symfony/event-dispatcher.git",
- "reference": "26f4edae48c913fc183a3da0553fe63bdfbd361a"
+ "reference": "1c93f7a1dff592c252574c79a8635a8a80856042"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/26f4edae48c913fc183a3da0553fe63bdfbd361a",
- "reference": "26f4edae48c913fc183a3da0553fe63bdfbd361a",
+ "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/1c93f7a1dff592c252574c79a8635a8a80856042",
+ "reference": "1c93f7a1dff592c252574c79a8635a8a80856042",
"shasum": ""
},
"require": {
@@ -839,24 +743,7 @@
],
"description": "Symfony EventDispatcher Component",
"homepage": "https://symfony.com",
- "support": {
- "source": "https://github.com/symfony/event-dispatcher/tree/v5.1.8"
- },
- "funding": [
- {
- "url": "https://symfony.com/sponsor",
- "type": "custom"
- },
- {
- "url": "https://github.com/fabpot",
- "type": "github"
- },
- {
- "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
- "type": "tidelift"
- }
- ],
- "time": "2020-10-24T12:01:57+00:00"
+ "time": "2020-12-18T08:03:05+00:00"
},
{
"name": "symfony/event-dispatcher-contracts",
@@ -918,37 +805,20 @@
"interoperability",
"standards"
],
- "support": {
- "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v2.2.0"
- },
- "funding": [
- {
- "url": "https://symfony.com/sponsor",
- "type": "custom"
- },
- {
- "url": "https://github.com/fabpot",
- "type": "github"
- },
- {
- "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
- "type": "tidelift"
- }
- ],
"time": "2020-09-07T11:33:47+00:00"
},
{
"name": "symfony/filesystem",
- "version": "v5.1.8",
+ "version": "v5.2.1",
"source": {
"type": "git",
"url": "https://github.com/symfony/filesystem.git",
- "reference": "df08650ea7aee2d925380069c131a66124d79177"
+ "reference": "fa8f8cab6b65e2d99a118e082935344c5ba8c60d"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/filesystem/zipball/df08650ea7aee2d925380069c131a66124d79177",
- "reference": "df08650ea7aee2d925380069c131a66124d79177",
+ "url": "https://api.github.com/repos/symfony/filesystem/zipball/fa8f8cab6b65e2d99a118e082935344c5ba8c60d",
+ "reference": "fa8f8cab6b65e2d99a118e082935344c5ba8c60d",
"shasum": ""
},
"require": {
@@ -980,37 +850,20 @@
],
"description": "Symfony Filesystem Component",
"homepage": "https://symfony.com",
- "support": {
- "source": "https://github.com/symfony/filesystem/tree/v5.1.8"
- },
- "funding": [
- {
- "url": "https://symfony.com/sponsor",
- "type": "custom"
- },
- {
- "url": "https://github.com/fabpot",
- "type": "github"
- },
- {
- "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
- "type": "tidelift"
- }
- ],
- "time": "2020-10-24T12:01:57+00:00"
+ "time": "2020-11-30T17:05:38+00:00"
},
{
"name": "symfony/finder",
- "version": "v5.1.8",
+ "version": "v5.2.1",
"source": {
"type": "git",
"url": "https://github.com/symfony/finder.git",
- "reference": "e70eb5a69c2ff61ea135a13d2266e8914a67b3a0"
+ "reference": "0b9231a5922fd7287ba5b411893c0ecd2733e5ba"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/finder/zipball/e70eb5a69c2ff61ea135a13d2266e8914a67b3a0",
- "reference": "e70eb5a69c2ff61ea135a13d2266e8914a67b3a0",
+ "url": "https://api.github.com/repos/symfony/finder/zipball/0b9231a5922fd7287ba5b411893c0ecd2733e5ba",
+ "reference": "0b9231a5922fd7287ba5b411893c0ecd2733e5ba",
"shasum": ""
},
"require": {
@@ -1041,42 +894,26 @@
],
"description": "Symfony Finder Component",
"homepage": "https://symfony.com",
- "support": {
- "source": "https://github.com/symfony/finder/tree/v5.1.8"
- },
- "funding": [
- {
- "url": "https://symfony.com/sponsor",
- "type": "custom"
- },
- {
- "url": "https://github.com/fabpot",
- "type": "github"
- },
- {
- "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
- "type": "tidelift"
- }
- ],
- "time": "2020-10-24T12:01:57+00:00"
+ "time": "2020-12-08T17:02:38+00:00"
},
{
"name": "symfony/options-resolver",
- "version": "v5.1.8",
+ "version": "v5.2.1",
"source": {
"type": "git",
"url": "https://github.com/symfony/options-resolver.git",
- "reference": "c6a02905e4ffc7a1498e8ee019db2b477cd1cc02"
+ "reference": "87a2a4a766244e796dd9cb9d6f58c123358cd986"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/options-resolver/zipball/c6a02905e4ffc7a1498e8ee019db2b477cd1cc02",
- "reference": "c6a02905e4ffc7a1498e8ee019db2b477cd1cc02",
+ "url": "https://api.github.com/repos/symfony/options-resolver/zipball/87a2a4a766244e796dd9cb9d6f58c123358cd986",
+ "reference": "87a2a4a766244e796dd9cb9d6f58c123358cd986",
"shasum": ""
},
"require": {
"php": ">=7.2.5",
"symfony/deprecation-contracts": "^2.1",
+ "symfony/polyfill-php73": "~1.0",
"symfony/polyfill-php80": "^1.15"
},
"type": "library",
@@ -1109,24 +946,7 @@
"configuration",
"options"
],
- "support": {
- "source": "https://github.com/symfony/options-resolver/tree/v5.1.8"
- },
- "funding": [
- {
- "url": "https://symfony.com/sponsor",
- "type": "custom"
- },
- {
- "url": "https://github.com/fabpot",
- "type": "github"
- },
- {
- "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
- "type": "tidelift"
- }
- ],
- "time": "2020-10-24T12:01:57+00:00"
+ "time": "2020-10-24T12:08:07+00:00"
},
{
"name": "symfony/polyfill-ctype",
@@ -1188,23 +1008,6 @@
"polyfill",
"portable"
],
- "support": {
- "source": "https://github.com/symfony/polyfill-ctype/tree/v1.20.0"
- },
- "funding": [
- {
- "url": "https://symfony.com/sponsor",
- "type": "custom"
- },
- {
- "url": "https://github.com/fabpot",
- "type": "github"
- },
- {
- "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
- "type": "tidelift"
- }
- ],
"time": "2020-10-23T14:02:19+00:00"
},
{
@@ -1269,23 +1072,6 @@
"portable",
"shim"
],
- "support": {
- "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.20.0"
- },
- "funding": [
- {
- "url": "https://symfony.com/sponsor",
- "type": "custom"
- },
- {
- "url": "https://github.com/fabpot",
- "type": "github"
- },
- {
- "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
- "type": "tidelift"
- }
- ],
"time": "2020-10-23T14:02:19+00:00"
},
{
@@ -1353,23 +1139,6 @@
"portable",
"shim"
],
- "support": {
- "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.20.0"
- },
- "funding": [
- {
- "url": "https://symfony.com/sponsor",
- "type": "custom"
- },
- {
- "url": "https://github.com/fabpot",
- "type": "github"
- },
- {
- "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
- "type": "tidelift"
- }
- ],
"time": "2020-10-23T14:02:19+00:00"
},
{
@@ -1433,23 +1202,6 @@
"portable",
"shim"
],
- "support": {
- "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.20.0"
- },
- "funding": [
- {
- "url": "https://symfony.com/sponsor",
- "type": "custom"
- },
- {
- "url": "https://github.com/fabpot",
- "type": "github"
- },
- {
- "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
- "type": "tidelift"
- }
- ],
"time": "2020-10-23T14:02:19+00:00"
},
{
@@ -1501,23 +1253,6 @@
"portable",
"shim"
],
- "support": {
- "source": "https://github.com/symfony/polyfill-php70/tree/v1.20.0"
- },
- "funding": [
- {
- "url": "https://symfony.com/sponsor",
- "type": "custom"
- },
- {
- "url": "https://github.com/fabpot",
- "type": "github"
- },
- {
- "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
- "type": "tidelift"
- }
- ],
"time": "2020-10-23T14:02:19+00:00"
},
{
@@ -1577,23 +1312,6 @@
"portable",
"shim"
],
- "support": {
- "source": "https://github.com/symfony/polyfill-php72/tree/v1.20.0"
- },
- "funding": [
- {
- "url": "https://symfony.com/sponsor",
- "type": "custom"
- },
- {
- "url": "https://github.com/fabpot",
- "type": "github"
- },
- {
- "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
- "type": "tidelift"
- }
- ],
"time": "2020-10-23T14:02:19+00:00"
},
{
@@ -1656,23 +1374,6 @@
"portable",
"shim"
],
- "support": {
- "source": "https://github.com/symfony/polyfill-php73/tree/v1.20.0"
- },
- "funding": [
- {
- "url": "https://symfony.com/sponsor",
- "type": "custom"
- },
- {
- "url": "https://github.com/fabpot",
- "type": "github"
- },
- {
- "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
- "type": "tidelift"
- }
- ],
"time": "2020-10-23T14:02:19+00:00"
},
{
@@ -1739,37 +1440,20 @@
"portable",
"shim"
],
- "support": {
- "source": "https://github.com/symfony/polyfill-php80/tree/v1.20.0"
- },
- "funding": [
- {
- "url": "https://symfony.com/sponsor",
- "type": "custom"
- },
- {
- "url": "https://github.com/fabpot",
- "type": "github"
- },
- {
- "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
- "type": "tidelift"
- }
- ],
"time": "2020-10-23T14:02:19+00:00"
},
{
"name": "symfony/process",
- "version": "v5.1.8",
+ "version": "v5.2.1",
"source": {
"type": "git",
"url": "https://github.com/symfony/process.git",
- "reference": "f00872c3f6804150d6a0f73b4151daab96248101"
+ "reference": "bd8815b8b6705298beaa384f04fabd459c10bedd"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/process/zipball/f00872c3f6804150d6a0f73b4151daab96248101",
- "reference": "f00872c3f6804150d6a0f73b4151daab96248101",
+ "url": "https://api.github.com/repos/symfony/process/zipball/bd8815b8b6705298beaa384f04fabd459c10bedd",
+ "reference": "bd8815b8b6705298beaa384f04fabd459c10bedd",
"shasum": ""
},
"require": {
@@ -1801,24 +1485,7 @@
],
"description": "Symfony Process Component",
"homepage": "https://symfony.com",
- "support": {
- "source": "https://github.com/symfony/process/tree/v5.1.8"
- },
- "funding": [
- {
- "url": "https://symfony.com/sponsor",
- "type": "custom"
- },
- {
- "url": "https://github.com/fabpot",
- "type": "github"
- },
- {
- "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
- "type": "tidelift"
- }
- ],
- "time": "2020-10-24T12:01:57+00:00"
+ "time": "2020-12-08T17:03:37+00:00"
},
{
"name": "symfony/service-contracts",
@@ -1880,37 +1547,20 @@
"interoperability",
"standards"
],
- "support": {
- "source": "https://github.com/symfony/service-contracts/tree/master"
- },
- "funding": [
- {
- "url": "https://symfony.com/sponsor",
- "type": "custom"
- },
- {
- "url": "https://github.com/fabpot",
- "type": "github"
- },
- {
- "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
- "type": "tidelift"
- }
- ],
"time": "2020-09-07T11:33:47+00:00"
},
{
"name": "symfony/stopwatch",
- "version": "v5.1.8",
+ "version": "v5.2.1",
"source": {
"type": "git",
"url": "https://github.com/symfony/stopwatch.git",
- "reference": "3d9f57c89011f0266e6b1d469e5c0110513859d5"
+ "reference": "2b105c0354f39a63038a1d8bf776ee92852813af"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/stopwatch/zipball/3d9f57c89011f0266e6b1d469e5c0110513859d5",
- "reference": "3d9f57c89011f0266e6b1d469e5c0110513859d5",
+ "url": "https://api.github.com/repos/symfony/stopwatch/zipball/2b105c0354f39a63038a1d8bf776ee92852813af",
+ "reference": "2b105c0354f39a63038a1d8bf776ee92852813af",
"shasum": ""
},
"require": {
@@ -1942,37 +1592,20 @@
],
"description": "Symfony Stopwatch Component",
"homepage": "https://symfony.com",
- "support": {
- "source": "https://github.com/symfony/stopwatch/tree/v5.1.8"
- },
- "funding": [
- {
- "url": "https://symfony.com/sponsor",
- "type": "custom"
- },
- {
- "url": "https://github.com/fabpot",
- "type": "github"
- },
- {
- "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
- "type": "tidelift"
- }
- ],
- "time": "2020-10-24T12:01:57+00:00"
+ "time": "2020-11-01T16:14:45+00:00"
},
{
"name": "symfony/string",
- "version": "v5.1.8",
+ "version": "v5.2.1",
"source": {
"type": "git",
"url": "https://github.com/symfony/string.git",
- "reference": "a97573e960303db71be0dd8fda9be3bca5e0feea"
+ "reference": "5bd67751d2e3f7d6f770c9154b8fbcb2aa05f7ed"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/string/zipball/a97573e960303db71be0dd8fda9be3bca5e0feea",
- "reference": "a97573e960303db71be0dd8fda9be3bca5e0feea",
+ "url": "https://api.github.com/repos/symfony/string/zipball/5bd67751d2e3f7d6f770c9154b8fbcb2aa05f7ed",
+ "reference": "5bd67751d2e3f7d6f770c9154b8fbcb2aa05f7ed",
"shasum": ""
},
"require": {
@@ -2025,24 +1658,7 @@
"utf-8",
"utf8"
],
- "support": {
- "source": "https://github.com/symfony/string/tree/v5.1.8"
- },
- "funding": [
- {
- "url": "https://symfony.com/sponsor",
- "type": "custom"
- },
- {
- "url": "https://github.com/fabpot",
- "type": "github"
- },
- {
- "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
- "type": "tidelift"
- }
- ],
- "time": "2020-10-24T12:01:57+00:00"
+ "time": "2020-12-05T07:33:16+00:00"
}
],
"aliases": [],
@@ -2051,6 +1667,5 @@
"prefer-stable": false,
"prefer-lowest": false,
"platform": [],
- "platform-dev": [],
- "plugin-api-version": "2.0.0"
+ "platform-dev": []
}
diff --git a/vendor-bin/daux/composer.lock b/vendor-bin/daux/composer.lock
index 8e08bd22..35bc4c60 100644
--- a/vendor-bin/daux/composer.lock
+++ b/vendor-bin/daux/composer.lock
@@ -76,10 +76,6 @@
"markdown",
"md"
],
- "support": {
- "issues": "https://github.com/dauxio/daux.io/issues",
- "source": "https://github.com/dauxio/daux.io/tree/master"
- },
"time": "2019-09-23T20:10:07+00:00"
},
{
@@ -147,10 +143,6 @@
"rest",
"web service"
],
- "support": {
- "issues": "https://github.com/guzzle/guzzle/issues",
- "source": "https://github.com/guzzle/guzzle/tree/6.5"
- },
"time": "2020-06-16T21:01:06+00:00"
},
{
@@ -202,10 +194,6 @@
"keywords": [
"promise"
],
- "support": {
- "issues": "https://github.com/guzzle/promises/issues",
- "source": "https://github.com/guzzle/promises/tree/1.4.0"
- },
"time": "2020-09-30T07:37:28+00:00"
},
{
@@ -277,10 +265,6 @@
"uri",
"url"
],
- "support": {
- "issues": "https://github.com/guzzle/psr7/issues",
- "source": "https://github.com/guzzle/psr7/tree/1.7.0"
- },
"time": "2020-09-30T07:37:11+00:00"
},
{
@@ -350,35 +334,29 @@
"markdown",
"parser"
],
- "support": {
- "docs": "https://commonmark.thephpleague.com/",
- "issues": "https://github.com/thephpleague/commonmark/issues",
- "rss": "https://github.com/thephpleague/commonmark/releases.atom",
- "source": "https://github.com/thephpleague/commonmark"
- },
"time": "2019-03-28T13:52:31+00:00"
},
{
"name": "league/plates",
- "version": "3.3.0",
+ "version": "v3.4.0",
"source": {
"type": "git",
"url": "https://github.com/thephpleague/plates.git",
- "reference": "b1684b6f127714497a0ef927ce42c0b44b45a8af"
+ "reference": "6d3ee31199b536a4e003b34a356ca20f6f75496a"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/thephpleague/plates/zipball/b1684b6f127714497a0ef927ce42c0b44b45a8af",
- "reference": "b1684b6f127714497a0ef927ce42c0b44b45a8af",
+ "url": "https://api.github.com/repos/thephpleague/plates/zipball/6d3ee31199b536a4e003b34a356ca20f6f75496a",
+ "reference": "6d3ee31199b536a4e003b34a356ca20f6f75496a",
"shasum": ""
},
"require": {
- "php": "^5.3 | ^7.0"
+ "php": "^7.0|^8.0"
},
"require-dev": {
- "mikey179/vfsstream": "^1.4",
- "phpunit/phpunit": "~4.0",
- "squizlabs/php_codesniffer": "~1.5"
+ "mikey179/vfsstream": "^1.6",
+ "phpunit/phpunit": "^9.5",
+ "squizlabs/php_codesniffer": "^3.5"
},
"type": "library",
"extra": {
@@ -400,10 +378,15 @@
"name": "Jonathan Reinink",
"email": "jonathan@reinink.ca",
"role": "Developer"
+ },
+ {
+ "name": "RJ Garcia",
+ "email": "ragboyjr@icloud.com",
+ "role": "Developer"
}
],
"description": "Plates, the native PHP template system that's fast, easy to use and easy to extend.",
- "homepage": "http://platesphp.com",
+ "homepage": "https://platesphp.com",
"keywords": [
"league",
"package",
@@ -411,11 +394,7 @@
"templating",
"views"
],
- "support": {
- "issues": "https://github.com/thephpleague/plates/issues",
- "source": "https://github.com/thephpleague/plates/tree/master"
- },
- "time": "2016-12-28T00:14:17+00:00"
+ "time": "2020-12-25T05:00:37+00:00"
},
{
"name": "myclabs/deep-copy",
@@ -467,12 +446,6 @@
"issues": "https://github.com/myclabs/DeepCopy/issues",
"source": "https://github.com/myclabs/DeepCopy/tree/1.10.2"
},
- "funding": [
- {
- "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy",
- "type": "tidelift"
- }
- ],
"time": "2020-11-13T09:40:50+00:00"
},
{
@@ -522,10 +495,6 @@
"container-interop",
"psr"
],
- "support": {
- "issues": "https://github.com/php-fig/container/issues",
- "source": "https://github.com/php-fig/container/tree/master"
- },
"time": "2017-02-14T16:28:37+00:00"
},
{
@@ -576,9 +545,6 @@
"request",
"response"
],
- "support": {
- "source": "https://github.com/php-fig/http-message/tree/master"
- },
"time": "2016-08-06T14:39:51+00:00"
},
{
@@ -619,24 +585,20 @@
}
],
"description": "A polyfill for getallheaders.",
- "support": {
- "issues": "https://github.com/ralouphie/getallheaders/issues",
- "source": "https://github.com/ralouphie/getallheaders/tree/develop"
- },
"time": "2019-03-08T08:55:37+00:00"
},
{
"name": "scrivo/highlight.php",
- "version": "v9.18.1.5",
+ "version": "v9.18.1.6",
"source": {
"type": "git",
"url": "https://github.com/scrivo/highlight.php.git",
- "reference": "fa75a865928a4a5d49e5e77faca6bd2f2410baaf"
+ "reference": "44a3d4136edb5ad8551590bf90f437db80b2d466"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/scrivo/highlight.php/zipball/fa75a865928a4a5d49e5e77faca6bd2f2410baaf",
- "reference": "fa75a865928a4a5d49e5e77faca6bd2f2410baaf",
+ "url": "https://api.github.com/repos/scrivo/highlight.php/zipball/44a3d4136edb5ad8551590bf90f437db80b2d466",
+ "reference": "44a3d4136edb5ad8551590bf90f437db80b2d466",
"shasum": ""
},
"require": {
@@ -650,9 +612,6 @@
"symfony/finder": "^2.8|^3.4",
"symfony/var-dumper": "^2.8|^3.4"
},
- "suggest": {
- "ext-dom": "Needed to make use of the features in the utilities namespace"
- },
"type": "library",
"autoload": {
"psr-0": {
@@ -692,30 +651,20 @@
"highlight.php",
"syntax"
],
- "support": {
- "issues": "https://github.com/scrivo/highlight.php/issues",
- "source": "https://github.com/scrivo/highlight.php"
- },
- "funding": [
- {
- "url": "https://github.com/allejo",
- "type": "github"
- }
- ],
- "time": "2020-11-22T06:07:40+00:00"
+ "time": "2020-12-22T19:20:29+00:00"
},
{
"name": "symfony/console",
- "version": "v4.4.16",
+ "version": "v4.4.18",
"source": {
"type": "git",
"url": "https://github.com/symfony/console.git",
- "reference": "20f73dd143a5815d475e0838ff867bce1eebd9d5"
+ "reference": "12e071278e396cc3e1c149857337e9e192deca0b"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/console/zipball/20f73dd143a5815d475e0838ff867bce1eebd9d5",
- "reference": "20f73dd143a5815d475e0838ff867bce1eebd9d5",
+ "url": "https://api.github.com/repos/symfony/console/zipball/12e071278e396cc3e1c149857337e9e192deca0b",
+ "reference": "12e071278e396cc3e1c149857337e9e192deca0b",
"shasum": ""
},
"require": {
@@ -774,24 +723,7 @@
],
"description": "Symfony Console Component",
"homepage": "https://symfony.com",
- "support": {
- "source": "https://github.com/symfony/console/tree/v4.4.16"
- },
- "funding": [
- {
- "url": "https://symfony.com/sponsor",
- "type": "custom"
- },
- {
- "url": "https://github.com/fabpot",
- "type": "github"
- },
- {
- "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
- "type": "tidelift"
- }
- ],
- "time": "2020-10-24T11:50:19+00:00"
+ "time": "2020-12-18T07:41:31+00:00"
},
{
"name": "symfony/deprecation-contracts",
@@ -844,40 +776,27 @@
"support": {
"source": "https://github.com/symfony/deprecation-contracts/tree/master"
},
- "funding": [
- {
- "url": "https://symfony.com/sponsor",
- "type": "custom"
- },
- {
- "url": "https://github.com/fabpot",
- "type": "github"
- },
- {
- "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
- "type": "tidelift"
- }
- ],
"time": "2020-09-07T11:33:47+00:00"
},
{
"name": "symfony/http-foundation",
- "version": "v4.4.16",
+ "version": "v4.4.18",
"source": {
"type": "git",
"url": "https://github.com/symfony/http-foundation.git",
- "reference": "827a00811ef699e809a201ceafac0b2b246bf38a"
+ "reference": "5ebda66b51612516bf338d5f87da2f37ff74cf34"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/http-foundation/zipball/827a00811ef699e809a201ceafac0b2b246bf38a",
- "reference": "827a00811ef699e809a201ceafac0b2b246bf38a",
+ "url": "https://api.github.com/repos/symfony/http-foundation/zipball/5ebda66b51612516bf338d5f87da2f37ff74cf34",
+ "reference": "5ebda66b51612516bf338d5f87da2f37ff74cf34",
"shasum": ""
},
"require": {
"php": ">=7.1.3",
"symfony/mime": "^4.3|^5.0",
- "symfony/polyfill-mbstring": "~1.1"
+ "symfony/polyfill-mbstring": "~1.1",
+ "symfony/polyfill-php80": "^1.15"
},
"require-dev": {
"predis/predis": "~1.0",
@@ -908,37 +827,20 @@
],
"description": "Symfony HttpFoundation Component",
"homepage": "https://symfony.com",
- "support": {
- "source": "https://github.com/symfony/http-foundation/tree/v4.4.16"
- },
- "funding": [
- {
- "url": "https://symfony.com/sponsor",
- "type": "custom"
- },
- {
- "url": "https://github.com/fabpot",
- "type": "github"
- },
- {
- "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
- "type": "tidelift"
- }
- ],
- "time": "2020-10-24T11:50:19+00:00"
+ "time": "2020-12-18T07:41:31+00:00"
},
{
"name": "symfony/intl",
- "version": "v5.1.8",
+ "version": "v5.2.1",
"source": {
"type": "git",
"url": "https://github.com/symfony/intl.git",
- "reference": "e353c6c37afa1ff90739b3941f60ff9fa650eec3"
+ "reference": "53927f98c9201fe5db3cfc4d574b1f4039020297"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/intl/zipball/e353c6c37afa1ff90739b3941f60ff9fa650eec3",
- "reference": "e353c6c37afa1ff90739b3941f60ff9fa650eec3",
+ "url": "https://api.github.com/repos/symfony/intl/zipball/53927f98c9201fe5db3cfc4d574b1f4039020297",
+ "reference": "53927f98c9201fe5db3cfc4d574b1f4039020297",
"shasum": ""
},
"require": {
@@ -996,41 +898,25 @@
"l10n",
"localization"
],
- "support": {
- "source": "https://github.com/symfony/intl/tree/v5.1.8"
- },
- "funding": [
- {
- "url": "https://symfony.com/sponsor",
- "type": "custom"
- },
- {
- "url": "https://github.com/fabpot",
- "type": "github"
- },
- {
- "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
- "type": "tidelift"
- }
- ],
- "time": "2020-10-24T12:01:57+00:00"
+ "time": "2020-12-14T10:10:03+00:00"
},
{
"name": "symfony/mime",
- "version": "v5.1.8",
+ "version": "v5.2.1",
"source": {
"type": "git",
"url": "https://github.com/symfony/mime.git",
- "reference": "f5485a92c24d4bcfc2f3fc648744fb398482ff1b"
+ "reference": "de97005aef7426ba008c46ba840fc301df577ada"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/mime/zipball/f5485a92c24d4bcfc2f3fc648744fb398482ff1b",
- "reference": "f5485a92c24d4bcfc2f3fc648744fb398482ff1b",
+ "url": "https://api.github.com/repos/symfony/mime/zipball/de97005aef7426ba008c46ba840fc301df577ada",
+ "reference": "de97005aef7426ba008c46ba840fc301df577ada",
"shasum": ""
},
"require": {
"php": ">=7.2.5",
+ "symfony/deprecation-contracts": "^2.1",
"symfony/polyfill-intl-idn": "^1.10",
"symfony/polyfill-mbstring": "^1.0",
"symfony/polyfill-php80": "^1.15"
@@ -1040,7 +926,11 @@
},
"require-dev": {
"egulias/email-validator": "^2.1.10",
- "symfony/dependency-injection": "^4.4|^5.0"
+ "phpdocumentor/reflection-docblock": "^3.0|^4.0|^5.0",
+ "symfony/dependency-injection": "^4.4|^5.0",
+ "symfony/property-access": "^4.4|^5.1",
+ "symfony/property-info": "^4.4|^5.1",
+ "symfony/serializer": "^5.2"
},
"type": "library",
"autoload": {
@@ -1071,24 +961,7 @@
"mime",
"mime-type"
],
- "support": {
- "source": "https://github.com/symfony/mime/tree/v5.1.8"
- },
- "funding": [
- {
- "url": "https://symfony.com/sponsor",
- "type": "custom"
- },
- {
- "url": "https://github.com/fabpot",
- "type": "github"
- },
- {
- "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
- "type": "tidelift"
- }
- ],
- "time": "2020-10-24T12:01:57+00:00"
+ "time": "2020-12-09T18:54:12+00:00"
},
{
"name": "symfony/polyfill-ctype",
@@ -1150,23 +1023,6 @@
"polyfill",
"portable"
],
- "support": {
- "source": "https://github.com/symfony/polyfill-ctype/tree/v1.20.0"
- },
- "funding": [
- {
- "url": "https://symfony.com/sponsor",
- "type": "custom"
- },
- {
- "url": "https://github.com/fabpot",
- "type": "github"
- },
- {
- "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
- "type": "tidelift"
- }
- ],
"time": "2020-10-23T14:02:19+00:00"
},
{
@@ -1229,23 +1085,6 @@
"portable",
"shim"
],
- "support": {
- "source": "https://github.com/symfony/polyfill-intl-icu/tree/v1.20.0"
- },
- "funding": [
- {
- "url": "https://symfony.com/sponsor",
- "type": "custom"
- },
- {
- "url": "https://github.com/fabpot",
- "type": "github"
- },
- {
- "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
- "type": "tidelift"
- }
- ],
"time": "2020-10-23T14:02:19+00:00"
},
{
@@ -1316,23 +1155,6 @@
"portable",
"shim"
],
- "support": {
- "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.20.0"
- },
- "funding": [
- {
- "url": "https://symfony.com/sponsor",
- "type": "custom"
- },
- {
- "url": "https://github.com/fabpot",
- "type": "github"
- },
- {
- "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
- "type": "tidelift"
- }
- ],
"time": "2020-10-23T14:02:19+00:00"
},
{
@@ -1400,23 +1222,6 @@
"portable",
"shim"
],
- "support": {
- "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.20.0"
- },
- "funding": [
- {
- "url": "https://symfony.com/sponsor",
- "type": "custom"
- },
- {
- "url": "https://github.com/fabpot",
- "type": "github"
- },
- {
- "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
- "type": "tidelift"
- }
- ],
"time": "2020-10-23T14:02:19+00:00"
},
{
@@ -1480,23 +1285,6 @@
"portable",
"shim"
],
- "support": {
- "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.20.0"
- },
- "funding": [
- {
- "url": "https://symfony.com/sponsor",
- "type": "custom"
- },
- {
- "url": "https://github.com/fabpot",
- "type": "github"
- },
- {
- "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
- "type": "tidelift"
- }
- ],
"time": "2020-10-23T14:02:19+00:00"
},
{
@@ -1556,23 +1344,6 @@
"portable",
"shim"
],
- "support": {
- "source": "https://github.com/symfony/polyfill-php72/tree/v1.20.0"
- },
- "funding": [
- {
- "url": "https://symfony.com/sponsor",
- "type": "custom"
- },
- {
- "url": "https://github.com/fabpot",
- "type": "github"
- },
- {
- "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
- "type": "tidelift"
- }
- ],
"time": "2020-10-23T14:02:19+00:00"
},
{
@@ -1635,23 +1406,6 @@
"portable",
"shim"
],
- "support": {
- "source": "https://github.com/symfony/polyfill-php73/tree/v1.20.0"
- },
- "funding": [
- {
- "url": "https://symfony.com/sponsor",
- "type": "custom"
- },
- {
- "url": "https://github.com/fabpot",
- "type": "github"
- },
- {
- "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
- "type": "tidelift"
- }
- ],
"time": "2020-10-23T14:02:19+00:00"
},
{
@@ -1718,37 +1472,20 @@
"portable",
"shim"
],
- "support": {
- "source": "https://github.com/symfony/polyfill-php80/tree/v1.20.0"
- },
- "funding": [
- {
- "url": "https://symfony.com/sponsor",
- "type": "custom"
- },
- {
- "url": "https://github.com/fabpot",
- "type": "github"
- },
- {
- "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
- "type": "tidelift"
- }
- ],
"time": "2020-10-23T14:02:19+00:00"
},
{
"name": "symfony/process",
- "version": "v4.4.16",
+ "version": "v4.4.18",
"source": {
"type": "git",
"url": "https://github.com/symfony/process.git",
- "reference": "2f4b049fb80ca5e9874615a2a85dc2a502090f05"
+ "reference": "075316ff72233ce3d04a9743414292e834f2cb4a"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/process/zipball/2f4b049fb80ca5e9874615a2a85dc2a502090f05",
- "reference": "2f4b049fb80ca5e9874615a2a85dc2a502090f05",
+ "url": "https://api.github.com/repos/symfony/process/zipball/075316ff72233ce3d04a9743414292e834f2cb4a",
+ "reference": "075316ff72233ce3d04a9743414292e834f2cb4a",
"shasum": ""
},
"require": {
@@ -1779,24 +1516,7 @@
],
"description": "Symfony Process Component",
"homepage": "https://symfony.com",
- "support": {
- "source": "https://github.com/symfony/process/tree/v4.4.16"
- },
- "funding": [
- {
- "url": "https://symfony.com/sponsor",
- "type": "custom"
- },
- {
- "url": "https://github.com/fabpot",
- "type": "github"
- },
- {
- "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
- "type": "tidelift"
- }
- ],
- "time": "2020-10-24T11:50:19+00:00"
+ "time": "2020-12-08T16:59:59+00:00"
},
{
"name": "symfony/service-contracts",
@@ -1858,37 +1578,20 @@
"interoperability",
"standards"
],
- "support": {
- "source": "https://github.com/symfony/service-contracts/tree/master"
- },
- "funding": [
- {
- "url": "https://symfony.com/sponsor",
- "type": "custom"
- },
- {
- "url": "https://github.com/fabpot",
- "type": "github"
- },
- {
- "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
- "type": "tidelift"
- }
- ],
"time": "2020-09-07T11:33:47+00:00"
},
{
"name": "symfony/yaml",
- "version": "v5.1.8",
+ "version": "v5.2.1",
"source": {
"type": "git",
"url": "https://github.com/symfony/yaml.git",
- "reference": "f284e032c3cefefb9943792132251b79a6127ca6"
+ "reference": "290ea5e03b8cf9b42c783163123f54441fb06939"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/yaml/zipball/f284e032c3cefefb9943792132251b79a6127ca6",
- "reference": "f284e032c3cefefb9943792132251b79a6127ca6",
+ "url": "https://api.github.com/repos/symfony/yaml/zipball/290ea5e03b8cf9b42c783163123f54441fb06939",
+ "reference": "290ea5e03b8cf9b42c783163123f54441fb06939",
"shasum": ""
},
"require": {
@@ -1933,24 +1636,7 @@
],
"description": "Symfony Yaml Component",
"homepage": "https://symfony.com",
- "support": {
- "source": "https://github.com/symfony/yaml/tree/v5.1.8"
- },
- "funding": [
- {
- "url": "https://symfony.com/sponsor",
- "type": "custom"
- },
- {
- "url": "https://github.com/fabpot",
- "type": "github"
- },
- {
- "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
- "type": "tidelift"
- }
- ],
- "time": "2020-10-24T12:03:25+00:00"
+ "time": "2020-12-08T17:02:38+00:00"
},
{
"name": "webuni/commonmark-table-extension",
@@ -2008,10 +1694,6 @@
"markdown",
"table"
],
- "support": {
- "issues": "https://github.com/webuni/commonmark-table-extension/issues",
- "source": "https://github.com/webuni/commonmark-table-extension/tree/0.9.0"
- },
"abandoned": "league/commonmark",
"time": "2018-11-28T11:29:11+00:00"
},
@@ -2094,6 +1776,5 @@
"prefer-stable": false,
"prefer-lowest": false,
"platform": [],
- "platform-dev": [],
- "plugin-api-version": "2.0.0"
+ "platform-dev": []
}
diff --git a/vendor-bin/phpunit/composer.lock b/vendor-bin/phpunit/composer.lock
index e963b995..45e101b1 100644
--- a/vendor-bin/phpunit/composer.lock
+++ b/vendor-bin/phpunit/composer.lock
@@ -9,21 +9,24 @@
"packages-dev": [
{
"name": "clue/arguments",
- "version": "v2.0.0",
+ "version": "v2.1.0",
"source": {
"type": "git",
- "url": "https://github.com/clue/php-arguments.git",
- "reference": "eb8356918bc51ac7e595e4ad92a2bc1c1d2754c2"
+ "url": "https://github.com/clue/arguments.git",
+ "reference": "87f2c4bc2ff602173bc52f5935a9c3b70d8c996d"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/clue/php-arguments/zipball/eb8356918bc51ac7e595e4ad92a2bc1c1d2754c2",
- "reference": "eb8356918bc51ac7e595e4ad92a2bc1c1d2754c2",
+ "url": "https://api.github.com/repos/clue/arguments/zipball/87f2c4bc2ff602173bc52f5935a9c3b70d8c996d",
+ "reference": "87f2c4bc2ff602173bc52f5935a9c3b70d8c996d",
"shasum": ""
},
"require": {
"php": ">=5.3"
},
+ "require-dev": {
+ "phpunit/phpunit": "^9.3 || ^5.7 || ^4.8.35"
+ },
"type": "library",
"autoload": {
"files": [
@@ -40,11 +43,11 @@
"authors": [
{
"name": "Christian Lück",
- "email": "christian@lueck.tv"
+ "email": "christian@clue.engineering"
}
],
"description": "The simple way to split your command line string into an array of command arguments in PHP.",
- "homepage": "https://github.com/clue/php-arguments",
+ "homepage": "https://github.com/clue/arguments",
"keywords": [
"args",
"arguments",
@@ -55,11 +58,7 @@
"parse",
"split"
],
- "support": {
- "issues": "https://github.com/clue/php-arguments/issues",
- "source": "https://github.com/clue/php-arguments/tree/v2.0.0"
- },
- "time": "2016-12-18T14:37:39+00:00"
+ "time": "2020-12-08T13:02:50+00:00"
},
{
"name": "dms/phpunit-arraysubset-asserts",
@@ -100,10 +99,6 @@
}
],
"description": "This package provides Array Subset and related asserts once depracated in PHPunit 8",
- "support": {
- "issues": "https://github.com/rdohms/phpunit-arraysubset-asserts/issues",
- "source": "https://github.com/rdohms/phpunit-arraysubset-asserts/tree/compat/phpunit8"
- },
"time": "2020-02-18T21:20:04+00:00"
},
{
@@ -159,20 +154,6 @@
"issues": "https://github.com/doctrine/instantiator/issues",
"source": "https://github.com/doctrine/instantiator/tree/1.4.0"
},
- "funding": [
- {
- "url": "https://www.doctrine-project.org/sponsorship.html",
- "type": "custom"
- },
- {
- "url": "https://www.patreon.com/phpdoctrine",
- "type": "patreon"
- },
- {
- "url": "https://tidelift.com/funding/github/packagist/doctrine%2Finstantiator",
- "type": "tidelift"
- }
- ],
"time": "2020-11-10T18:47:58+00:00"
},
{
@@ -219,11 +200,6 @@
],
"description": "Virtual file system to mock the real file system in unit tests.",
"homepage": "http://vfs.bovigo.org/",
- "support": {
- "issues": "https://github.com/bovigo/vfsStream/issues",
- "source": "https://github.com/bovigo/vfsStream/tree/master",
- "wiki": "https://github.com/bovigo/vfsStream/wiki"
- },
"time": "2019-10-30T15:31:00+00:00"
},
{
@@ -276,12 +252,6 @@
"issues": "https://github.com/myclabs/DeepCopy/issues",
"source": "https://github.com/myclabs/DeepCopy/tree/1.10.2"
},
- "funding": [
- {
- "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy",
- "type": "tidelift"
- }
- ],
"time": "2020-11-13T09:40:50+00:00"
},
{
@@ -340,36 +310,33 @@
"mock",
"testing"
],
- "support": {
- "issues": "https://github.com/mlively/Phake/issues",
- "source": "https://github.com/mlively/Phake/tree/v3.1.8"
- },
"time": "2020-05-11T18:43:26+00:00"
},
{
"name": "phar-io/manifest",
- "version": "1.0.3",
+ "version": "2.0.1",
"source": {
"type": "git",
"url": "https://github.com/phar-io/manifest.git",
- "reference": "7761fcacf03b4d4f16e7ccb606d4879ca431fcf4"
+ "reference": "85265efd3af7ba3ca4b2a2c34dbfc5788dd29133"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/phar-io/manifest/zipball/7761fcacf03b4d4f16e7ccb606d4879ca431fcf4",
- "reference": "7761fcacf03b4d4f16e7ccb606d4879ca431fcf4",
+ "url": "https://api.github.com/repos/phar-io/manifest/zipball/85265efd3af7ba3ca4b2a2c34dbfc5788dd29133",
+ "reference": "85265efd3af7ba3ca4b2a2c34dbfc5788dd29133",
"shasum": ""
},
"require": {
"ext-dom": "*",
"ext-phar": "*",
- "phar-io/version": "^2.0",
- "php": "^5.6 || ^7.0"
+ "ext-xmlwriter": "*",
+ "phar-io/version": "^3.0.1",
+ "php": "^7.2 || ^8.0"
},
"type": "library",
"extra": {
"branch-alias": {
- "dev-master": "1.0.x-dev"
+ "dev-master": "2.0.x-dev"
}
},
"autoload": {
@@ -399,28 +366,24 @@
}
],
"description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)",
- "support": {
- "issues": "https://github.com/phar-io/manifest/issues",
- "source": "https://github.com/phar-io/manifest/tree/master"
- },
- "time": "2018-07-08T19:23:20+00:00"
+ "time": "2020-06-27T14:33:11+00:00"
},
{
"name": "phar-io/version",
- "version": "2.0.1",
+ "version": "3.0.4",
"source": {
"type": "git",
"url": "https://github.com/phar-io/version.git",
- "reference": "45a2ec53a73c70ce41d55cedef9063630abaf1b6"
+ "reference": "e4782611070e50613683d2b9a57730e9a3ba5451"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/phar-io/version/zipball/45a2ec53a73c70ce41d55cedef9063630abaf1b6",
- "reference": "45a2ec53a73c70ce41d55cedef9063630abaf1b6",
+ "url": "https://api.github.com/repos/phar-io/version/zipball/e4782611070e50613683d2b9a57730e9a3ba5451",
+ "reference": "e4782611070e50613683d2b9a57730e9a3ba5451",
"shasum": ""
},
"require": {
- "php": "^5.6 || ^7.0"
+ "php": "^7.2 || ^8.0"
},
"type": "library",
"autoload": {
@@ -450,11 +413,7 @@
}
],
"description": "Library for handling version information and constraints",
- "support": {
- "issues": "https://github.com/phar-io/version/issues",
- "source": "https://github.com/phar-io/version/tree/master"
- },
- "time": "2018-07-08T19:19:57+00:00"
+ "time": "2020-12-13T23:18:30+00:00"
},
{
"name": "phpdocumentor/reflection-common",
@@ -503,10 +462,6 @@
"reflection",
"static analysis"
],
- "support": {
- "issues": "https://github.com/phpDocumentor/ReflectionCommon/issues",
- "source": "https://github.com/phpDocumentor/ReflectionCommon/tree/2.x"
- },
"time": "2020-06-27T09:03:43+00:00"
},
{
@@ -559,10 +514,6 @@
}
],
"description": "With this component, a library can provide support for annotations via DocBlocks or otherwise retrieve information that is embedded in a DocBlock.",
- "support": {
- "issues": "https://github.com/phpDocumentor/ReflectionDocBlock/issues",
- "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/master"
- },
"time": "2020-09-03T19:13:55+00:00"
},
{
@@ -608,24 +559,20 @@
}
],
"description": "A PSR-5 based resolver of Class names, Types and Structural Element Names",
- "support": {
- "issues": "https://github.com/phpDocumentor/TypeResolver/issues",
- "source": "https://github.com/phpDocumentor/TypeResolver/tree/1.4.0"
- },
"time": "2020-09-17T18:55:26+00:00"
},
{
"name": "phpspec/prophecy",
- "version": "1.12.1",
+ "version": "1.12.2",
"source": {
"type": "git",
"url": "https://github.com/phpspec/prophecy.git",
- "reference": "8ce87516be71aae9b956f81906aaf0338e0d8a2d"
+ "reference": "245710e971a030f42e08f4912863805570f23d39"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/phpspec/prophecy/zipball/8ce87516be71aae9b956f81906aaf0338e0d8a2d",
- "reference": "8ce87516be71aae9b956f81906aaf0338e0d8a2d",
+ "url": "https://api.github.com/repos/phpspec/prophecy/zipball/245710e971a030f42e08f4912863805570f23d39",
+ "reference": "245710e971a030f42e08f4912863805570f23d39",
"shasum": ""
},
"require": {
@@ -637,7 +584,7 @@
},
"require-dev": {
"phpspec/phpspec": "^6.0",
- "phpunit/phpunit": "^8.0 || ^9.0 <9.3"
+ "phpunit/phpunit": "^8.0 || ^9.0"
},
"type": "library",
"extra": {
@@ -675,33 +622,29 @@
"spy",
"stub"
],
- "support": {
- "issues": "https://github.com/phpspec/prophecy/issues",
- "source": "https://github.com/phpspec/prophecy/tree/1.12.1"
- },
- "time": "2020-09-29T09:10:42+00:00"
+ "time": "2020-12-19T10:15:11+00:00"
},
{
"name": "phpunit/php-code-coverage",
- "version": "7.0.12",
+ "version": "7.0.14",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/php-code-coverage.git",
- "reference": "52f55786aa2e52c26cd9e2db20aff2981e0f7399"
+ "reference": "bb7c9a210c72e4709cdde67f8b7362f672f2225c"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/52f55786aa2e52c26cd9e2db20aff2981e0f7399",
- "reference": "52f55786aa2e52c26cd9e2db20aff2981e0f7399",
+ "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/bb7c9a210c72e4709cdde67f8b7362f672f2225c",
+ "reference": "bb7c9a210c72e4709cdde67f8b7362f672f2225c",
"shasum": ""
},
"require": {
"ext-dom": "*",
"ext-xmlwriter": "*",
- "php": "^7.2",
+ "php": ">=7.2",
"phpunit/php-file-iterator": "^2.0.2",
"phpunit/php-text-template": "^1.2.1",
- "phpunit/php-token-stream": "^3.1.1",
+ "phpunit/php-token-stream": "^3.1.1 || ^4.0",
"sebastian/code-unit-reverse-lookup": "^1.0.1",
"sebastian/environment": "^4.2.2",
"sebastian/version": "^2.0.1",
@@ -742,37 +685,27 @@
"testing",
"xunit"
],
- "support": {
- "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues",
- "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/7.0.12"
- },
- "funding": [
- {
- "url": "https://github.com/sebastianbergmann",
- "type": "github"
- }
- ],
- "time": "2020-11-27T06:08:35+00:00"
+ "time": "2020-12-02T13:39:03+00:00"
},
{
"name": "phpunit/php-file-iterator",
- "version": "2.0.2",
+ "version": "2.0.3",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/php-file-iterator.git",
- "reference": "050bedf145a257b1ff02746c31894800e5122946"
+ "reference": "4b49fb70f067272b659ef0174ff9ca40fdaa6357"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/050bedf145a257b1ff02746c31894800e5122946",
- "reference": "050bedf145a257b1ff02746c31894800e5122946",
+ "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/4b49fb70f067272b659ef0174ff9ca40fdaa6357",
+ "reference": "4b49fb70f067272b659ef0174ff9ca40fdaa6357",
"shasum": ""
},
"require": {
- "php": "^7.1"
+ "php": ">=7.1"
},
"require-dev": {
- "phpunit/phpunit": "^7.1"
+ "phpunit/phpunit": "^8.5"
},
"type": "library",
"extra": {
@@ -802,11 +735,7 @@
"filesystem",
"iterator"
],
- "support": {
- "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues",
- "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/2.0.2"
- },
- "time": "2018-09-13T20:33:42+00:00"
+ "time": "2020-11-30T08:25:21+00:00"
},
{
"name": "phpunit/php-text-template",
@@ -847,31 +776,27 @@
"keywords": [
"template"
],
- "support": {
- "issues": "https://github.com/sebastianbergmann/php-text-template/issues",
- "source": "https://github.com/sebastianbergmann/php-text-template/tree/1.2.1"
- },
"time": "2015-06-21T13:50:34+00:00"
},
{
"name": "phpunit/php-timer",
- "version": "2.1.2",
+ "version": "2.1.3",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/php-timer.git",
- "reference": "1038454804406b0b5f5f520358e78c1c2f71501e"
+ "reference": "2454ae1765516d20c4ffe103d85a58a9a3bd5662"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/1038454804406b0b5f5f520358e78c1c2f71501e",
- "reference": "1038454804406b0b5f5f520358e78c1c2f71501e",
+ "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/2454ae1765516d20c4ffe103d85a58a9a3bd5662",
+ "reference": "2454ae1765516d20c4ffe103d85a58a9a3bd5662",
"shasum": ""
},
"require": {
- "php": "^7.1"
+ "php": ">=7.1"
},
"require-dev": {
- "phpunit/phpunit": "^7.0"
+ "phpunit/phpunit": "^8.5"
},
"type": "library",
"extra": {
@@ -900,37 +825,33 @@
"keywords": [
"timer"
],
- "support": {
- "issues": "https://github.com/sebastianbergmann/php-timer/issues",
- "source": "https://github.com/sebastianbergmann/php-timer/tree/master"
- },
- "time": "2019-06-07T04:22:29+00:00"
+ "time": "2020-11-30T08:20:02+00:00"
},
{
"name": "phpunit/php-token-stream",
- "version": "3.1.1",
+ "version": "4.0.4",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/php-token-stream.git",
- "reference": "995192df77f63a59e47f025390d2d1fdf8f425ff"
+ "reference": "a853a0e183b9db7eed023d7933a858fa1c8d25a3"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/php-token-stream/zipball/995192df77f63a59e47f025390d2d1fdf8f425ff",
- "reference": "995192df77f63a59e47f025390d2d1fdf8f425ff",
+ "url": "https://api.github.com/repos/sebastianbergmann/php-token-stream/zipball/a853a0e183b9db7eed023d7933a858fa1c8d25a3",
+ "reference": "a853a0e183b9db7eed023d7933a858fa1c8d25a3",
"shasum": ""
},
"require": {
"ext-tokenizer": "*",
- "php": "^7.1"
+ "php": "^7.3 || ^8.0"
},
"require-dev": {
- "phpunit/phpunit": "^7.0"
+ "phpunit/phpunit": "^9.0"
},
"type": "library",
"extra": {
"branch-alias": {
- "dev-master": "3.1-dev"
+ "dev-master": "4.0-dev"
}
},
"autoload": {
@@ -953,25 +874,21 @@
"keywords": [
"tokenizer"
],
- "support": {
- "issues": "https://github.com/sebastianbergmann/php-token-stream/issues",
- "source": "https://github.com/sebastianbergmann/php-token-stream/tree/3.1.1"
- },
"abandoned": true,
- "time": "2019-09-17T06:23:10+00:00"
+ "time": "2020-08-04T08:28:15+00:00"
},
{
"name": "phpunit/phpunit",
- "version": "8.5.11",
+ "version": "8.5.13",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/phpunit.git",
- "reference": "3123601e3b29339b20129acc3f989cfec3274566"
+ "reference": "8e86be391a58104ef86037ba8a846524528d784e"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/3123601e3b29339b20129acc3f989cfec3274566",
- "reference": "3123601e3b29339b20129acc3f989cfec3274566",
+ "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/8e86be391a58104ef86037ba8a846524528d784e",
+ "reference": "8e86be391a58104ef86037ba8a846524528d784e",
"shasum": ""
},
"require": {
@@ -983,9 +900,9 @@
"ext-xml": "*",
"ext-xmlwriter": "*",
"myclabs/deep-copy": "^1.10.0",
- "phar-io/manifest": "^1.0.3",
- "phar-io/version": "^2.0.1",
- "php": "^7.2",
+ "phar-io/manifest": "^2.0.1",
+ "phar-io/version": "^3.0.2",
+ "php": ">=7.2",
"phpspec/prophecy": "^1.10.3",
"phpunit/php-code-coverage": "^7.0.12",
"phpunit/php-file-iterator": "^2.0.2",
@@ -1041,41 +958,27 @@
"testing",
"xunit"
],
- "support": {
- "issues": "https://github.com/sebastianbergmann/phpunit/issues",
- "source": "https://github.com/sebastianbergmann/phpunit/tree/8.5.11"
- },
- "funding": [
- {
- "url": "https://phpunit.de/donate.html",
- "type": "custom"
- },
- {
- "url": "https://github.com/sebastianbergmann",
- "type": "github"
- }
- ],
- "time": "2020-11-27T12:46:45+00:00"
+ "time": "2020-12-01T04:53:52+00:00"
},
{
"name": "sebastian/code-unit-reverse-lookup",
- "version": "1.0.1",
+ "version": "1.0.2",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/code-unit-reverse-lookup.git",
- "reference": "4419fcdb5eabb9caa61a27c7a1db532a6b55dd18"
+ "reference": "1de8cd5c010cb153fcd68b8d0f64606f523f7619"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/4419fcdb5eabb9caa61a27c7a1db532a6b55dd18",
- "reference": "4419fcdb5eabb9caa61a27c7a1db532a6b55dd18",
+ "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/1de8cd5c010cb153fcd68b8d0f64606f523f7619",
+ "reference": "1de8cd5c010cb153fcd68b8d0f64606f523f7619",
"shasum": ""
},
"require": {
- "php": "^5.6 || ^7.0"
+ "php": ">=5.6"
},
"require-dev": {
- "phpunit/phpunit": "^5.7 || ^6.0"
+ "phpunit/phpunit": "^8.5"
},
"type": "library",
"extra": {
@@ -1100,33 +1003,29 @@
],
"description": "Looks up which function or method a line of code belongs to",
"homepage": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/",
- "support": {
- "issues": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/issues",
- "source": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/tree/master"
- },
- "time": "2017-03-04T06:30:41+00:00"
+ "time": "2020-11-30T08:15:22+00:00"
},
{
"name": "sebastian/comparator",
- "version": "3.0.2",
+ "version": "3.0.3",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/comparator.git",
- "reference": "5de4fc177adf9bce8df98d8d141a7559d7ccf6da"
+ "reference": "1071dfcef776a57013124ff35e1fc41ccd294758"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/5de4fc177adf9bce8df98d8d141a7559d7ccf6da",
- "reference": "5de4fc177adf9bce8df98d8d141a7559d7ccf6da",
+ "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/1071dfcef776a57013124ff35e1fc41ccd294758",
+ "reference": "1071dfcef776a57013124ff35e1fc41ccd294758",
"shasum": ""
},
"require": {
- "php": "^7.1",
+ "php": ">=7.1",
"sebastian/diff": "^3.0",
"sebastian/exporter": "^3.1"
},
"require-dev": {
- "phpunit/phpunit": "^7.1"
+ "phpunit/phpunit": "^8.5"
},
"type": "library",
"extra": {
@@ -1144,6 +1043,10 @@
"BSD-3-Clause"
],
"authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ },
{
"name": "Jeff Welch",
"email": "whatthejeff@gmail.com"
@@ -1155,10 +1058,6 @@
{
"name": "Bernhard Schussek",
"email": "bschussek@2bepublished.at"
- },
- {
- "name": "Sebastian Bergmann",
- "email": "sebastian@phpunit.de"
}
],
"description": "Provides the functionality to compare PHP values for equality",
@@ -1168,28 +1067,24 @@
"compare",
"equality"
],
- "support": {
- "issues": "https://github.com/sebastianbergmann/comparator/issues",
- "source": "https://github.com/sebastianbergmann/comparator/tree/master"
- },
- "time": "2018-07-12T15:12:46+00:00"
+ "time": "2020-11-30T08:04:30+00:00"
},
{
"name": "sebastian/diff",
- "version": "3.0.2",
+ "version": "3.0.3",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/diff.git",
- "reference": "720fcc7e9b5cf384ea68d9d930d480907a0c1a29"
+ "reference": "14f72dd46eaf2f2293cbe79c93cc0bc43161a211"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/720fcc7e9b5cf384ea68d9d930d480907a0c1a29",
- "reference": "720fcc7e9b5cf384ea68d9d930d480907a0c1a29",
+ "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/14f72dd46eaf2f2293cbe79c93cc0bc43161a211",
+ "reference": "14f72dd46eaf2f2293cbe79c93cc0bc43161a211",
"shasum": ""
},
"require": {
- "php": "^7.1"
+ "php": ">=7.1"
},
"require-dev": {
"phpunit/phpunit": "^7.5 || ^8.0",
@@ -1211,13 +1106,13 @@
"BSD-3-Clause"
],
"authors": [
- {
- "name": "Kore Nordmann",
- "email": "mail@kore-nordmann.de"
- },
{
"name": "Sebastian Bergmann",
"email": "sebastian@phpunit.de"
+ },
+ {
+ "name": "Kore Nordmann",
+ "email": "mail@kore-nordmann.de"
}
],
"description": "Diff implementation",
@@ -1228,28 +1123,24 @@
"unidiff",
"unified diff"
],
- "support": {
- "issues": "https://github.com/sebastianbergmann/diff/issues",
- "source": "https://github.com/sebastianbergmann/diff/tree/master"
- },
- "time": "2019-02-04T06:01:07+00:00"
+ "time": "2020-11-30T07:59:04+00:00"
},
{
"name": "sebastian/environment",
- "version": "4.2.3",
+ "version": "4.2.4",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/environment.git",
- "reference": "464c90d7bdf5ad4e8a6aea15c091fec0603d4368"
+ "reference": "d47bbbad83711771f167c72d4e3f25f7fcc1f8b0"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/464c90d7bdf5ad4e8a6aea15c091fec0603d4368",
- "reference": "464c90d7bdf5ad4e8a6aea15c091fec0603d4368",
+ "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/d47bbbad83711771f167c72d4e3f25f7fcc1f8b0",
+ "reference": "d47bbbad83711771f167c72d4e3f25f7fcc1f8b0",
"shasum": ""
},
"require": {
- "php": "^7.1"
+ "php": ">=7.1"
},
"require-dev": {
"phpunit/phpunit": "^7.5"
@@ -1285,28 +1176,24 @@
"environment",
"hhvm"
],
- "support": {
- "issues": "https://github.com/sebastianbergmann/environment/issues",
- "source": "https://github.com/sebastianbergmann/environment/tree/4.2.3"
- },
- "time": "2019-11-20T08:46:58+00:00"
+ "time": "2020-11-30T07:53:42+00:00"
},
{
"name": "sebastian/exporter",
- "version": "3.1.2",
+ "version": "3.1.3",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/exporter.git",
- "reference": "68609e1261d215ea5b21b7987539cbfbe156ec3e"
+ "reference": "6b853149eab67d4da22291d36f5b0631c0fd856e"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/68609e1261d215ea5b21b7987539cbfbe156ec3e",
- "reference": "68609e1261d215ea5b21b7987539cbfbe156ec3e",
+ "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/6b853149eab67d4da22291d36f5b0631c0fd856e",
+ "reference": "6b853149eab67d4da22291d36f5b0631c0fd856e",
"shasum": ""
},
"require": {
- "php": "^7.0",
+ "php": ">=7.0",
"sebastian/recursion-context": "^3.0"
},
"require-dev": {
@@ -1356,28 +1243,24 @@
"export",
"exporter"
],
- "support": {
- "issues": "https://github.com/sebastianbergmann/exporter/issues",
- "source": "https://github.com/sebastianbergmann/exporter/tree/master"
- },
- "time": "2019-09-14T09:02:43+00:00"
+ "time": "2020-11-30T07:47:53+00:00"
},
{
"name": "sebastian/global-state",
- "version": "3.0.0",
+ "version": "3.0.1",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/global-state.git",
- "reference": "edf8a461cf1d4005f19fb0b6b8b95a9f7fa0adc4"
+ "reference": "474fb9edb7ab891665d3bfc6317f42a0a150454b"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/edf8a461cf1d4005f19fb0b6b8b95a9f7fa0adc4",
- "reference": "edf8a461cf1d4005f19fb0b6b8b95a9f7fa0adc4",
+ "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/474fb9edb7ab891665d3bfc6317f42a0a150454b",
+ "reference": "474fb9edb7ab891665d3bfc6317f42a0a150454b",
"shasum": ""
},
"require": {
- "php": "^7.2",
+ "php": ">=7.2",
"sebastian/object-reflector": "^1.1.1",
"sebastian/recursion-context": "^3.0"
},
@@ -1414,28 +1297,24 @@
"keywords": [
"global state"
],
- "support": {
- "issues": "https://github.com/sebastianbergmann/global-state/issues",
- "source": "https://github.com/sebastianbergmann/global-state/tree/master"
- },
- "time": "2019-02-01T05:30:01+00:00"
+ "time": "2020-11-30T07:43:24+00:00"
},
{
"name": "sebastian/object-enumerator",
- "version": "3.0.3",
+ "version": "3.0.4",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/object-enumerator.git",
- "reference": "7cfd9e65d11ffb5af41198476395774d4c8a84c5"
+ "reference": "e67f6d32ebd0c749cf9d1dbd9f226c727043cdf2"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/7cfd9e65d11ffb5af41198476395774d4c8a84c5",
- "reference": "7cfd9e65d11ffb5af41198476395774d4c8a84c5",
+ "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/e67f6d32ebd0c749cf9d1dbd9f226c727043cdf2",
+ "reference": "e67f6d32ebd0c749cf9d1dbd9f226c727043cdf2",
"shasum": ""
},
"require": {
- "php": "^7.0",
+ "php": ">=7.0",
"sebastian/object-reflector": "^1.1.1",
"sebastian/recursion-context": "^3.0"
},
@@ -1465,28 +1344,24 @@
],
"description": "Traverses array structures and object graphs to enumerate all referenced objects",
"homepage": "https://github.com/sebastianbergmann/object-enumerator/",
- "support": {
- "issues": "https://github.com/sebastianbergmann/object-enumerator/issues",
- "source": "https://github.com/sebastianbergmann/object-enumerator/tree/master"
- },
- "time": "2017-08-03T12:35:26+00:00"
+ "time": "2020-11-30T07:40:27+00:00"
},
{
"name": "sebastian/object-reflector",
- "version": "1.1.1",
+ "version": "1.1.2",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/object-reflector.git",
- "reference": "773f97c67f28de00d397be301821b06708fca0be"
+ "reference": "9b8772b9cbd456ab45d4a598d2dd1a1bced6363d"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/773f97c67f28de00d397be301821b06708fca0be",
- "reference": "773f97c67f28de00d397be301821b06708fca0be",
+ "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/9b8772b9cbd456ab45d4a598d2dd1a1bced6363d",
+ "reference": "9b8772b9cbd456ab45d4a598d2dd1a1bced6363d",
"shasum": ""
},
"require": {
- "php": "^7.0"
+ "php": ">=7.0"
},
"require-dev": {
"phpunit/phpunit": "^6.0"
@@ -1514,28 +1389,24 @@
],
"description": "Allows reflection of object attributes, including inherited and non-public ones",
"homepage": "https://github.com/sebastianbergmann/object-reflector/",
- "support": {
- "issues": "https://github.com/sebastianbergmann/object-reflector/issues",
- "source": "https://github.com/sebastianbergmann/object-reflector/tree/master"
- },
- "time": "2017-03-29T09:07:27+00:00"
+ "time": "2020-11-30T07:37:18+00:00"
},
{
"name": "sebastian/recursion-context",
- "version": "3.0.0",
+ "version": "3.0.1",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/recursion-context.git",
- "reference": "5b0cd723502bac3b006cbf3dbf7a1e3fcefe4fa8"
+ "reference": "367dcba38d6e1977be014dc4b22f47a484dac7fb"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/5b0cd723502bac3b006cbf3dbf7a1e3fcefe4fa8",
- "reference": "5b0cd723502bac3b006cbf3dbf7a1e3fcefe4fa8",
+ "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/367dcba38d6e1977be014dc4b22f47a484dac7fb",
+ "reference": "367dcba38d6e1977be014dc4b22f47a484dac7fb",
"shasum": ""
},
"require": {
- "php": "^7.0"
+ "php": ">=7.0"
},
"require-dev": {
"phpunit/phpunit": "^6.0"
@@ -1556,14 +1427,14 @@
"BSD-3-Clause"
],
"authors": [
- {
- "name": "Jeff Welch",
- "email": "whatthejeff@gmail.com"
- },
{
"name": "Sebastian Bergmann",
"email": "sebastian@phpunit.de"
},
+ {
+ "name": "Jeff Welch",
+ "email": "whatthejeff@gmail.com"
+ },
{
"name": "Adam Harvey",
"email": "aharvey@php.net"
@@ -1571,28 +1442,24 @@
],
"description": "Provides functionality to recursively process PHP variables",
"homepage": "http://www.github.com/sebastianbergmann/recursion-context",
- "support": {
- "issues": "https://github.com/sebastianbergmann/recursion-context/issues",
- "source": "https://github.com/sebastianbergmann/recursion-context/tree/master"
- },
- "time": "2017-03-03T06:23:57+00:00"
+ "time": "2020-11-30T07:34:24+00:00"
},
{
"name": "sebastian/resource-operations",
- "version": "2.0.1",
+ "version": "2.0.2",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/resource-operations.git",
- "reference": "4d7a795d35b889bf80a0cc04e08d77cedfa917a9"
+ "reference": "31d35ca87926450c44eae7e2611d45a7a65ea8b3"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/resource-operations/zipball/4d7a795d35b889bf80a0cc04e08d77cedfa917a9",
- "reference": "4d7a795d35b889bf80a0cc04e08d77cedfa917a9",
+ "url": "https://api.github.com/repos/sebastianbergmann/resource-operations/zipball/31d35ca87926450c44eae7e2611d45a7a65ea8b3",
+ "reference": "31d35ca87926450c44eae7e2611d45a7a65ea8b3",
"shasum": ""
},
"require": {
- "php": "^7.1"
+ "php": ">=7.1"
},
"type": "library",
"extra": {
@@ -1617,28 +1484,24 @@
],
"description": "Provides a list of PHP built-in functions that operate on resources",
"homepage": "https://www.github.com/sebastianbergmann/resource-operations",
- "support": {
- "issues": "https://github.com/sebastianbergmann/resource-operations/issues",
- "source": "https://github.com/sebastianbergmann/resource-operations/tree/master"
- },
- "time": "2018-10-04T04:07:39+00:00"
+ "time": "2020-11-30T07:30:19+00:00"
},
{
"name": "sebastian/type",
- "version": "1.1.3",
+ "version": "1.1.4",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/type.git",
- "reference": "3aaaa15fa71d27650d62a948be022fe3b48541a3"
+ "reference": "0150cfbc4495ed2df3872fb31b26781e4e077eb4"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/3aaaa15fa71d27650d62a948be022fe3b48541a3",
- "reference": "3aaaa15fa71d27650d62a948be022fe3b48541a3",
+ "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/0150cfbc4495ed2df3872fb31b26781e4e077eb4",
+ "reference": "0150cfbc4495ed2df3872fb31b26781e4e077eb4",
"shasum": ""
},
"require": {
- "php": "^7.2"
+ "php": ">=7.2"
},
"require-dev": {
"phpunit/phpunit": "^8.2"
@@ -1667,11 +1530,7 @@
],
"description": "Collection of value objects that represent the types of the PHP type system",
"homepage": "https://github.com/sebastianbergmann/type",
- "support": {
- "issues": "https://github.com/sebastianbergmann/type/issues",
- "source": "https://github.com/sebastianbergmann/type/tree/master"
- },
- "time": "2019-07-02T08:10:15+00:00"
+ "time": "2020-11-30T07:25:11+00:00"
},
{
"name": "sebastian/version",
@@ -1714,10 +1573,6 @@
],
"description": "Library that helps with managing the version number of Git-hosted PHP projects",
"homepage": "https://github.com/sebastianbergmann/version",
- "support": {
- "issues": "https://github.com/sebastianbergmann/version/issues",
- "source": "https://github.com/sebastianbergmann/version/tree/master"
- },
"time": "2016-10-03T07:35:21+00:00"
},
{
@@ -1780,23 +1635,6 @@
"polyfill",
"portable"
],
- "support": {
- "source": "https://github.com/symfony/polyfill-ctype/tree/v1.20.0"
- },
- "funding": [
- {
- "url": "https://symfony.com/sponsor",
- "type": "custom"
- },
- {
- "url": "https://github.com/fabpot",
- "type": "github"
- },
- {
- "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
- "type": "tidelift"
- }
- ],
"time": "2020-10-23T14:02:19+00:00"
},
{
@@ -1837,16 +1675,6 @@
}
],
"description": "A small library for converting tokenized PHP source code into XML and potentially other formats",
- "support": {
- "issues": "https://github.com/theseer/tokenizer/issues",
- "source": "https://github.com/theseer/tokenizer/tree/master"
- },
- "funding": [
- {
- "url": "https://github.com/theseer",
- "type": "github"
- }
- ],
"time": "2020-07-12T23:59:07+00:00"
},
{
@@ -1896,10 +1724,6 @@
"check",
"validate"
],
- "support": {
- "issues": "https://github.com/webmozart/assert/issues",
- "source": "https://github.com/webmozart/assert/tree/master"
- },
"time": "2020-07-08T17:02:28+00:00"
},
{
@@ -1947,10 +1771,6 @@
}
],
"description": "A PHP implementation of Ant's glob.",
- "support": {
- "issues": "https://github.com/webmozart/glob/issues",
- "source": "https://github.com/webmozart/glob/tree/master"
- },
"time": "2015-12-29T11:14:33+00:00"
},
{
@@ -1997,10 +1817,6 @@
}
],
"description": "A robust cross-platform utility for normalizing, comparing and modifying file paths.",
- "support": {
- "issues": "https://github.com/webmozart/path-util/issues",
- "source": "https://github.com/webmozart/path-util/tree/2.3.0"
- },
"time": "2015-12-17T08:42:14+00:00"
}
],
@@ -2010,6 +1826,5 @@
"prefer-stable": false,
"prefer-lowest": false,
"platform": [],
- "platform-dev": [],
- "plugin-api-version": "2.0.0"
+ "platform-dev": []
}
diff --git a/vendor-bin/robo/composer.lock b/vendor-bin/robo/composer.lock
index c558c227..d4395718 100644
--- a/vendor-bin/robo/composer.lock
+++ b/vendor-bin/robo/composer.lock
@@ -9,46 +9,33 @@
"packages-dev": [
{
"name": "consolidation/annotated-command",
- "version": "4.2.3",
+ "version": "4.2.4",
"source": {
"type": "git",
"url": "https://github.com/consolidation/annotated-command.git",
- "reference": "4b596872f24c39d9c04d7b3adb6bc51baa1f2fd5"
+ "reference": "ec297e05cb86557671c2d6cbb1bebba6c7ae2c60"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/consolidation/annotated-command/zipball/4b596872f24c39d9c04d7b3adb6bc51baa1f2fd5",
- "reference": "4b596872f24c39d9c04d7b3adb6bc51baa1f2fd5",
+ "url": "https://api.github.com/repos/consolidation/annotated-command/zipball/ec297e05cb86557671c2d6cbb1bebba6c7ae2c60",
+ "reference": "ec297e05cb86557671c2d6cbb1bebba6c7ae2c60",
"shasum": ""
},
"require": {
"consolidation/output-formatters": "^4.1.1",
"php": ">=7.1.3",
"psr/log": "^1|^2",
- "symfony/console": "^4.4.8|^5",
+ "symfony/console": "^4.4.8|~5.1.0",
"symfony/event-dispatcher": "^4.4.8|^5",
"symfony/finder": "^4.4.8|^5"
},
"require-dev": {
- "g1a/composer-test-scenarios": "^3",
- "php-coveralls/php-coveralls": "^2.2",
- "phpunit/phpunit": "^6",
- "squizlabs/php_codesniffer": "^3"
+ "phpunit/phpunit": ">=7.5.20",
+ "squizlabs/php_codesniffer": "^3",
+ "yoast/phpunit-polyfills": "^0.2.0"
},
"type": "library",
"extra": {
- "scenarios": {
- "symfony4": {
- "require": {
- "symfony/console": "^4.0"
- },
- "config": {
- "platform": {
- "php": "7.1.3"
- }
- }
- }
- },
"branch-alias": {
"dev-main": "4.x-dev"
}
@@ -69,11 +56,7 @@
}
],
"description": "Initialize Symfony Console commands from annotated command class methods.",
- "support": {
- "issues": "https://github.com/consolidation/annotated-command/issues",
- "source": "https://github.com/consolidation/annotated-command/tree/4.2.3"
- },
- "time": "2020-10-03T14:28:42+00:00"
+ "time": "2020-12-10T16:56:39+00:00"
},
{
"name": "consolidation/config",
@@ -154,24 +137,20 @@
}
],
"description": "Provide configuration services for a commandline tool.",
- "support": {
- "issues": "https://github.com/consolidation/config/issues",
- "source": "https://github.com/consolidation/config/tree/master"
- },
"time": "2019-03-03T19:37:04+00:00"
},
{
"name": "consolidation/log",
- "version": "2.0.1",
+ "version": "2.0.2",
"source": {
"type": "git",
"url": "https://github.com/consolidation/log.git",
- "reference": "ba0bf6af1fbd09ed4dc18fc2f27b12ceff487cbf"
+ "reference": "82a2aaaa621a7b976e50a745a8d249d5085ee2b1"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/consolidation/log/zipball/ba0bf6af1fbd09ed4dc18fc2f27b12ceff487cbf",
- "reference": "ba0bf6af1fbd09ed4dc18fc2f27b12ceff487cbf",
+ "url": "https://api.github.com/repos/consolidation/log/zipball/82a2aaaa621a7b976e50a745a8d249d5085ee2b1",
+ "reference": "82a2aaaa621a7b976e50a745a8d249d5085ee2b1",
"shasum": ""
},
"require": {
@@ -180,27 +159,14 @@
"symfony/console": "^4|^5"
},
"require-dev": {
- "g1a/composer-test-scenarios": "^3",
- "php-coveralls/php-coveralls": "^2.2",
- "phpunit/phpunit": "^6",
- "squizlabs/php_codesniffer": "^3"
+ "phpunit/phpunit": ">=7.5.20",
+ "squizlabs/php_codesniffer": "^3",
+ "yoast/phpunit-polyfills": "^0.2.0"
},
"type": "library",
"extra": {
- "scenarios": {
- "symfony4": {
- "require-dev": {
- "symfony/console": "^4"
- },
- "config": {
- "platform": {
- "php": "7.1.3"
- }
- }
- }
- },
"branch-alias": {
- "dev-master": "2.x-dev"
+ "dev-main": "2.x-dev"
}
},
"autoload": {
@@ -219,24 +185,20 @@
}
],
"description": "Improved Psr-3 / Psr\\Log logger based on Symfony Console components.",
- "support": {
- "issues": "https://github.com/consolidation/log/issues",
- "source": "https://github.com/consolidation/log/tree/2.0.1"
- },
- "time": "2020-05-27T17:06:13+00:00"
+ "time": "2020-12-10T16:26:23+00:00"
},
{
"name": "consolidation/output-formatters",
- "version": "4.1.1",
+ "version": "4.1.2",
"source": {
"type": "git",
"url": "https://github.com/consolidation/output-formatters.git",
- "reference": "9deeddd6a916d0a756b216a8b40ce1016e17c0b9"
+ "reference": "5821e6ae076bf690058a4de6c94dce97398a69c9"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/consolidation/output-formatters/zipball/9deeddd6a916d0a756b216a8b40ce1016e17c0b9",
- "reference": "9deeddd6a916d0a756b216a8b40ce1016e17c0b9",
+ "url": "https://api.github.com/repos/consolidation/output-formatters/zipball/5821e6ae076bf690058a4de6c94dce97398a69c9",
+ "reference": "5821e6ae076bf690058a4de6c94dce97398a69c9",
"shasum": ""
},
"require": {
@@ -246,32 +208,20 @@
"symfony/finder": "^4|^5"
},
"require-dev": {
- "g1a/composer-test-scenarios": "^3",
- "php-coveralls/php-coveralls": "^2.2",
- "phpunit/phpunit": "^6",
+ "php-coveralls/php-coveralls": "^2.4.2",
+ "phpunit/phpunit": ">=7",
"squizlabs/php_codesniffer": "^3",
"symfony/var-dumper": "^4",
- "symfony/yaml": "^4"
+ "symfony/yaml": "^4",
+ "yoast/phpunit-polyfills": "^0.2.0"
},
"suggest": {
"symfony/var-dumper": "For using the var_dump formatter"
},
"type": "library",
"extra": {
- "scenarios": {
- "symfony4": {
- "require": {
- "symfony/console": "^4.0"
- },
- "config": {
- "platform": {
- "php": "7.1.3"
- }
- }
- }
- },
"branch-alias": {
- "dev-master": "4.x-dev"
+ "dev-main": "4.x-dev"
}
},
"autoload": {
@@ -290,11 +240,7 @@
}
],
"description": "Format text by applying transformations provided by plug-in formatters.",
- "support": {
- "issues": "https://github.com/consolidation/output-formatters/issues",
- "source": "https://github.com/consolidation/output-formatters/tree/4.1.1"
- },
- "time": "2020-05-27T20:51:17+00:00"
+ "time": "2020-12-12T19:04:59+00:00"
},
{
"name": "consolidation/robo",
@@ -409,10 +355,6 @@
}
],
"description": "Modern task runner",
- "support": {
- "issues": "https://github.com/consolidation/Robo/issues",
- "source": "https://github.com/consolidation/Robo/tree/1.4.13"
- },
"time": "2020-10-11T04:51:34+00:00"
},
{
@@ -463,10 +405,6 @@
}
],
"description": "Provides a self:update command for Symfony Console applications.",
- "support": {
- "issues": "https://github.com/consolidation/self-update/issues",
- "source": "https://github.com/consolidation/self-update/tree/1.2.0"
- },
"time": "2020-04-13T02:49:20+00:00"
},
{
@@ -498,10 +436,6 @@
],
"description": "Promoting the interoperability of container objects (DIC, SL, etc.)",
"homepage": "https://github.com/container-interop/container-interop",
- "support": {
- "issues": "https://github.com/container-interop/container-interop/issues",
- "source": "https://github.com/container-interop/container-interop/tree/master"
- },
"abandoned": "psr/container",
"time": "2017-02-14T19:40:03+00:00"
},
@@ -562,10 +496,6 @@
"dot",
"notation"
],
- "support": {
- "issues": "https://github.com/dflydev/dflydev-dot-access-data/issues",
- "source": "https://github.com/dflydev/dflydev-dot-access-data/tree/master"
- },
"time": "2017-01-20T21:14:22+00:00"
},
{
@@ -613,10 +543,6 @@
}
],
"description": "Expands internal property references in PHP arrays file.",
- "support": {
- "issues": "https://github.com/grasmash/expander/issues",
- "source": "https://github.com/grasmash/expander/tree/master"
- },
"time": "2017-12-21T22:14:55+00:00"
},
{
@@ -665,10 +591,6 @@
}
],
"description": "Expands internal property references in a yaml file.",
- "support": {
- "issues": "https://github.com/grasmash/yaml-expander/issues",
- "source": "https://github.com/grasmash/yaml-expander/tree/master"
- },
"time": "2017-12-16T16:06:03+00:00"
},
{
@@ -734,10 +656,6 @@
"provider",
"service"
],
- "support": {
- "issues": "https://github.com/thephpleague/container/issues",
- "source": "https://github.com/thephpleague/container/tree/2.x"
- },
"time": "2017-05-10T09:20:27+00:00"
},
{
@@ -855,10 +773,6 @@
}
],
"description": "More info available on: http://pear.php.net/package/Console_Getopt",
- "support": {
- "issues": "http://pear.php.net/bugs/search.php?cmd=display&package_name[]=Console_Getopt",
- "source": "https://github.com/pear/Console_Getopt"
- },
"time": "2019-11-20T18:27:48+00:00"
},
{
@@ -903,10 +817,6 @@
}
],
"description": "Minimal set of PEAR core files to be used as composer dependency",
- "support": {
- "issues": "http://pear.php.net/bugs/search.php?cmd=display&package_name[]=PEAR",
- "source": "https://github.com/pear/pear-core-minimal"
- },
"time": "2019-11-19T19:00:24+00:00"
},
{
@@ -962,10 +872,6 @@
"keywords": [
"exception"
],
- "support": {
- "issues": "http://pear.php.net/bugs/search.php?cmd=display&package_name[]=PEAR_Exception",
- "source": "https://github.com/pear/PEAR_Exception"
- },
"time": "2019-12-10T10:24:42+00:00"
},
{
@@ -1015,10 +921,6 @@
"container-interop",
"psr"
],
- "support": {
- "issues": "https://github.com/php-fig/container/issues",
- "source": "https://github.com/php-fig/container/tree/master"
- },
"time": "2017-02-14T16:28:37+00:00"
},
{
@@ -1066,23 +968,20 @@
"psr",
"psr-3"
],
- "support": {
- "source": "https://github.com/php-fig/log/tree/1.1.3"
- },
"time": "2020-03-23T09:12:05+00:00"
},
{
"name": "symfony/console",
- "version": "v4.4.16",
+ "version": "v4.4.18",
"source": {
"type": "git",
"url": "https://github.com/symfony/console.git",
- "reference": "20f73dd143a5815d475e0838ff867bce1eebd9d5"
+ "reference": "12e071278e396cc3e1c149857337e9e192deca0b"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/console/zipball/20f73dd143a5815d475e0838ff867bce1eebd9d5",
- "reference": "20f73dd143a5815d475e0838ff867bce1eebd9d5",
+ "url": "https://api.github.com/repos/symfony/console/zipball/12e071278e396cc3e1c149857337e9e192deca0b",
+ "reference": "12e071278e396cc3e1c149857337e9e192deca0b",
"shasum": ""
},
"require": {
@@ -1141,37 +1040,20 @@
],
"description": "Symfony Console Component",
"homepage": "https://symfony.com",
- "support": {
- "source": "https://github.com/symfony/console/tree/v4.4.16"
- },
- "funding": [
- {
- "url": "https://symfony.com/sponsor",
- "type": "custom"
- },
- {
- "url": "https://github.com/fabpot",
- "type": "github"
- },
- {
- "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
- "type": "tidelift"
- }
- ],
- "time": "2020-10-24T11:50:19+00:00"
+ "time": "2020-12-18T07:41:31+00:00"
},
{
"name": "symfony/event-dispatcher",
- "version": "v4.4.16",
+ "version": "v4.4.18",
"source": {
"type": "git",
"url": "https://github.com/symfony/event-dispatcher.git",
- "reference": "4204f13d2d0b7ad09454f221bb2195fccdf1fe98"
+ "reference": "5d4c874b0eb1c32d40328a09dbc37307a5a910b0"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/4204f13d2d0b7ad09454f221bb2195fccdf1fe98",
- "reference": "4204f13d2d0b7ad09454f221bb2195fccdf1fe98",
+ "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/5d4c874b0eb1c32d40328a09dbc37307a5a910b0",
+ "reference": "5d4c874b0eb1c32d40328a09dbc37307a5a910b0",
"shasum": ""
},
"require": {
@@ -1224,24 +1106,7 @@
],
"description": "Symfony EventDispatcher Component",
"homepage": "https://symfony.com",
- "support": {
- "source": "https://github.com/symfony/event-dispatcher/tree/v4.4.16"
- },
- "funding": [
- {
- "url": "https://symfony.com/sponsor",
- "type": "custom"
- },
- {
- "url": "https://github.com/fabpot",
- "type": "github"
- },
- {
- "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
- "type": "tidelift"
- }
- ],
- "time": "2020-10-24T11:50:19+00:00"
+ "time": "2020-12-18T07:41:31+00:00"
},
{
"name": "symfony/event-dispatcher-contracts",
@@ -1303,37 +1168,20 @@
"interoperability",
"standards"
],
- "support": {
- "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v1.1.9"
- },
- "funding": [
- {
- "url": "https://symfony.com/sponsor",
- "type": "custom"
- },
- {
- "url": "https://github.com/fabpot",
- "type": "github"
- },
- {
- "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
- "type": "tidelift"
- }
- ],
"time": "2020-07-06T13:19:58+00:00"
},
{
"name": "symfony/filesystem",
- "version": "v4.4.16",
+ "version": "v4.4.18",
"source": {
"type": "git",
"url": "https://github.com/symfony/filesystem.git",
- "reference": "e74b873395b7213d44d1397bd4a605cd1632a68a"
+ "reference": "d99fbef7e0f69bf162ae6131b31132fa3cc4bcbe"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/filesystem/zipball/e74b873395b7213d44d1397bd4a605cd1632a68a",
- "reference": "e74b873395b7213d44d1397bd4a605cd1632a68a",
+ "url": "https://api.github.com/repos/symfony/filesystem/zipball/d99fbef7e0f69bf162ae6131b31132fa3cc4bcbe",
+ "reference": "d99fbef7e0f69bf162ae6131b31132fa3cc4bcbe",
"shasum": ""
},
"require": {
@@ -1365,37 +1213,20 @@
],
"description": "Symfony Filesystem Component",
"homepage": "https://symfony.com",
- "support": {
- "source": "https://github.com/symfony/filesystem/tree/v4.4.16"
- },
- "funding": [
- {
- "url": "https://symfony.com/sponsor",
- "type": "custom"
- },
- {
- "url": "https://github.com/fabpot",
- "type": "github"
- },
- {
- "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
- "type": "tidelift"
- }
- ],
- "time": "2020-10-24T11:50:19+00:00"
+ "time": "2020-11-30T13:04:35+00:00"
},
{
"name": "symfony/finder",
- "version": "v5.1.8",
+ "version": "v5.2.1",
"source": {
"type": "git",
"url": "https://github.com/symfony/finder.git",
- "reference": "e70eb5a69c2ff61ea135a13d2266e8914a67b3a0"
+ "reference": "0b9231a5922fd7287ba5b411893c0ecd2733e5ba"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/finder/zipball/e70eb5a69c2ff61ea135a13d2266e8914a67b3a0",
- "reference": "e70eb5a69c2ff61ea135a13d2266e8914a67b3a0",
+ "url": "https://api.github.com/repos/symfony/finder/zipball/0b9231a5922fd7287ba5b411893c0ecd2733e5ba",
+ "reference": "0b9231a5922fd7287ba5b411893c0ecd2733e5ba",
"shasum": ""
},
"require": {
@@ -1426,24 +1257,7 @@
],
"description": "Symfony Finder Component",
"homepage": "https://symfony.com",
- "support": {
- "source": "https://github.com/symfony/finder/tree/v5.1.8"
- },
- "funding": [
- {
- "url": "https://symfony.com/sponsor",
- "type": "custom"
- },
- {
- "url": "https://github.com/fabpot",
- "type": "github"
- },
- {
- "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
- "type": "tidelift"
- }
- ],
- "time": "2020-10-24T12:01:57+00:00"
+ "time": "2020-12-08T17:02:38+00:00"
},
{
"name": "symfony/polyfill-ctype",
@@ -1505,23 +1319,6 @@
"polyfill",
"portable"
],
- "support": {
- "source": "https://github.com/symfony/polyfill-ctype/tree/v1.20.0"
- },
- "funding": [
- {
- "url": "https://symfony.com/sponsor",
- "type": "custom"
- },
- {
- "url": "https://github.com/fabpot",
- "type": "github"
- },
- {
- "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
- "type": "tidelift"
- }
- ],
"time": "2020-10-23T14:02:19+00:00"
},
{
@@ -1585,23 +1382,6 @@
"portable",
"shim"
],
- "support": {
- "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.20.0"
- },
- "funding": [
- {
- "url": "https://symfony.com/sponsor",
- "type": "custom"
- },
- {
- "url": "https://github.com/fabpot",
- "type": "github"
- },
- {
- "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
- "type": "tidelift"
- }
- ],
"time": "2020-10-23T14:02:19+00:00"
},
{
@@ -1664,23 +1444,6 @@
"portable",
"shim"
],
- "support": {
- "source": "https://github.com/symfony/polyfill-php73/tree/v1.20.0"
- },
- "funding": [
- {
- "url": "https://symfony.com/sponsor",
- "type": "custom"
- },
- {
- "url": "https://github.com/fabpot",
- "type": "github"
- },
- {
- "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
- "type": "tidelift"
- }
- ],
"time": "2020-10-23T14:02:19+00:00"
},
{
@@ -1747,23 +1510,6 @@
"portable",
"shim"
],
- "support": {
- "source": "https://github.com/symfony/polyfill-php80/tree/v1.20.0"
- },
- "funding": [
- {
- "url": "https://symfony.com/sponsor",
- "type": "custom"
- },
- {
- "url": "https://github.com/fabpot",
- "type": "github"
- },
- {
- "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
- "type": "tidelift"
- }
- ],
"time": "2020-10-23T14:02:19+00:00"
},
{
@@ -1811,20 +1557,6 @@
"support": {
"source": "https://github.com/symfony/process/tree/v3.4.47"
},
- "funding": [
- {
- "url": "https://symfony.com/sponsor",
- "type": "custom"
- },
- {
- "url": "https://github.com/fabpot",
- "type": "github"
- },
- {
- "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
- "type": "tidelift"
- }
- ],
"time": "2020-10-24T10:57:07+00:00"
},
{
@@ -1887,37 +1619,20 @@
"interoperability",
"standards"
],
- "support": {
- "source": "https://github.com/symfony/service-contracts/tree/master"
- },
- "funding": [
- {
- "url": "https://symfony.com/sponsor",
- "type": "custom"
- },
- {
- "url": "https://github.com/fabpot",
- "type": "github"
- },
- {
- "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
- "type": "tidelift"
- }
- ],
"time": "2020-09-07T11:33:47+00:00"
},
{
"name": "symfony/yaml",
- "version": "v4.4.16",
+ "version": "v4.4.18",
"source": {
"type": "git",
"url": "https://github.com/symfony/yaml.git",
- "reference": "543cb4dbd45ed803f08a9a65f27fb149b5dd20c2"
+ "reference": "bbce94f14d73732340740366fcbe63363663a403"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/yaml/zipball/543cb4dbd45ed803f08a9a65f27fb149b5dd20c2",
- "reference": "543cb4dbd45ed803f08a9a65f27fb149b5dd20c2",
+ "url": "https://api.github.com/repos/symfony/yaml/zipball/bbce94f14d73732340740366fcbe63363663a403",
+ "reference": "bbce94f14d73732340740366fcbe63363663a403",
"shasum": ""
},
"require": {
@@ -1958,24 +1673,7 @@
],
"description": "Symfony Yaml Component",
"homepage": "https://symfony.com",
- "support": {
- "source": "https://github.com/symfony/yaml/tree/v4.4.16"
- },
- "funding": [
- {
- "url": "https://symfony.com/sponsor",
- "type": "custom"
- },
- {
- "url": "https://github.com/fabpot",
- "type": "github"
- },
- {
- "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
- "type": "tidelift"
- }
- ],
- "time": "2020-10-24T11:50:19+00:00"
+ "time": "2020-12-08T16:59:59+00:00"
}
],
"aliases": [],
@@ -1984,6 +1682,5 @@
"prefer-stable": false,
"prefer-lowest": false,
"platform": [],
- "platform-dev": [],
- "plugin-api-version": "2.0.0"
+ "platform-dev": []
}
From ee0c3c9449e8d9ad489e849f871188d9fa4779de Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Wed, 30 Dec 2020 17:01:17 -0500
Subject: [PATCH 093/366] Tests and fixes for user modification
---
lib/REST/Miniflux/V1.php | 45 +++++++-------
locale/en.php | 3 -
tests/cases/REST/Miniflux/TestV1.php | 90 ++++++++++++++++++++++++++--
3 files changed, 110 insertions(+), 28 deletions(-)
diff --git a/lib/REST/Miniflux/V1.php b/lib/REST/Miniflux/V1.php
index a532304a..08b69799 100644
--- a/lib/REST/Miniflux/V1.php
+++ b/lib/REST/Miniflux/V1.php
@@ -268,10 +268,12 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
$t = gettype($d);
if (!isset($body[$k])) {
$body[$k] = null;
+ } elseif ($k === "entry_sorting_direction") {
+ if (!in_array($body[$k], ["asc", "desc"])) {
+ return new ErrorResponse(["InvalidInputValue", 'field' => $k], 422);
+ }
} elseif (gettype($body[$k]) !== $t) {
return new ErrorResponse(["InvalidInputType", 'field' => $k, 'expected' => $t, 'actual' => gettype($body[$k])], 422);
- } elseif ($k === "entry_sorting_direction" && !in_array($body[$k], ["asc", "desc"])) {
- return new ErrorResponse(["InvalidInputValue", 'field' => $k], 422);
}
}
return $body;
@@ -376,22 +378,23 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
return new Response($this->listUsers([Arsse::$user->id], false)[0] ?? new \stdClass);
}
- protected function updateUserByNum(array $data, array $path): ResponseInterface {
- try {
- if (!$this->isAdmin()) {
- // this function is restricted to admins unless the affected user and calling user are the same
- if (Arsse::$db->userLookup((int) $path[1]) !== Arsse::$user->id) {
- return new ErrorResponse("403", 403);
- } elseif ($data['is_admin']) {
- // non-admins should not be able to set themselves as admin
- return new ErrorResponse("InvalidElevation");
- }
- $user = Arsse::$user->id;
- } else {
- $user = Arsse::$db->userLookup((int) $path[1]);
+ protected function updateUserByNum(array $path, array $data): ResponseInterface {
+ // this function is restricted to admins unless the affected user and calling user are the same
+ $user = Arsse::$user->propertiesGet(Arsse::$user->id, false);
+ if (((int) $path[1]) === $user['num']) {
+ if ($data['is_admin'] && !$user['admin']) {
+ // non-admins should not be able to set themselves as admin
+ return new ErrorResponse("InvalidElevation", 403);
+ }
+ $user = Arsse::$user->id;
+ } elseif (!$user['admin']) {
+ return new ErrorResponse("403", 403);
+ } else {
+ try {
+ $user = Arsse::$user->lookup((int) $path[1]);
+ } catch (ExceptionConflict $e) {
+ return new ErrorResponse("404", 404);
}
- } catch (ExceptionConflict $e) {
- return new ErrorResponse("404", 404);
}
// map Miniflux properties to internal metadata properties
$in = [];
@@ -424,12 +427,12 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
switch ($e->getCode()) {
case 10403:
return new ErrorResponse(["DuplicateUser", 'user' => $data['username']], 409);
- case 20441:
- return new ErrorResponse(["InvalidTimeone", 'tz' => $data['timezone']], 422);
+ case 10441:
+ return new ErrorResponse(["InvalidInputValue", 'field' => "timezone"], 422);
case 10443:
- return new ErrorResponse("InvalidPageSize", 422);
+ return new ErrorResponse(["InvalidInputValue", 'field' => "entries_per_page"], 422);
case 10444:
- return new ErrorResponse(["InvalidUsername", $e->getMessage()], 422);
+ return new ErrorResponse(["InvalidInputValue", 'field' => "username"], 422);
}
throw $e; // @codeCoverageIgnore
}
diff --git a/locale/en.php b/locale/en.php
index b0cfe82d..6944023c 100644
--- a/locale/en.php
+++ b/locale/en.php
@@ -22,9 +22,6 @@ return [
'API.Miniflux.Error.InvalidCategory' => 'Invalid category title "{title}"',
'API.Miniflux.Error.InvalidElevation' => 'Only administrators can change permissions of standard users',
'API.Miniflux.Error.DuplicateUser' => 'The user name "{user}" already exists',
- 'API.Miniflux.Error.InvalidUser' => '{0}',
- 'API.Miniflux.Error.InvalidTimezone' => 'Specified time zone "{tz}" is invalid',
- 'API.Miniflux.Error.InvalidPageSize' => 'Page size must be greater than zero',
'API.TTRSS.Category.Uncategorized' => 'Uncategorized',
'API.TTRSS.Category.Special' => 'Special',
diff --git a/tests/cases/REST/Miniflux/TestV1.php b/tests/cases/REST/Miniflux/TestV1.php
index 0b9f68d3..8495d62d 100644
--- a/tests/cases/REST/Miniflux/TestV1.php
+++ b/tests/cases/REST/Miniflux/TestV1.php
@@ -16,7 +16,7 @@ use JKingWeb\Arsse\Misc\Date;
use JKingWeb\Arsse\REST\Miniflux\V1;
use JKingWeb\Arsse\REST\Miniflux\ErrorResponse;
use JKingWeb\Arsse\User\ExceptionConflict;
-use JKingWeb\Arsse\User\Exception;
+use JKingWeb\Arsse\User\ExceptionInput as UserExceptionInput;
use Psr\Http\Message\ResponseInterface;
use Laminas\Diactoros\Response\JsonResponse as Response;
use Laminas\Diactoros\Response\EmptyResponse;
@@ -82,13 +82,14 @@ class TestV1 extends \JKingWeb\Arsse\Test\AbstractTest {
public function setUp(): void {
self::clearData();
self::setConf();
- // create a mock user manager; we use a PHPUnitmock because Phake for reasons unknown is unable to mock the User class correctly, sometimes
- Arsse::$user = $this->createMock(User::class);
- Arsse::$user->method("propertiesGet")->willReturn(['num' => 42, 'admin' => false, 'root_folder_name' => null]);
// create a mock database interface
Arsse::$db = \Phake::mock(Database::class);
$this->transaction = \Phake::mock(Transaction::class);
\Phake::when(Arsse::$db)->begin->thenReturn($this->transaction);
+ // create a mock user manager; we use a PHPUnitmock because Phake for reasons unknown is unable to mock the User class correctly, sometimes
+ Arsse::$user = $this->createMock(User::class);
+ Arsse::$user->method("propertiesGet")->willReturn(['num' => 42, 'admin' => false, 'root_folder_name' => null]);
+ Arsse::$user->method("begin")->willReturn($this->transaction);
//initialize a handler
$this->h = new V1();
}
@@ -239,6 +240,87 @@ class TestV1 extends \JKingWeb\Arsse\Test\AbstractTest {
];
}
+ /** @dataProvider provideUserModifications */
+ public function testModifyAUser(bool $admin, string $url, array $body, $in1, $out1, $in2, $out2, $in3, $out3, ResponseInterface $exp): void {
+ $this->h = $this->createPartialMock(V1::class, ["now"]);
+ $this->h->method("now")->willReturn(Date::normalize(self::NOW));
+ Arsse::$user = $this->createMock(User::class);
+ Arsse::$user->method("begin")->willReturn($this->transaction);
+ Arsse::$user->method("propertiesGet")->willReturnCallback(function(string $u, bool $includeLarge) use ($admin) {
+ if ($u === "john.doe@example.com" || $u === "ook") {
+ return ['num' => 2, 'admin' => $admin];
+ } else {
+ return ['num' => 1, 'admin' => true];
+ }
+ });
+ Arsse::$user->method("lookup")->willReturnCallback(function(int $u) {
+ if ($u === 1) {
+ return "jane.doe@example.com";
+ } elseif ($u === 2) {
+ return "john.doe@example.com";
+ } else {
+ throw new ExceptionConflict("doesNotExist");
+ }
+ });
+ if ($out1 instanceof \Exception) {
+ Arsse::$user->method("rename")->willThrowException($out1);
+ } else {
+ Arsse::$user->method("rename")->willReturn($out1 ?? false);
+ }
+ if ($out2 instanceof \Exception) {
+ Arsse::$user->method("passwordSet")->willThrowException($out2);
+ } else {
+ Arsse::$user->method("passwordSet")->willReturn($out2 ?? "");
+ }
+ if ($out3 instanceof \Exception) {
+ Arsse::$user->method("propertiesSet")->willThrowException($out3);
+ } else {
+ Arsse::$user->method("propertiesSet")->willReturn($out3 ?? []);
+ }
+ $user = $url === "/users/1" ? "jane.doe@example.com" : "john.doe@example.com";
+ if ($in1 === null) {
+ Arsse::$user->expects($this->exactly(0))->method("rename");
+ } else {
+ Arsse::$user->expects($this->exactly(1))->method("rename")->with($user, $in1);
+ $user = $in1;
+ }
+ if ($in2 === null) {
+ Arsse::$user->expects($this->exactly(0))->method("passwordSet");
+ } else {
+ Arsse::$user->expects($this->exactly(1))->method("passwordSet")->with($user, $in2);
+ }
+ if ($in3 === null) {
+ Arsse::$user->expects($this->exactly(0))->method("propertiesSet");
+ } else {
+ Arsse::$user->expects($this->exactly(1))->method("propertiesSet")->with($user, $in3);
+ }
+ $this->assertMessage($exp, $this->req("PUT", $url, $body));
+ }
+
+ public function provideUserModifications(): iterable {
+ $out1 = ['num' => 2, 'admin' => false];
+ $out2 = ['num' => 1, 'admin' => false];
+ $resp1 = array_merge($this->users[1], ['username' => "john.doe@example.com"]);
+ $resp2 = array_merge($this->users[1], ['id' => 1, 'is_admin' => true]);
+ return [
+ [false, "/users/1", ['is_admin' => 0], null, null, null, null, null, null, new ErrorResponse(["InvalidInputType", 'field' => "is_admin", 'expected' => "boolean", 'actual' => "integer"], 422)],
+ [false, "/users/1", ['entry_sorting_direction' => "bad"], null, null, null, null, null, null, new ErrorResponse(["InvalidInputValue", 'field' => "entry_sorting_direction"], 422)],
+ [false, "/users/1", ['theme' => "stark"], null, null, null, null, null, null, new ErrorResponse("403", 403)],
+ [false, "/users/2", ['is_admin' => true], null, null, null, null, null, null, new ErrorResponse("InvalidElevation", 403)],
+ [false, "/users/2", ['language' => "fr_CA"], null, null, null, null, ['lang' => "fr_CA"], $out1, new Response($resp1)],
+ [false, "/users/2", ['entry_sorting_direction' => "asc"], null, null, null, null, ['sort_asc' => true], $out1, new Response($resp1)],
+ [false, "/users/2", ['entry_sorting_direction' => "desc"], null, null, null, null, ['sort_asc' => false], $out1, new Response($resp1)],
+ [false, "/users/2", ['entries_per_page' => -1], null, null, null, null, ['page_size' => -1], new UserExceptionInput("invalidNonZeroInteger"), new ErrorResponse(["InvalidInputValue", 'field' => "entries_per_page"], 422)],
+ [false, "/users/2", ['timezone' => "Ook"], null, null, null, null, ['tz' => "Ook"], new UserExceptionInput("invalidTimezone"), new ErrorResponse(["InvalidInputValue", 'field' => "timezone"], 422)],
+ [false, "/users/2", ['username' => "j:k"], "j:k", new UserExceptionInput("invalidUsername"), null, null, null, null, new ErrorResponse(["InvalidInputValue", 'field' => "username"], 422)],
+ [false, "/users/2", ['username' => "ook"], "ook", new ExceptionConflict("alreadyExists"), null, null, null, null, new ErrorResponse(["DuplicateUser", 'user' => "ook"], 409)],
+ [false, "/users/2", ['password' => "ook"], null, null, "ook", "ook", null, null, new Response(array_merge($resp1, ['password' => "ook"]))],
+ [false, "/users/2", ['username' => "ook", 'password' => "ook"], "ook", true, "ook", "ook", null, null, new Response(array_merge($resp1, ['username' => "ook", 'password' => "ook"]))],
+ [true, "/users/1", ['theme' => "stark"], null, null, null, null, ['theme' => "stark"], $out2, new Response($resp2)],
+ [true, "/users/3", ['theme' => "stark"], null, null, null, null, null, null, new ErrorResponse("404", 404)],
+ ];
+ }
+
public function testListCategories(): void {
\Phake::when(Arsse::$db)->folderList->thenReturn(new Result($this->v([
['id' => 1, 'name' => "Science"],
From 197922f92fe7f718283f8818874fa08ed4d7600d Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Thu, 31 Dec 2020 13:57:36 -0500
Subject: [PATCH 094/366] Implement Miniflux user creation
---
lib/REST/Miniflux/V1.php | 75 ++++++++++++++++++++--------
locale/en.php | 1 +
tests/cases/REST/Miniflux/TestV1.php | 55 ++++++++++++++++++++
3 files changed, 111 insertions(+), 20 deletions(-)
diff --git a/lib/REST/Miniflux/V1.php b/lib/REST/Miniflux/V1.php
index 08b69799..c020b8a3 100644
--- a/lib/REST/Miniflux/V1.php
+++ b/lib/REST/Miniflux/V1.php
@@ -333,6 +333,33 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
return $out;
}
+ protected function editUser(string $user, array $data): array {
+ // map Miniflux properties to internal metadata properties
+ $in = [];
+ foreach (self::USER_META_MAP as $i => [$o,,]) {
+ if (isset($data[$i])) {
+ if ($i === "entry_sorting_direction") {
+ $in[$o] = $data[$i] === "asc";
+ } else {
+ $in[$o] = $data[$i];
+ }
+ }
+ }
+ // make any requested changes
+ $tr = Arsse::$user->begin();
+ if ($in) {
+ Arsse::$user->propertiesSet($user, $in);
+ }
+ // read out the newly-modified user and commit the changes
+ $out = $this->listUsers([$user], true)[0];
+ $tr->commit();
+ // add the input password if a password change was requested
+ if (isset($data['password'])) {
+ $out['password'] = $data['password'];
+ }
+ return $out;
+ }
+
protected function discoverSubscriptions(array $data): ResponseInterface {
try {
$list = Feed::discoverAll((string) $data['url'], (string) $data['username'], (string) $data['password']);
@@ -378,6 +405,33 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
return new Response($this->listUsers([Arsse::$user->id], false)[0] ?? new \stdClass);
}
+ protected function createUser(array $data): ResponseInterface {
+ if ($data['username'] === null) {
+ return new ErrorResponse(["MissingInputValue", 'field' => "username"], 422);
+ } elseif ($data['password'] === null) {
+ return new ErrorResponse(["MissingInputValue", 'field' => "password"], 422);
+ }
+ try {
+ $tr = Arsse::$user->begin();
+ $data['password'] = Arsse::$user->add($data['username'], $data['password']);
+ $out = $this->editUser($data['username'], $data);
+ $tr->commit();
+ } catch (UserException $e) {
+ switch ($e->getCode()) {
+ case 10403:
+ return new ErrorResponse(["DuplicateUser", 'user' => $data['username']], 409);
+ case 10441:
+ return new ErrorResponse(["InvalidInputValue", 'field' => "timezone"], 422);
+ case 10443:
+ return new ErrorResponse(["InvalidInputValue", 'field' => "entries_per_page"], 422);
+ case 10444:
+ return new ErrorResponse(["InvalidInputValue", 'field' => "username"], 422);
+ }
+ throw $e; // @codeCoverageIgnore
+ }
+ return new Response($out, 201);
+ }
+
protected function updateUserByNum(array $path, array $data): ResponseInterface {
// this function is restricted to admins unless the affected user and calling user are the same
$user = Arsse::$user->propertiesGet(Arsse::$user->id, false);
@@ -396,17 +450,6 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
return new ErrorResponse("404", 404);
}
}
- // map Miniflux properties to internal metadata properties
- $in = [];
- foreach (self::USER_META_MAP as $i => [$o,,]) {
- if (isset($data[$i])) {
- if ($i === "entry_sorting_direction") {
- $in[$o] = $data[$i] === "asc";
- } else {
- $in[$o] = $data[$i];
- }
- }
- }
// make any requested changes
try {
$tr = Arsse::$user->begin();
@@ -417,11 +460,7 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
if (isset($data['password'])) {
Arsse::$user->passwordSet($user, $data['password']);
}
- if ($in) {
- Arsse::$user->propertiesSet($user, $in);
- }
- // read out the newly-modified user and commit the changes
- $out = $this->listUsers([$user], true)[0];
+ $out = $this->editUser($user, $data);
$tr->commit();
} catch (UserException $e) {
switch ($e->getCode()) {
@@ -436,10 +475,6 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
}
throw $e; // @codeCoverageIgnore
}
- // add the input password if a password change was requested
- if (isset($data['password'])) {
- $out['password'] = $data['password'];
- }
return new Response($out);
}
diff --git a/locale/en.php b/locale/en.php
index 6944023c..d67547aa 100644
--- a/locale/en.php
+++ b/locale/en.php
@@ -11,6 +11,7 @@ return [
'API.Miniflux.Error.401' => 'Access Unauthorized',
'API.Miniflux.Error.403' => 'Access Forbidden',
'API.Miniflux.Error.404' => 'Resource Not Found',
+ 'API.Miniflux.Error.MissingInputValue' => 'Required key "{field}" was not present in input',
'API.Miniflux.Error.InvalidBodyJSON' => 'Invalid JSON payload: {0}',
'API.Miniflux.Error.InvalidInputType' => 'Input key "{field}" of type {actual} was expected as {expected}',
'API.Miniflux.Error.InvalidInputValue' => 'Supplied value is not valid for input key "{field}"',
diff --git a/tests/cases/REST/Miniflux/TestV1.php b/tests/cases/REST/Miniflux/TestV1.php
index 8495d62d..ce962727 100644
--- a/tests/cases/REST/Miniflux/TestV1.php
+++ b/tests/cases/REST/Miniflux/TestV1.php
@@ -321,6 +321,61 @@ class TestV1 extends \JKingWeb\Arsse\Test\AbstractTest {
];
}
+ /** @dataProvider provideUserAdditions */
+ public function testAddAUser(array $body, $in1, $out1, $in2, $out2, ResponseInterface $exp): void {
+ $this->h = $this->createPartialMock(V1::class, ["now"]);
+ $this->h->method("now")->willReturn(Date::normalize(self::NOW));
+ Arsse::$user = $this->createMock(User::class);
+ Arsse::$user->method("begin")->willReturn($this->transaction);
+ Arsse::$user->method("propertiesGet")->willReturnCallback(function(string $u, bool $includeLarge) {
+ if ($u === "john.doe@example.com") {
+ return ['num' => 1, 'admin' => true];
+ } else {
+ return ['num' => 2, 'admin' => false];
+ }
+ });
+ if ($out1 instanceof \Exception) {
+ Arsse::$user->method("add")->willThrowException($out1);
+ } else {
+ Arsse::$user->method("add")->willReturn($in1[1] ?? "");
+ }
+ if ($out2 instanceof \Exception) {
+ Arsse::$user->method("propertiesSet")->willThrowException($out2);
+ } else {
+ Arsse::$user->method("propertiesSet")->willReturn($out2 ?? []);
+ }
+ if ($in1 === null) {
+ Arsse::$user->expects($this->exactly(0))->method("add");
+ } else {
+ Arsse::$user->expects($this->exactly(1))->method("add")->with(...($in1 ?? []));
+ }
+ if ($in2 === null) {
+ Arsse::$user->expects($this->exactly(0))->method("propertiesSet");
+ } else {
+ Arsse::$user->expects($this->exactly(1))->method("propertiesSet")->with($body['username'], $in2);
+ }
+ $this->assertMessage($exp, $this->req("POST", "/users", $body));
+ }
+
+ public function provideUserAdditions(): iterable {
+ $resp1 = array_merge($this->users[1], ['username' => "ook", 'password' => "eek"]);
+ return [
+ [[], null, null, null, null, new ErrorResponse(["MissingInputValue", 'field' => "username"], 422)],
+ [['username' => "ook"], null, null, null, null, new ErrorResponse(["MissingInputValue", 'field' => "password"], 422)],
+ [['username' => "ook", 'password' => "eek"], ["ook", "eek"], new ExceptionConflict("alreadyExists"), null, null, new ErrorResponse(["DuplicateUser", 'user' => "ook"], 409)],
+ [['username' => "j:k", 'password' => "eek"], ["j:k", "eek"], new UserExceptionInput("invalidUsername"), null, null, new ErrorResponse(["InvalidInputValue", 'field' => "username"], 422)],
+ [['username' => "ook", 'password' => "eek", 'timezone' => "ook"], ["ook", "eek"], "eek", ['tz' => "ook"], new UserExceptionInput("invalidTimezone"), new ErrorResponse(["InvalidInputValue", 'field' => "timezone"], 422)],
+ [['username' => "ook", 'password' => "eek", 'entries_per_page' => -1], ["ook", "eek"], "eek", ['page_size' => -1], new UserExceptionInput("invalidNonZeroInteger"), new ErrorResponse(["InvalidInputValue", 'field' => "entries_per_page"], 422)],
+ [['username' => "ook", 'password' => "eek", 'theme' => "default"], ["ook", "eek"], "eek", ['theme' => "default"], ['theme' => "default"], new Response($resp1, 201)],
+ ];
+ }
+
+ public function testAddAUserWithoutAuthority(): void {
+ Arsse::$user = $this->createMock(User::class);
+ Arsse::$user->method("propertiesGet")->willReturn(['num' => 1, 'admin' => false]);
+ $this->assertMessage(new ErrorResponse("403", 403), $this->req("POST", "/users", []));
+ }
+
public function testListCategories(): void {
\Phake::when(Arsse::$db)->folderList->thenReturn(new Result($this->v([
['id' => 1, 'name' => "Science"],
From bf95b134bd020a9e8043848a1aa0b94586ae28a3 Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Thu, 31 Dec 2020 15:46:47 -0500
Subject: [PATCH 095/366] Fix up error codes for category changes
---
lib/REST/Miniflux/V1.php | 90 ++++++++++++++--------------
tests/cases/REST/Miniflux/TestV1.php | 21 ++++---
2 files changed, 58 insertions(+), 53 deletions(-)
diff --git a/lib/REST/Miniflux/V1.php b/lib/REST/Miniflux/V1.php
index c020b8a3..94cdab0e 100644
--- a/lib/REST/Miniflux/V1.php
+++ b/lib/REST/Miniflux/V1.php
@@ -50,81 +50,81 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
'entry_swipe' => ["swipe", true, false],
'custom_css' => ["stylesheet", "", true],
];
- protected const CALLS = [ // handler method Admin Path Body Query
+ protected const CALLS = [ // handler method Admin Path Body Query Required fields
'/categories' => [
- 'GET' => ["getCategories", false, false, false, false],
- 'POST' => ["createCategory", false, false, true, false],
+ 'GET' => ["getCategories", false, false, false, false, []],
+ 'POST' => ["createCategory", false, false, true, false, ["title"]],
],
'/categories/1' => [
- 'PUT' => ["updateCategory", false, true, true, false],
- 'DELETE' => ["deleteCategory", false, true, false, false],
+ 'PUT' => ["updateCategory", false, true, true, false, ["title"]], // title is effectively required since no other field can be changed
+ 'DELETE' => ["deleteCategory", false, true, false, false, []],
],
'/categories/1/mark-all-as-read' => [
- 'PUT' => ["markCategory", false, true, false, false],
+ 'PUT' => ["markCategory", false, true, false, false, []],
],
'/discover' => [
- 'POST' => ["discoverSubscriptions", false, false, true, false],
+ 'POST' => ["discoverSubscriptions", false, false, true, false, ["url"]],
],
'/entries' => [
- 'GET' => ["getEntries", false, false, false, true],
- 'PUT' => ["updateEntries", false, false, true, false],
+ 'GET' => ["getEntries", false, false, false, true, []],
+ 'PUT' => ["updateEntries", false, false, true, false, []],
],
'/entries/1' => [
- 'GET' => ["getEntry", false, true, false, false],
+ 'GET' => ["getEntry", false, true, false, false, []],
],
'/entries/1/bookmark' => [
- 'PUT' => ["toggleEntryBookmark", false, true, false, false],
+ 'PUT' => ["toggleEntryBookmark", false, true, false, false, []],
],
'/export' => [
- 'GET' => ["opmlExport", false, false, false, false],
+ 'GET' => ["opmlExport", false, false, false, false, []],
],
'/feeds' => [
- 'GET' => ["getFeeds", false, false, false, false],
- 'POST' => ["createFeed", false, false, true, false],
+ 'GET' => ["getFeeds", false, false, false, false, []],
+ 'POST' => ["createFeed", false, false, true, false, []],
],
'/feeds/1' => [
- 'GET' => ["getFeed", false, true, false, false],
- 'PUT' => ["updateFeed", false, true, true, false],
- 'DELETE' => ["deleteFeed", false, true, false, false],
+ 'GET' => ["getFeed", false, true, false, false, []],
+ 'PUT' => ["updateFeed", false, true, true, false, []],
+ 'DELETE' => ["deleteFeed", false, true, false, false, []],
],
'/feeds/1/entries' => [
- 'GET' => ["getFeedEntries", false, true, false, false],
+ 'GET' => ["getFeedEntries", false, true, false, false, []],
],
'/feeds/1/entries/1' => [
- 'GET' => ["getFeedEntry", false, true, false, false],
+ 'GET' => ["getFeedEntry", false, true, false, false, []],
],
'/feeds/1/icon' => [
- 'GET' => ["getFeedIcon", false, true, false, false],
+ 'GET' => ["getFeedIcon", false, true, false, false, []],
],
'/feeds/1/mark-all-as-read' => [
- 'PUT' => ["markFeed", false, true, false, false],
+ 'PUT' => ["markFeed", false, true, false, false, []],
],
'/feeds/1/refresh' => [
- 'PUT' => ["refreshFeed", false, true, false, false],
+ 'PUT' => ["refreshFeed", false, true, false, false, []],
],
'/feeds/refresh' => [
- 'PUT' => ["refreshAllFeeds", false, false, false, false],
+ 'PUT' => ["refreshAllFeeds", false, false, false, false, []],
],
'/import' => [
- 'POST' => ["opmlImport", false, false, true, false],
+ 'POST' => ["opmlImport", false, false, true, false, []],
],
'/me' => [
- 'GET' => ["getCurrentUser", false, false, false, false],
+ 'GET' => ["getCurrentUser", false, false, false, false, []],
],
'/users' => [
- 'GET' => ["getUsers", true, false, false, false],
- 'POST' => ["createUser", true, false, true, false],
+ 'GET' => ["getUsers", true, false, false, false, []],
+ 'POST' => ["createUser", true, false, true, false, ["username", "password"]],
],
'/users/1' => [
- 'GET' => ["getUserByNum", true, true, false, false],
- 'PUT' => ["updateUserByNum", false, true, true, false], // requires admin for users other than self
- 'DELETE' => ["deleteUserByNum", true, true, false, false],
+ 'GET' => ["getUserByNum", true, true, false, false, []],
+ 'PUT' => ["updateUserByNum", false, true, true, false, []], // requires admin for users other than self
+ 'DELETE' => ["deleteUserByNum", true, true, false, false, []],
],
'/users/1/mark-all-as-read' => [
- 'PUT' => ["markUserByNum", false, true, false, false],
+ 'PUT' => ["markUserByNum", false, true, false, false, []],
],
'/users/*' => [
- 'GET' => ["getUserById", true, true, false, false],
+ 'GET' => ["getUserById", true, true, false, false, []],
],
];
@@ -169,7 +169,7 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
if ($func instanceof ResponseInterface) {
return $func;
} else {
- [$func, $reqAdmin, $reqPath, $reqBody, $reqQuery] = $func;
+ [$func, $reqAdmin, $reqPath, $reqBody, $reqQuery, $reqFields] = $func;
}
if ($reqAdmin && !$this->isAdmin()) {
return new ErrorResponse("403", 403);
@@ -195,7 +195,7 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
} else {
$data = [];
}
- $data = $this->normalizeBody((array) $data);
+ $data = $this->normalizeBody((array) $data, $reqFields);
if ($data instanceof ResponseInterface) {
return $data;
}
@@ -255,7 +255,7 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
return implode("/", $path);
}
- protected function normalizeBody(array $body) {
+ protected function normalizeBody(array $body, array $req) {
// Miniflux does not attempt to coerce values into different types
foreach (self::VALID_JSON as $k => $t) {
if (!isset($body[$k])) {
@@ -264,6 +264,7 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
return new ErrorResponse(["InvalidInputType", 'field' => $k, 'expected' => $t, 'actual' => gettype($body[$k])], 422);
}
}
+ //normalize user-specific input
foreach (self::USER_META_MAP as $k => [,$d,]) {
$t = gettype($d);
if (!isset($body[$k])) {
@@ -276,6 +277,12 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
return new ErrorResponse(["InvalidInputType", 'field' => $k, 'expected' => $t, 'actual' => gettype($body[$k])], 422);
}
}
+ // check for any missing required values
+ foreach ($req as $k) {
+ if (!isset($body[$k])) {
+ return new ErrorResponse(["MissingInputValue", 'field' => $k], 422);
+ }
+ }
return $body;
}
@@ -406,11 +413,6 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
}
protected function createUser(array $data): ResponseInterface {
- if ($data['username'] === null) {
- return new ErrorResponse(["MissingInputValue", 'field' => "username"], 422);
- } elseif ($data['password'] === null) {
- return new ErrorResponse(["MissingInputValue", 'field' => "password"], 422);
- }
try {
$tr = Arsse::$user->begin();
$data['password'] = Arsse::$user->add($data['username'], $data['password']);
@@ -496,9 +498,9 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
$id = Arsse::$db->folderAdd(Arsse::$user->id, ['name' => (string) $data['title']]);
} catch (ExceptionInput $e) {
if ($e->getCode() === 10236) {
- return new ErrorResponse(["DuplicateCategory", 'title' => $data['title']], 500);
+ return new ErrorResponse(["DuplicateCategory", 'title' => $data['title']], 409);
} else {
- return new ErrorResponse(["InvalidCategory", 'title' => $data['title']], 500);
+ return new ErrorResponse(["InvalidCategory", 'title' => $data['title']], 422);
}
}
$meta = Arsse::$user->propertiesGet(Arsse::$user->id, false);
@@ -521,11 +523,11 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
}
} catch (ExceptionInput $e) {
if ($e->getCode() === 10236) {
- return new ErrorResponse(["DuplicateCategory", 'title' => $title], 500);
+ return new ErrorResponse(["DuplicateCategory", 'title' => $title], 409);
} elseif (in_array($e->getCode(), [10237, 10239])) {
return new ErrorResponse("404", 404);
} else {
- return new ErrorResponse(["InvalidCategory", 'title' => $title], 500);
+ return new ErrorResponse(["InvalidCategory", 'title' => $title], 422);
}
}
$meta = Arsse::$user->propertiesGet(Arsse::$user->id, false);
diff --git a/tests/cases/REST/Miniflux/TestV1.php b/tests/cases/REST/Miniflux/TestV1.php
index ce962727..94fad0d8 100644
--- a/tests/cases/REST/Miniflux/TestV1.php
+++ b/tests/cases/REST/Miniflux/TestV1.php
@@ -416,9 +416,10 @@ class TestV1 extends \JKingWeb\Arsse\Test\AbstractTest {
public function provideCategoryAdditions(): iterable {
return [
["New", new Response(['id' => 2112, 'title' => "New", 'user_id' => 42], 201)],
- ["Duplicate", new ErrorResponse(["DuplicateCategory", 'title' => "Duplicate"], 500)],
- ["", new ErrorResponse(["InvalidCategory", 'title' => ""], 500)],
- [" ", new ErrorResponse(["InvalidCategory", 'title' => " "], 500)],
+ ["Duplicate", new ErrorResponse(["DuplicateCategory", 'title' => "Duplicate"], 409)],
+ ["", new ErrorResponse(["InvalidCategory", 'title' => ""], 422)],
+ [" ", new ErrorResponse(["InvalidCategory", 'title' => " "], 422)],
+ [null, new ErrorResponse(["MissingInputValue", 'field' => "title"], 422)],
[false, new ErrorResponse(["InvalidInputType", 'field' => "title", 'actual' => "boolean", 'expected' => "string"],422)],
];
}
@@ -442,14 +443,16 @@ class TestV1 extends \JKingWeb\Arsse\Test\AbstractTest {
return [
[3, "New", "subjectMissing", new ErrorResponse("404", 404)],
[2, "New", true, new Response(['id' => 2, 'title' => "New", 'user_id' => 42])],
- [2, "Duplicate", "constraintViolation", new ErrorResponse(["DuplicateCategory", 'title' => "Duplicate"], 500)],
- [2, "", "missing", new ErrorResponse(["InvalidCategory", 'title' => ""], 500)],
- [2, " ", "whitespace", new ErrorResponse(["InvalidCategory", 'title' => " "], 500)],
- [2, false, "subjectMissing", new ErrorResponse(["InvalidInputType", 'field' => "title", 'actual' => "boolean", 'expected' => "string"],422)],
+ [2, "Duplicate", "constraintViolation", new ErrorResponse(["DuplicateCategory", 'title' => "Duplicate"], 409)],
+ [2, "", "missing", new ErrorResponse(["InvalidCategory", 'title' => ""], 422)],
+ [2, " ", "whitespace", new ErrorResponse(["InvalidCategory", 'title' => " "], 422)],
+ [2, null, "missing", new ErrorResponse(["MissingInputValue", 'field' => "title"], 422)],
+ [2, false, "subjectMissing", new ErrorResponse(["InvalidInputType", 'field' => "title", 'actual' => "boolean", 'expected' => "string"], 422)],
[1, "New", true, new Response(['id' => 1, 'title' => "New", 'user_id' => 42])],
[1, "Duplicate", "constraintViolation", new Response(['id' => 1, 'title' => "Duplicate", 'user_id' => 42])], // This is allowed because the name of the root folder is only a duplicate in circumstances where it is used
- [1, "", "missing", new ErrorResponse(["InvalidCategory", 'title' => ""], 500)],
- [1, " ", "whitespace", new ErrorResponse(["InvalidCategory", 'title' => " "], 500)],
+ [1, "", "missing", new ErrorResponse(["InvalidCategory", 'title' => ""], 422)],
+ [1, " ", "whitespace", new ErrorResponse(["InvalidCategory", 'title' => " "], 422)],
+ [1, null, "missing", new ErrorResponse(["MissingInputValue", 'field' => "title"], 422)],
[1, false, false, new ErrorResponse(["InvalidInputType", 'field' => "title", 'actual' => "boolean", 'expected' => "string"], 422)],
];
}
From 31f0539dc0b4bc2588802f983531d95794b47474 Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Thu, 31 Dec 2020 17:03:08 -0500
Subject: [PATCH 096/366] Implement Miniflux user deletion
---
lib/REST/Miniflux/V1.php | 9 +++++++++
tests/cases/REST/Miniflux/TestV1.php | 28 ++++++++++++++++++++++++++--
2 files changed, 35 insertions(+), 2 deletions(-)
diff --git a/lib/REST/Miniflux/V1.php b/lib/REST/Miniflux/V1.php
index 94cdab0e..12f509a3 100644
--- a/lib/REST/Miniflux/V1.php
+++ b/lib/REST/Miniflux/V1.php
@@ -480,6 +480,15 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
return new Response($out);
}
+ protected function deleteUserByNum(array $path): ResponseInterface {
+ try {
+ Arsse::$user->remove(Arsse::$user->lookup((int) $path[1]));
+ } catch (ExceptionConflict $e) {
+ return new ErrorResponse("404", 404);
+ }
+ return new EmptyResponse(204);
+ }
+
protected function getCategories(): ResponseInterface {
$out = [];
$meta = Arsse::$user->propertiesGet(Arsse::$user->id, false);
diff --git a/tests/cases/REST/Miniflux/TestV1.php b/tests/cases/REST/Miniflux/TestV1.php
index 94fad0d8..3851ab32 100644
--- a/tests/cases/REST/Miniflux/TestV1.php
+++ b/tests/cases/REST/Miniflux/TestV1.php
@@ -371,11 +371,35 @@ class TestV1 extends \JKingWeb\Arsse\Test\AbstractTest {
}
public function testAddAUserWithoutAuthority(): void {
- Arsse::$user = $this->createMock(User::class);
- Arsse::$user->method("propertiesGet")->willReturn(['num' => 1, 'admin' => false]);
$this->assertMessage(new ErrorResponse("403", 403), $this->req("POST", "/users", []));
}
+ public function testDeleteAUser(): void {
+ Arsse::$user = $this->createMock(User::class);
+ Arsse::$user->method("propertiesGet")->willReturn(['admin' => true]);
+ Arsse::$user->method("lookup")->willReturn("john.doe@example.com");
+ Arsse::$user->method("remove")->willReturn(true);
+ Arsse::$user->expects($this->exactly(1))->method("lookup")->with(2112);
+ Arsse::$user->expects($this->exactly(1))->method("remove")->with("john.doe@example.com");
+ $this->assertMessage(new EmptyResponse(204), $this->req("DELETE", "/users/2112"));
+ }
+
+ public function testDeleteAMissingUser(): void {
+ Arsse::$user = $this->createMock(User::class);
+ Arsse::$user->method("propertiesGet")->willReturn(['admin' => true]);
+ Arsse::$user->method("lookup")->willThrowException(new ExceptionConflict("doesNotExist"));
+ Arsse::$user->method("remove")->willReturn(true);
+ Arsse::$user->expects($this->exactly(1))->method("lookup")->with(2112);
+ Arsse::$user->expects($this->exactly(0))->method("remove");
+ $this->assertMessage(new ErrorResponse("404", 404), $this->req("DELETE", "/users/2112"));
+ }
+
+ public function testDeleteAUserWithoutAuthority(): void {
+ Arsse::$user->expects($this->exactly(0))->method("lookup");
+ Arsse::$user->expects($this->exactly(0))->method("remove");
+ $this->assertMessage(new ErrorResponse("403", 403), $this->req("DELETE", "/users/2112"));
+ }
+
public function testListCategories(): void {
\Phake::when(Arsse::$db)->folderList->thenReturn(new Result($this->v([
['id' => 1, 'name' => "Science"],
From 7e173327149d86443969940481c8e13285bc245c Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Thu, 31 Dec 2020 17:50:40 -0500
Subject: [PATCH 097/366] Implement marking all as read for Miniflux
---
lib/REST/Miniflux/V1.php | 10 ++++++++++
tests/cases/REST/Miniflux/TestV1.php | 7 +++++++
2 files changed, 17 insertions(+)
diff --git a/lib/REST/Miniflux/V1.php b/lib/REST/Miniflux/V1.php
index 12f509a3..9ad2882c 100644
--- a/lib/REST/Miniflux/V1.php
+++ b/lib/REST/Miniflux/V1.php
@@ -489,6 +489,16 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
return new EmptyResponse(204);
}
+ protected function markUserByNum(array $path): ResponseInterface {
+ // this function is restricted to the logged-in user
+ $user = Arsse::$user->propertiesGet(Arsse::$user->id, false);
+ if (((int) $path[1]) !== $user['num']) {
+ return new ErrorResponse("403", 403);
+ }
+ Arsse::$db->articleMark(Arsse::$user->id, ['read' => true], (new Context)->hidden(false));
+ return new EmptyResponse(204);
+ }
+
protected function getCategories(): ResponseInterface {
$out = [];
$meta = Arsse::$user->propertiesGet(Arsse::$user->id, false);
diff --git a/tests/cases/REST/Miniflux/TestV1.php b/tests/cases/REST/Miniflux/TestV1.php
index 3851ab32..255d9a55 100644
--- a/tests/cases/REST/Miniflux/TestV1.php
+++ b/tests/cases/REST/Miniflux/TestV1.php
@@ -400,6 +400,13 @@ class TestV1 extends \JKingWeb\Arsse\Test\AbstractTest {
$this->assertMessage(new ErrorResponse("403", 403), $this->req("DELETE", "/users/2112"));
}
+ public function testMarkAllArticlesAsRead(): void {
+ \Phake::when(Arsse::$db)->articleMark->thenReturn(true);
+ $this->assertMessage(new ErrorResponse("403", 403), $this->req("PUT", "/users/1/mark-all-as-read"));
+ $this->assertMessage(new EmptyResponse(204), $this->req("PUT", "/users/42/mark-all-as-read"));
+ \Phake::verify(Arsse::$db)->articleMark("john.doe@example.com", ['read' => true], (new Context)->hidden(false));
+ }
+
public function testListCategories(): void {
\Phake::when(Arsse::$db)->folderList->thenReturn(new Result($this->v([
['id' => 1, 'name' => "Science"],
From ffc5579a7a874219cb57f09cc7ae75b2c95d0c44 Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Sun, 3 Jan 2021 16:41:15 -0500
Subject: [PATCH 098/366] Partial implementation of filter rule handling
---
lib/AbstractException.php | 1 +
lib/Feed.php | 35 ++++++++++++++++++++++++-----------
lib/Rule/Exception.php | 10 ++++++++++
lib/Rule/Rule.php | 31 +++++++++++++++++++++++++++++++
locale/en.php | 1 +
tests/cases/Misc/TestRule.php | 22 ++++++++++++++++++++++
tests/phpunit.dist.xml | 1 +
7 files changed, 90 insertions(+), 11 deletions(-)
create mode 100644 lib/Rule/Exception.php
create mode 100644 lib/Rule/Rule.php
create mode 100644 tests/cases/Misc/TestRule.php
diff --git a/lib/AbstractException.php b/lib/AbstractException.php
index 73a17076..5a575c34 100644
--- a/lib/AbstractException.php
+++ b/lib/AbstractException.php
@@ -100,6 +100,7 @@ abstract class AbstractException extends \Exception {
"ImportExport/Exception.invalidFolderName" => 10613,
"ImportExport/Exception.invalidFolderCopy" => 10614,
"ImportExport/Exception.invalidTagName" => 10615,
+ "Rule/Exception.invalidPattern" => 10701,
];
public function __construct(string $msgID = "", $vars = null, \Throwable $e = null) {
diff --git a/lib/Feed.php b/lib/Feed.php
index 81256a63..da01d2f0 100644
--- a/lib/Feed.php
+++ b/lib/Feed.php
@@ -79,10 +79,14 @@ class Feed {
// we only really care if articles have been modified; if there are no new articles, act as if the feed is unchanged
if (!sizeof($this->newItems) && !sizeof($this->changedItems)) {
$this->modified = false;
- }
- // if requested, scrape full content for any new and changed items
- if ($scrape) {
- $this->scrape();
+ } else {
+ if ($feedID) {
+ $this->computeFilterRules($feedID);
+ }
+ // if requested, scrape full content for any new and changed items
+ if ($scrape) {
+ $this->scrape();
+ }
}
}
// compute the time at which the feed should next be fetched
@@ -119,7 +123,7 @@ class Feed {
}
}
- protected function parse(): bool {
+ protected function parse(): void {
try {
$feed = $this->resource->reader->getParser(
$this->resource->getUrl(),
@@ -222,7 +226,6 @@ class Feed {
sort($f->categories);
}
$this->data = $feed;
- return true;
}
protected function deduplicateItems(array $items): array {
@@ -269,13 +272,13 @@ class Feed {
return $out;
}
- protected function matchToDatabase(int $feedID = null): bool {
+ protected function matchToDatabase(int $feedID = null): void {
// first perform deduplication on items
$items = $this->deduplicateItems($this->data->items);
// if we haven't been given a database feed ID to check against, all items are new
if (is_null($feedID)) {
$this->newItems = $items;
- return true;
+ return;
}
// get as many of the latest articles in the database as there are in the feed
$articles = Arsse::$db->feedMatchLatest($feedID, sizeof($items))->getAll();
@@ -303,7 +306,6 @@ class Feed {
// merge the two change-lists, preserving keys
$this->changedItems = array_combine(array_merge(array_keys($this->changedItems), array_keys($changed)), array_merge($this->changedItems, $changed));
}
- return true;
}
protected function matchItems(array $items, array $articles): array {
@@ -438,7 +440,7 @@ class Feed {
return $dates;
}
- protected function scrape(): bool {
+ protected function scrape(): void {
$scraper = new Scraper(self::configure());
foreach (array_merge($this->newItems, $this->changedItems) as $item) {
$scraper->setUrl($item->url);
@@ -447,6 +449,17 @@ class Feed {
$item->content = $scraper->getFilteredContent();
}
}
- return true;
+ }
+
+ protected function computeFilterRules(int $feedID): void {
+ return;
+ $rules = Arsse::$db->feedRulesGet($feedID);
+ foreach ($rules as $r) {
+ $keep = "";
+ $block = "";
+ if (strlen($r['keep'])) {
+
+ }
+ }
}
}
diff --git a/lib/Rule/Exception.php b/lib/Rule/Exception.php
new file mode 100644
index 00000000..e3c66645
--- /dev/null
+++ b/lib/Rule/Exception.php
@@ -0,0 +1,10 @@
+", $pattern, $m, \PREG_OFFSET_CAPTURE)) {
+ // where necessary escape our chosen delimiter (backtick) in reverse order
+ foreach (array_reverse($m[0]) as [,$pos]) {
+ // count the number of backslashes preceding the delimiter character
+ $count = 0;
+ $p = $pos;
+ while ($p-- && $pattern[$p] === "\\" && ++$count);
+ // if the number is even (including zero), add a backslash
+ if ($count % 2 === 0) {
+ $pattern = substr($pattern, 0, $pos)."\\".substr($pattern, $pos);
+ }
+ }
+ }
+ // add the delimiters and test the pattern
+ $pattern = "`$pattern`u";
+ if (@preg_match($pattern, "") === false) {
+ throw new Exception("invalidPattern");
+ }
+ return $pattern;
+ }
+}
\ No newline at end of file
diff --git a/locale/en.php b/locale/en.php
index d67547aa..1927eaf7 100644
--- a/locale/en.php
+++ b/locale/en.php
@@ -194,4 +194,5 @@ return [
'Exception.JKingWeb/Arsse/ImportExport/Exception.invalidFolderName' => 'Input data contains an invalid folder name',
'Exception.JKingWeb/Arsse/ImportExport/Exception.invalidFolderCopy' => 'Input data contains multiple folders of the same name under the same parent',
'Exception.JKingWeb/Arsse/ImportExport/Exception.invalidTagName' => 'Input data contains an invalid tag name',
+ 'Exception.JKingWeb/Arsse/Rule/Exception.invalidPattern' => 'Specified rule pattern is invalid'
];
diff --git a/tests/cases/Misc/TestRule.php b/tests/cases/Misc/TestRule.php
new file mode 100644
index 00000000..804b1724
--- /dev/null
+++ b/tests/cases/Misc/TestRule.php
@@ -0,0 +1,22 @@
+assertSame($exp, Rule::prep("`..`..\\`..\\\\`.."));
+ }
+
+ public function testPrepareAnInvalidPattern(): void {
+ $this->assertException("invalidPattern", "Rule");
+ Rule::prep("[");
+ }
+}
\ No newline at end of file
diff --git a/tests/phpunit.dist.xml b/tests/phpunit.dist.xml
index 18486652..0875bf54 100644
--- a/tests/phpunit.dist.xml
+++ b/tests/phpunit.dist.xml
@@ -51,6 +51,7 @@
cases/Misc/TestContext.php
cases/Misc/TestURL.php
cases/Misc/TestHTTP.php
+ cases/Misc/TestRule.php
cases/User/TestInternal.php
From b12f87e231fa2c318bd9fb75cdb9fc1885195f42 Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Sun, 3 Jan 2021 16:51:25 -0500
Subject: [PATCH 099/366] Support Xdebug 3.x for coverage
---
RoboFile.php | 4 ++--
tests/bootstrap.php | 6 +++++-
2 files changed, 7 insertions(+), 3 deletions(-)
diff --git a/RoboFile.php b/RoboFile.php
index 31b98924..0f1a349d 100644
--- a/RoboFile.php
+++ b/RoboFile.php
@@ -96,11 +96,11 @@ class RoboFile extends \Robo\Tasks {
if (extension_loaded("pcov")) {
return "$php -d pcov.enabled=1 -d pcov.directory=$code";
} elseif (extension_loaded("xdebug")) {
- return $php;
+ return "$php -d xdebug.mode=coverage";
} elseif (file_exists($dir."pcov.$ext")) {
return "$php -d extension=pcov.$ext -d pcov.enabled=1 -d pcov.directory=$code";
} elseif (file_exists($dir."xdebug.$ext")) {
- return "$php -d zend_extension=xdebug.$ext";
+ return "$php -d zend_extension=xdebug.$ext -d xdebug.mode=coverage";
} else {
if (IS_WIN) {
$dbg = dirname(\PHP_BINARY)."\\phpdbg.exe";
diff --git a/tests/bootstrap.php b/tests/bootstrap.php
index 2e4c2514..c8f36508 100644
--- a/tests/bootstrap.php
+++ b/tests/bootstrap.php
@@ -16,5 +16,9 @@ error_reporting(\E_ALL);
require_once BASE."vendor".DIRECTORY_SEPARATOR."autoload.php";
if (function_exists("xdebug_set_filter")) {
- xdebug_set_filter(\XDEBUG_FILTER_CODE_COVERAGE, \XDEBUG_PATH_WHITELIST, [BASE."lib/"]);
+ if (defined("XDEBUG_PATH_INCLUDE")) {
+ xdebug_set_filter(\XDEBUG_FILTER_CODE_COVERAGE, XDEBUG_PATH_INCLUDE, [BASE."lib/"]);
+ } else {
+ xdebug_set_filter(\XDEBUG_FILTER_CODE_COVERAGE, XDEBUG_PATH_WHITELIST, [BASE."lib/"]);
+ }
}
From 47ae65b9d368bfcb6585c6ce1fa828baebd05d17 Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Sun, 3 Jan 2021 22:15:39 -0500
Subject: [PATCH 100/366] Function to apply filter rules
---
lib/Rule/Rule.php | 59 ++++++++++++++++++++++++++++++++++-
tests/cases/Misc/TestRule.php | 30 +++++++++++++++++-
2 files changed, 87 insertions(+), 2 deletions(-)
diff --git a/lib/Rule/Rule.php b/lib/Rule/Rule.php
index 90b0bbec..5f387f1d 100644
--- a/lib/Rule/Rule.php
+++ b/lib/Rule/Rule.php
@@ -28,4 +28,61 @@ abstract class Rule {
}
return $pattern;
}
-}
\ No newline at end of file
+
+ public static function validate(string $pattern): bool {
+ try {
+ static::prep($pattern);
+ } catch (Exception $e) {
+ return false;
+ }
+ return true;
+ }
+
+ /** applies keep and block rules against the title and categories of an article
+ *
+ * Returns true if the article is to be kept, and false if it is to be suppressed
+ */
+ public static function apply(string $keepRule, string $blockRule, string $title, array $categories = []): bool {
+ // if neither rule is processed we should keep
+ $keep = true;
+ // add the title to the front of the category array
+ array_unshift($categories, $title);
+ // process the keep rule if it exists
+ if (strlen($keepRule)) {
+ try {
+ $rule = static::prep($keepRule);
+ } catch (Exception $e) {
+ return true;
+ }
+ // if a keep rule is specified the default state is now not to keep
+ $keep = false;
+ foreach ($categories as $str) {
+ if (is_string($str)) {
+ if (preg_match($rule, $str)) {
+ // keep if the keep-rule matches one of the strings
+ $keep = true;
+ break;
+ }
+ }
+ }
+ }
+ // process the block rule if the keep rule was matched
+ if ($keep && strlen($blockRule)) {
+ try {
+ $rule = static::prep($blockRule);
+ } catch (Exception $e) {
+ return true;
+ }
+ foreach ($categories as $str) {
+ if (is_string($str)) {
+ if (preg_match($rule, $str)) {
+ // do not keep if the block-rule matches one of the strings
+ $keep = false;
+ break;
+ }
+ }
+ }
+ }
+ return $keep;
+ }
+}
diff --git a/tests/cases/Misc/TestRule.php b/tests/cases/Misc/TestRule.php
index 804b1724..9df68380 100644
--- a/tests/cases/Misc/TestRule.php
+++ b/tests/cases/Misc/TestRule.php
@@ -7,16 +7,44 @@ declare(strict_types=1);
namespace JKingWeb\Arsse\TestCase\Misc;
use JKingWeb\Arsse\Rule\Rule;
+use JKingWeb\Arsse\Rule\Exception;
/** @covers \JKingWeb\Arsse\Rule\Rule */
class TestRule extends \JKingWeb\Arsse\Test\AbstractTest {
public function testPrepareAPattern(): void {
$exp = "`\\`..\\`..\\`..\\\\\\`..`u";
+ $this->assertTrue(Rule::validate("`..`..\\`..\\\\`.."));
$this->assertSame($exp, Rule::prep("`..`..\\`..\\\\`.."));
}
public function testPrepareAnInvalidPattern(): void {
+ $this->assertFalse(Rule::validate("["));
$this->assertException("invalidPattern", "Rule");
Rule::prep("[");
}
-}
\ No newline at end of file
+
+ /** @dataProvider provideApplications */
+ public function testApplyRules(string $keepRule, string $blockRule, string $title, array $categories, $exp): void {
+ if ($exp instanceof \Exception) {
+ $this->assertException($exp);
+ Rule::apply($keepRule, $blockRule, $title, $categories);
+ } else {
+ $this->assertSame($exp, Rule::apply($keepRule, $blockRule, $title, $categories));
+ }
+ }
+
+ public function provideApplications(): iterable {
+ return [
+ ["", "", "Title", ["Dummy", "Category"], true],
+ ["^Title$", "", "Title", ["Dummy", "Category"], true],
+ ["^Category$", "", "Title", ["Dummy", "Category"], true],
+ ["^Naught$", "", "Title", ["Dummy", "Category"], false],
+ ["", "^Title$", "Title", ["Dummy", "Category"], false],
+ ["", "^Category$", "Title", ["Dummy", "Category"], false],
+ ["", "^Naught$", "Title", ["Dummy", "Category"], true],
+ ["^Category$", "^Category$", "Title", ["Dummy", "Category"], false],
+ ["[", "", "Title", ["Dummy", "Category"], true],
+ ["", "[", "Title", ["Dummy", "Category"], true],
+ ];
+ }
+}
From 461e25605273167c7a355c6f67575f14f7e42a55 Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Thu, 7 Jan 2021 10:12:38 -0500
Subject: [PATCH 101/366] Work around MySQL syntax weirdness
Also improve test for token translation to actually test that the
translated tokens are accepted by the database system
---
lib/Database.php | 16 ++++++++++------
lib/Db/Driver.php | 1 +
lib/Db/MySQL/Driver.php | 2 ++
tests/cases/Db/BaseDriver.php | 21 ++++++++++++++-------
4 files changed, 27 insertions(+), 13 deletions(-)
diff --git a/lib/Database.php b/lib/Database.php
index 95ccc611..343e2cec 100644
--- a/lib/Database.php
+++ b/lib/Database.php
@@ -762,6 +762,7 @@ class Database {
// validate inputs
$folder = $this->folderValidateId($user, $folder)['id'];
// create a complex query
+ $integer = $this->db->sqlToken("integer");
$q = new Query(
"SELECT
s.id as id,
@@ -789,7 +790,7 @@ class Database {
select
subscription,
sum(hidden) as hidden,
- sum(cast((\"read\" = 1 and hidden = 0) as integer)) as marked
+ sum(cast((\"read\" = 1 and hidden = 0) as $integer)) as marked
from arsse_marks group by subscription
) as mark_stats on mark_stats.subscription = s.id"
);
@@ -1211,7 +1212,7 @@ class Database {
* - "block": The block rule; any article which matches this rule are hidden
*/
public function feedRulesGet(int $feedID): Db\Result {
- return $this->db->prepare("SELECT owner, coalesce(keep_rule, '') as keep, coalesce(block_rule, '') as block from arsse_subscriptions where feed = ? and (keep || block) <> '' order by owner", "int")->run($feedID);
+ return $this->db->prepare("SELECT owner, coalesce(keep_rule, '') as keep, coalesce(block_rule, '') as block from arsse_subscriptions where feed = ? and (coalesce(keep_rule, '') || coalesce(block_rule, '')) <> '' order by owner", "int")->run($feedID);
}
/** Retrieves various identifiers for the latest $count articles in the given newsfeed. The identifiers are:
@@ -1803,6 +1804,7 @@ class Database {
/** Deletes from the database articles which are beyond the configured clean-up threshold */
public function articleCleanup(): bool {
+ $integer = $this->db->sqlToken("integer");
$query = $this->db->prepareArray(
"WITH RECURSIVE
exempt_articles as (
@@ -1828,8 +1830,8 @@ class Database {
left join (
select
article,
- sum(cast((starred = 1 and hidden = 0) as integer)) as starred,
- sum(cast((\"read\" = 1 or hidden = 1) as integer)) as \"read\",
+ sum(cast((starred = 1 and hidden = 0) as $integer)) as starred,
+ sum(cast((\"read\" = 1 or hidden = 1) as $integer)) as \"read\",
max(arsse_marks.modified) as marked_date
from arsse_marks
group by article
@@ -1960,6 +1962,7 @@ class Database {
* @param boolean $includeEmpty Whether to include (true) or supress (false) labels which have no articles assigned to them
*/
public function labelList(string $user, bool $includeEmpty = true): Db\Result {
+ $integer = $this->db->sqlToken("integer");
return $this->db->prepareArray(
"SELECT * FROM (
SELECT
@@ -1975,7 +1978,7 @@ class Database {
SELECT
label,
sum(hidden) as hidden,
- sum(cast((\"read\" = 1 and hidden = 0) as integer)) as marked
+ sum(cast((\"read\" = 1 and hidden = 0) as $integer)) as marked
from arsse_marks
join arsse_subscriptions on arsse_subscriptions.id = arsse_marks.subscription
join arsse_label_members on arsse_label_members.article = arsse_marks.article
@@ -2025,6 +2028,7 @@ class Database {
$this->labelValidateId($user, $id, $byName, false);
$field = $byName ? "name" : "id";
$type = $byName ? "str" : "int";
+ $integer = $this->db->sqlToken("integer");
$out = $this->db->prepareArray(
"SELECT
id,
@@ -2039,7 +2043,7 @@ class Database {
SELECT
label,
sum(hidden) as hidden,
- sum(cast((\"read\" = 1 and hidden = 0) as integer)) as marked
+ sum(cast((\"read\" = 1 and hidden = 0) as $integer)) as marked
from arsse_marks
join arsse_subscriptions on arsse_subscriptions.id = arsse_marks.subscription
join arsse_label_members on arsse_label_members.article = arsse_marks.article
diff --git a/lib/Db/Driver.php b/lib/Db/Driver.php
index 1488b1b1..d533b926 100644
--- a/lib/Db/Driver.php
+++ b/lib/Db/Driver.php
@@ -74,6 +74,7 @@ interface Driver {
* - "greatest": the GREATEST function implemented by PostgreSQL and MySQL
* - "nocase": the name of a general-purpose case-insensitive collation sequence
* - "like": the case-insensitive LIKE operator
+ * - "integer": the integer type to use for explicit casts
*/
public function sqlToken(string $token): string;
diff --git a/lib/Db/MySQL/Driver.php b/lib/Db/MySQL/Driver.php
index 023a2819..8a82be44 100644
--- a/lib/Db/MySQL/Driver.php
+++ b/lib/Db/MySQL/Driver.php
@@ -81,6 +81,8 @@ class Driver extends \JKingWeb\Arsse\Db\AbstractDriver {
switch (strtolower($token)) {
case "nocase":
return '"utf8mb4_unicode_ci"';
+ case "integer":
+ return "signed integer";
default:
return $token;
}
diff --git a/tests/cases/Db/BaseDriver.php b/tests/cases/Db/BaseDriver.php
index 94091ac5..665443d3 100644
--- a/tests/cases/Db/BaseDriver.php
+++ b/tests/cases/Db/BaseDriver.php
@@ -90,13 +90,6 @@ abstract class BaseDriver extends \JKingWeb\Arsse\Test\AbstractTest {
$this->assertTrue($this->drv->charsetAcceptable());
}
- public function testTranslateAToken(): void {
- $this->assertRegExp("/^[a-z][a-z0-9]*$/i", $this->drv->sqlToken("greatest"));
- $this->assertRegExp("/^\"?[a-z][a-z0-9_\-]*\"?$/i", $this->drv->sqlToken("nocase"));
- $this->assertRegExp("/^[a-z][a-z0-9]*$/i", $this->drv->sqlToken("like"));
- $this->assertSame("distinct", $this->drv->sqlToken("distinct"));
- }
-
public function testExecAValidStatement(): void {
$this->assertTrue($this->drv->exec($this->create));
}
@@ -386,4 +379,18 @@ abstract class BaseDriver extends \JKingWeb\Arsse\Test\AbstractTest {
// this performs maintenance in the absence of tables; see BaseUpdate.php for another test with tables
$this->assertTrue($this->drv->maintenance());
}
+
+ public function testTranslateTokens(): void {
+ $greatest = $this->drv->sqlToken("GrEatESt");
+ $nocase = $this->drv->sqlToken("noCASE");
+ $like = $this->drv->sqlToken("liKe");
+ $integer = $this->drv->sqlToken("InTEGer");
+
+ $this->assertSame("NOT_A_TOKEN", $this->drv->sqlToken("NOT_A_TOKEN"));
+
+ $this->assertSame("Z", $this->drv->query("SELECT $greatest('Z', 'A')")->getValue());
+ $this->assertSame("Z", $this->drv->query("SELECT 'Z' collate $nocase")->getValue());
+ $this->assertSame("Z", $this->drv->query("SELECT 'Z' where 'Z' $like 'z'")->getValue());
+ $this->assertEquals(1, $this->drv->query("SELECT CAST((1=1) as $integer)")->getValue());
+ }
}
From 6dba8aa66b69132752c40865de91f25cba823def Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Thu, 7 Jan 2021 15:08:50 -0500
Subject: [PATCH 102/366] Fixes for rules
- Whitespace is now collapsed before evaluating rules
- Feed tests are fixed to retrieve a dumy set of rules
- Rule evaluation during feed parsing also filled out
---
lib/Feed.php | 14 +++++++++-----
lib/Rule/Rule.php | 10 ++++++----
tests/cases/Feed/TestFeed.php | 1 +
tests/cases/Misc/TestRule.php | 22 ++++++++++++----------
4 files changed, 28 insertions(+), 19 deletions(-)
diff --git a/lib/Feed.php b/lib/Feed.php
index da01d2f0..6d68ea8f 100644
--- a/lib/Feed.php
+++ b/lib/Feed.php
@@ -7,6 +7,7 @@ declare(strict_types=1);
namespace JKingWeb\Arsse;
use JKingWeb\Arsse\Misc\Date;
+use JKingWeb\Arsse\Rule\Rule;
use PicoFeed\PicoFeedException;
use PicoFeed\Config\Config;
use PicoFeed\Client\Client;
@@ -25,6 +26,7 @@ class Feed {
public $nextFetch;
public $newItems = [];
public $changedItems = [];
+ public $filteredItems = [];
public static function discover(string $url, string $username = '', string $password = ''): string {
// fetch the candidate feed
@@ -452,14 +454,16 @@ class Feed {
}
protected function computeFilterRules(int $feedID): void {
- return;
$rules = Arsse::$db->feedRulesGet($feedID);
foreach ($rules as $r) {
- $keep = "";
- $block = "";
- if (strlen($r['keep'])) {
-
+ $stats = ['new' => [], 'changed' => []];
+ foreach ($this->newItems as $index => $item) {
+ $stats['new'][$index] = Rule::apply($r['keep'], $r['block'], $item->title, $item->categories);
}
+ foreach ($this->changedItems as $index => $item) {
+ $stats['changed'][$index] = Rule::apply($r['keep'], $r['block'], $item->title, $item->categories);
+ }
+ $this->filteredItems[$r['owner']] = $stats;
}
}
}
diff --git a/lib/Rule/Rule.php b/lib/Rule/Rule.php
index 5f387f1d..451d360f 100644
--- a/lib/Rule/Rule.php
+++ b/lib/Rule/Rule.php
@@ -45,8 +45,10 @@ abstract class Rule {
public static function apply(string $keepRule, string $blockRule, string $title, array $categories = []): bool {
// if neither rule is processed we should keep
$keep = true;
- // add the title to the front of the category array
- array_unshift($categories, $title);
+ // merge and clean the data to match
+ $data = array_map(function($str) {
+ return preg_replace('/\s+/', " ", $str);
+ }, array_merge([$title], $categories));
// process the keep rule if it exists
if (strlen($keepRule)) {
try {
@@ -56,7 +58,7 @@ abstract class Rule {
}
// if a keep rule is specified the default state is now not to keep
$keep = false;
- foreach ($categories as $str) {
+ foreach ($data as $str) {
if (is_string($str)) {
if (preg_match($rule, $str)) {
// keep if the keep-rule matches one of the strings
@@ -73,7 +75,7 @@ abstract class Rule {
} catch (Exception $e) {
return true;
}
- foreach ($categories as $str) {
+ foreach ($data as $str) {
if (is_string($str)) {
if (preg_match($rule, $str)) {
// do not keep if the block-rule matches one of the strings
diff --git a/tests/cases/Feed/TestFeed.php b/tests/cases/Feed/TestFeed.php
index f9a422e4..b5ce6653 100644
--- a/tests/cases/Feed/TestFeed.php
+++ b/tests/cases/Feed/TestFeed.php
@@ -95,6 +95,7 @@ class TestFeed extends \JKingWeb\Arsse\Test\AbstractTest {
self::clearData();
self::setConf();
Arsse::$db = \Phake::mock(Database::class);
+ \Phake::when(Arsse::$db)->feedRulesGet->thenReturn(new Result([]));
}
public function testParseAFeed(): void {
diff --git a/tests/cases/Misc/TestRule.php b/tests/cases/Misc/TestRule.php
index 9df68380..8652aa45 100644
--- a/tests/cases/Misc/TestRule.php
+++ b/tests/cases/Misc/TestRule.php
@@ -35,16 +35,18 @@ class TestRule extends \JKingWeb\Arsse\Test\AbstractTest {
public function provideApplications(): iterable {
return [
- ["", "", "Title", ["Dummy", "Category"], true],
- ["^Title$", "", "Title", ["Dummy", "Category"], true],
- ["^Category$", "", "Title", ["Dummy", "Category"], true],
- ["^Naught$", "", "Title", ["Dummy", "Category"], false],
- ["", "^Title$", "Title", ["Dummy", "Category"], false],
- ["", "^Category$", "Title", ["Dummy", "Category"], false],
- ["", "^Naught$", "Title", ["Dummy", "Category"], true],
- ["^Category$", "^Category$", "Title", ["Dummy", "Category"], false],
- ["[", "", "Title", ["Dummy", "Category"], true],
- ["", "[", "Title", ["Dummy", "Category"], true],
+ ["", "", "Title", ["Dummy", "Category"], true],
+ ["^Title$", "", "Title", ["Dummy", "Category"], true],
+ ["^Category$", "", "Title", ["Dummy", "Category"], true],
+ ["^Naught$", "", "Title", ["Dummy", "Category"], false],
+ ["", "^Title$", "Title", ["Dummy", "Category"], false],
+ ["", "^Category$", "Title", ["Dummy", "Category"], false],
+ ["", "^Naught$", "Title", ["Dummy", "Category"], true],
+ ["^Category$", "^Category$", "Title", ["Dummy", "Category"], false],
+ ["[", "", "Title", ["Dummy", "Category"], true],
+ ["", "[", "Title", ["Dummy", "Category"], true],
+ ["", "^A B C$", "A B\nC", ["X\n Y \t \r Z"], false],
+ ["", "^X Y Z$", "A B\nC", ["X\n Y \t \r Z"], false],
];
}
}
From c1eff8479ca0ede7a417ea7e161e0057e0453c38 Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Thu, 7 Jan 2021 19:49:09 -0500
Subject: [PATCH 103/366] Simplify configuration property caching
---
lib/Conf.php | 16 +++++++---------
1 file changed, 7 insertions(+), 9 deletions(-)
diff --git a/lib/Conf.php b/lib/Conf.php
index 2de8addb..c6fd33c1 100644
--- a/lib/Conf.php
+++ b/lib/Conf.php
@@ -128,16 +128,14 @@ class Conf {
'dbSQLite3Timeout' => "double",
];
- protected static $types = [];
+ protected $types = [];
/** Creates a new configuration object
* @param string $import_file Optional file to read configuration data from
* @see self::importFile() */
public function __construct(string $import_file = "") {
- if (!static::$types) {
- static::$types = $this->propertyDiscover();
- }
- foreach (array_keys(static::$types) as $prop) {
+ $this->types = $this->propertyDiscover();
+ foreach (array_keys($this->types) as $prop) {
$this->$prop = $this->propertyImport($prop, $this->$prop);
}
if ($import_file !== "") {
@@ -273,9 +271,9 @@ class Conf {
}
protected function propertyImport(string $key, $value, string $file = "") {
- $typeName = static::$types[$key]['name'] ?? "mixed";
- $typeConst = static::$types[$key]['const'] ?? Value::T_MIXED;
- $nullable = (int) (bool) (static::$types[$key]['const'] & Value::M_NULL);
+ $typeName = $this->types[$key]['name'] ?? "mixed";
+ $typeConst = $this->types[$key]['const'] ?? Value::T_MIXED;
+ $nullable = (int) (bool) ($this->types[$key]['const'] & Value::M_NULL);
try {
if ($typeName === "\\DateInterval") {
// date intervals have special handling: if the existing value (ultimately, the default value)
@@ -319,7 +317,7 @@ class Conf {
}
return $value;
} catch (ExceptionType $e) {
- $type = static::$types[$key]['const'] & ~(Value::M_STRICT | Value::M_DROP | Value::M_NULL | Value::M_ARRAY);
+ $type = $this->types[$key]['const'] & ~(Value::M_STRICT | Value::M_DROP | Value::M_NULL | Value::M_ARRAY);
throw new Conf\Exception("typeMismatch", ['param' => $key, 'type' => self::TYPE_NAMES[$type], 'file' => $file, 'nullable' => $nullable]);
}
}
From 4f34b4ff2968bb73f543fe2277c0c61a0a8d9783 Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Fri, 8 Jan 2021 14:17:46 -0500
Subject: [PATCH 104/366] Rule refactoring
- The Database class is now responsible for preparing rules
- Rules are now returned in an array keyed by user
- Empty strings are now passed through during rule preparation
---
lib/Database.php | 28 +++++++++++++-----
lib/Feed.php | 4 +--
lib/Rule/Rule.php | 45 +++++++++++++----------------
tests/cases/Database/SeriesFeed.php | 12 ++++----
tests/cases/Feed/TestFeed.php | 2 +-
tests/cases/Misc/TestRule.php | 9 ++++--
6 files changed, 57 insertions(+), 43 deletions(-)
diff --git a/lib/Database.php b/lib/Database.php
index 343e2cec..f748aa7c 100644
--- a/lib/Database.php
+++ b/lib/Database.php
@@ -13,6 +13,8 @@ use JKingWeb\Arsse\Context\Context;
use JKingWeb\Arsse\Misc\Date;
use JKingWeb\Arsse\Misc\ValueInfo as V;
use JKingWeb\Arsse\Misc\URL;
+use JKingWeb\Arsse\Rule\Rule;
+use JKingWeb\Arsse\Rule\Exception as RuleException;
/** The high-level interface with the database
*
@@ -1205,14 +1207,26 @@ class Database {
/** Retrieves the set of filters users have applied to a given feed
*
- * Each record includes the following keys:
- *
- * - "owner": The user for whom to apply the filters
- * - "keep": The "keep" rule; any articles which fail to match this rule are hidden
- * - "block": The block rule; any article which matches this rule are hidden
+ * The result is an associative array whose keys are usernames, values
+ * being an array in turn with the following keys:
+ *
+ * - "keep": The "keep" rule as a prepared pattern; any articles which fail to match this rule are hidden
+ * - "block": The block rule as a prepared pattern; any articles which match this rule are hidden
*/
- public function feedRulesGet(int $feedID): Db\Result {
- return $this->db->prepare("SELECT owner, coalesce(keep_rule, '') as keep, coalesce(block_rule, '') as block from arsse_subscriptions where feed = ? and (coalesce(keep_rule, '') || coalesce(block_rule, '')) <> '' order by owner", "int")->run($feedID);
+ public function feedRulesGet(int $feedID): array {
+ $out = [];
+ $result = $this->db->prepare("SELECT owner, coalesce(keep_rule, '') as keep, coalesce(block_rule, '') as block from arsse_subscriptions where feed = ? and (coalesce(keep_rule, '') || coalesce(block_rule, '')) <> '' order by owner", "int")->run($feedID);
+ foreach ($result as $row) {
+ try {
+ $keep = Rule::prep($row['keep']);
+ $block = Rule::prep($row['block']);
+ } catch (RuleException $e) {
+ // invalid rules should not normally appear in the database, but it's possible
+ continue;
+ }
+ $out[$row['owner']] = ['keep' => $keep, 'block' => $block];
+ }
+ return $out;
}
/** Retrieves various identifiers for the latest $count articles in the given newsfeed. The identifiers are:
diff --git a/lib/Feed.php b/lib/Feed.php
index 6d68ea8f..b0e91290 100644
--- a/lib/Feed.php
+++ b/lib/Feed.php
@@ -455,7 +455,7 @@ class Feed {
protected function computeFilterRules(int $feedID): void {
$rules = Arsse::$db->feedRulesGet($feedID);
- foreach ($rules as $r) {
+ foreach ($rules as $user => $r) {
$stats = ['new' => [], 'changed' => []];
foreach ($this->newItems as $index => $item) {
$stats['new'][$index] = Rule::apply($r['keep'], $r['block'], $item->title, $item->categories);
@@ -463,7 +463,7 @@ class Feed {
foreach ($this->changedItems as $index => $item) {
$stats['changed'][$index] = Rule::apply($r['keep'], $r['block'], $item->title, $item->categories);
}
- $this->filteredItems[$r['owner']] = $stats;
+ $this->filteredItems[$user] = $stats;
}
}
}
diff --git a/lib/Rule/Rule.php b/lib/Rule/Rule.php
index 451d360f..ee3c4a65 100644
--- a/lib/Rule/Rule.php
+++ b/lib/Rule/Rule.php
@@ -8,6 +8,9 @@ namespace JKingWeb\Arsse\Rule;
abstract class Rule {
public static function prep(string $pattern): string {
+ if (!strlen($pattern)) {
+ return "";
+ }
if (preg_match_all("<`>", $pattern, $m, \PREG_OFFSET_CAPTURE)) {
// where necessary escape our chosen delimiter (backtick) in reverse order
foreach (array_reverse($m[0]) as [,$pos]) {
@@ -42,7 +45,13 @@ abstract class Rule {
*
* Returns true if the article is to be kept, and false if it is to be suppressed
*/
- public static function apply(string $keepRule, string $blockRule, string $title, array $categories = []): bool {
+ public static function apply(string $keepPattern, string $blockPattern, string $title, array $categories = []): bool {
+ // ensure input is valid
+ assert(!strlen($keepPattern) || @preg_match($keepPattern, "") !== false, new \Exception("Keep pattern is invalid"));
+ assert(!strlen($blockPattern) || @preg_match($blockPattern, "") !== false, new \Exception("Block pattern is invalid"));
+ assert(sizeof(array_filter($categories, function($v) {
+ return !is_string($v);
+ })) === 0, new \Exception("All categories must be strings"));
// if neither rule is processed we should keep
$keep = true;
// merge and clean the data to match
@@ -50,38 +59,24 @@ abstract class Rule {
return preg_replace('/\s+/', " ", $str);
}, array_merge([$title], $categories));
// process the keep rule if it exists
- if (strlen($keepRule)) {
- try {
- $rule = static::prep($keepRule);
- } catch (Exception $e) {
- return true;
- }
+ if (strlen($keepPattern)) {
// if a keep rule is specified the default state is now not to keep
$keep = false;
foreach ($data as $str) {
- if (is_string($str)) {
- if (preg_match($rule, $str)) {
- // keep if the keep-rule matches one of the strings
- $keep = true;
- break;
- }
+ if (preg_match($keepPattern, $str)) {
+ // keep if the keep-rule matches one of the strings
+ $keep = true;
+ break;
}
}
}
// process the block rule if the keep rule was matched
- if ($keep && strlen($blockRule)) {
- try {
- $rule = static::prep($blockRule);
- } catch (Exception $e) {
- return true;
- }
+ if ($keep && strlen($blockPattern)) {
foreach ($data as $str) {
- if (is_string($str)) {
- if (preg_match($rule, $str)) {
- // do not keep if the block-rule matches one of the strings
- $keep = false;
- break;
- }
+ if (preg_match($blockPattern, $str)) {
+ // do not keep if the block-rule matches one of the strings
+ $keep = false;
+ break;
}
}
}
diff --git a/tests/cases/Database/SeriesFeed.php b/tests/cases/Database/SeriesFeed.php
index 8f17694a..65a2931f 100644
--- a/tests/cases/Database/SeriesFeed.php
+++ b/tests/cases/Database/SeriesFeed.php
@@ -76,9 +76,9 @@ trait SeriesFeed {
],
'rows' => [
[1,'john.doe@example.com',1,null,'^Sport$'],
- [2,'john.doe@example.com',2,null,null],
+ [2,'john.doe@example.com',2,"",null],
[3,'john.doe@example.com',3,'\w+',null],
- [4,'john.doe@example.com',4,null,null],
+ [4,'john.doe@example.com',4,'\w+',"["], // invalid rule leads to both rules being ignored
[5,'john.doe@example.com',5,null,'and/or'],
[6,'jane.doe@example.com',1,'^(?i)[a-z]+','bluberry'],
],
@@ -205,16 +205,16 @@ trait SeriesFeed {
/** @dataProvider provideFilterRules */
public function testGetRules(int $in, array $exp): void {
- $this->assertResult($exp, Arsse::$db->feedRulesGet($in));
+ $this->assertSame($exp, Arsse::$db->feedRulesGet($in));
}
public function provideFilterRules(): iterable {
return [
- [1, [['owner' => "john.doe@example.com", 'keep' => "", 'block' => "^Sport$"], ['owner' => "jane.doe@example.com", 'keep' => "^(?i)[a-z]+", 'block' => "bluberry"]]],
+ [1, ['jane.doe@example.com' => ['keep' => "`^(?i)[a-z]+`u", 'block' => "`bluberry`u"], 'john.doe@example.com' => ['keep' => "", 'block' => "`^Sport$`u"]]],
[2, []],
- [3, [['owner' => "john.doe@example.com", 'keep' => '\w+', 'block' => ""]]],
+ [3, ['john.doe@example.com' => ['keep' => '`\w+`u', 'block' => ""]]],
[4, []],
- [5, [['owner' => "john.doe@example.com", 'keep' => "", 'block' => "and/or"]]],
+ [5, ['john.doe@example.com' => ['keep' => "", 'block' => "`and/or`u"]]],
];
}
diff --git a/tests/cases/Feed/TestFeed.php b/tests/cases/Feed/TestFeed.php
index b5ce6653..cb94c5e7 100644
--- a/tests/cases/Feed/TestFeed.php
+++ b/tests/cases/Feed/TestFeed.php
@@ -95,7 +95,7 @@ class TestFeed extends \JKingWeb\Arsse\Test\AbstractTest {
self::clearData();
self::setConf();
Arsse::$db = \Phake::mock(Database::class);
- \Phake::when(Arsse::$db)->feedRulesGet->thenReturn(new Result([]));
+ \Phake::when(Arsse::$db)->feedRulesGet->thenReturn([]);
}
public function testParseAFeed(): void {
diff --git a/tests/cases/Misc/TestRule.php b/tests/cases/Misc/TestRule.php
index 8652aa45..0d72f5fd 100644
--- a/tests/cases/Misc/TestRule.php
+++ b/tests/cases/Misc/TestRule.php
@@ -23,8 +23,15 @@ class TestRule extends \JKingWeb\Arsse\Test\AbstractTest {
Rule::prep("[");
}
+ public function testPrepareAnEmptyPattern(): void {
+ $this->assertTrue(Rule::validate(""));
+ $this->assertSame("", Rule::prep(""));
+ }
+
/** @dataProvider provideApplications */
public function testApplyRules(string $keepRule, string $blockRule, string $title, array $categories, $exp): void {
+ $keepRule = Rule::prep($keepRule);
+ $blockRule = Rule::prep($blockRule);
if ($exp instanceof \Exception) {
$this->assertException($exp);
Rule::apply($keepRule, $blockRule, $title, $categories);
@@ -43,8 +50,6 @@ class TestRule extends \JKingWeb\Arsse\Test\AbstractTest {
["", "^Category$", "Title", ["Dummy", "Category"], false],
["", "^Naught$", "Title", ["Dummy", "Category"], true],
["^Category$", "^Category$", "Title", ["Dummy", "Category"], false],
- ["[", "", "Title", ["Dummy", "Category"], true],
- ["", "[", "Title", ["Dummy", "Category"], true],
["", "^A B C$", "A B\nC", ["X\n Y \t \r Z"], false],
["", "^X Y Z$", "A B\nC", ["X\n Y \t \r Z"], false],
];
From 549c7bdc721002400b047a3ed33dc4a7f1fa2acb Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Fri, 8 Jan 2021 15:47:19 -0500
Subject: [PATCH 105/366] Style fixes
---
lib/Database.php | 2 +-
lib/REST/Miniflux/V1.php | 4 ++--
lib/Rule/Exception.php | 2 +-
lib/Rule/Rule.php | 2 +-
lib/User.php | 8 ++++----
lib/User/Driver.php | 6 +++---
tests/cases/Database/SeriesUser.php | 4 ++--
tests/cases/Misc/TestRule.php | 8 +-------
tests/cases/REST/Miniflux/TestV1.php | 2 +-
tests/cases/User/TestInternal.php | 1 -
tests/cases/User/TestUser.php | 4 ++--
11 files changed, 18 insertions(+), 25 deletions(-)
diff --git a/lib/Database.php b/lib/Database.php
index f748aa7c..5e44d384 100644
--- a/lib/Database.php
+++ b/lib/Database.php
@@ -1209,7 +1209,7 @@ class Database {
*
* The result is an associative array whose keys are usernames, values
* being an array in turn with the following keys:
- *
+ *
* - "keep": The "keep" rule as a prepared pattern; any articles which fail to match this rule are hidden
* - "block": The block rule as a prepared pattern; any articles which match this rule are hidden
*/
diff --git a/lib/REST/Miniflux/V1.php b/lib/REST/Miniflux/V1.php
index 9ad2882c..f048e798 100644
--- a/lib/REST/Miniflux/V1.php
+++ b/lib/REST/Miniflux/V1.php
@@ -265,7 +265,7 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
}
}
//normalize user-specific input
- foreach (self::USER_META_MAP as $k => [,$d,]) {
+ foreach (self::USER_META_MAP as $k => [,$d]) {
$t = gettype($d);
if (!isset($body[$k])) {
$body[$k] = null;
@@ -343,7 +343,7 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
protected function editUser(string $user, array $data): array {
// map Miniflux properties to internal metadata properties
$in = [];
- foreach (self::USER_META_MAP as $i => [$o,,]) {
+ foreach (self::USER_META_MAP as $i => [$o,]) {
if (isset($data[$i])) {
if ($i === "entry_sorting_direction") {
$in[$o] = $data[$i] === "asc";
diff --git a/lib/Rule/Exception.php b/lib/Rule/Exception.php
index e3c66645..1239e378 100644
--- a/lib/Rule/Exception.php
+++ b/lib/Rule/Exception.php
@@ -7,4 +7,4 @@ declare(strict_types=1);
namespace JKingWeb\Arsse\Rule;
class Exception extends \JKingWeb\Arsse\AbstractException {
-}
\ No newline at end of file
+}
diff --git a/lib/Rule/Rule.php b/lib/Rule/Rule.php
index ee3c4a65..c8d41898 100644
--- a/lib/Rule/Rule.php
+++ b/lib/Rule/Rule.php
@@ -42,7 +42,7 @@ abstract class Rule {
}
/** applies keep and block rules against the title and categories of an article
- *
+ *
* Returns true if the article is to be kept, and false if it is to be suppressed
*/
public static function apply(string $keepPattern, string $blockPattern, string $title, array $categories = []): bool {
diff --git a/lib/User.php b/lib/User.php
index accec103..04748962 100644
--- a/lib/User.php
+++ b/lib/User.php
@@ -44,12 +44,12 @@ class User {
public function begin(): Db\Transaction {
/* TODO: A proper implementation of this would return a meta-transaction
- object which would contain both a user-manager transaction (when
+ object which would contain both a user-manager transaction (when
applicable) and a database transaction, and commit or roll back both
- as the situation calls.
+ as the situation calls.
In theory, an external user driver would probably have to implement its
- own approximation of atomic transactions and rollback. In practice the
+ own approximation of atomic transactions and rollback. In practice the
only driver is the internal one, which is always backed by an ACID
database; the added complexity is thus being deferred until such time
as it is actually needed for a concrete implementation.
@@ -106,7 +106,7 @@ class User {
}
public function rename(string $user, string $newName): bool {
- // ensure the new user name does not contain any U+003A COLON or
+ // ensure the new user name does not contain any U+003A COLON or
// control characters, as this is incompatible with HTTP Basic authentication
if (preg_match("/[\x{00}-\x{1F}\x{7F}:]/", $newName, $m)) {
$c = ord($m[0]);
diff --git a/lib/User/Driver.php b/lib/User/Driver.php
index d4b73706..87668791 100644
--- a/lib/User/Driver.php
+++ b/lib/User/Driver.php
@@ -28,10 +28,10 @@ interface Driver {
public function userAdd(string $user, string $password = null): ?string;
/** Renames a user
- *
- * The implementation must retain all user metadata as well as the
+ *
+ * The implementation must retain all user metadata as well as the
* user's password
- */
+ */
public function userRename(string $user, string $newName): bool;
/** Removes a user */
diff --git a/tests/cases/Database/SeriesUser.php b/tests/cases/Database/SeriesUser.php
index 0cd4ffb7..b56a64d8 100644
--- a/tests/cases/Database/SeriesUser.php
+++ b/tests/cases/Database/SeriesUser.php
@@ -184,8 +184,8 @@ trait SeriesUser {
public function testRenameAUser(): void {
$this->assertTrue(Arsse::$db->userRename("john.doe@example.com", "juan.doe@example.com"));
$state = $this->primeExpectations($this->data, [
- 'arsse_users' => ['id', 'num'],
- 'arsse_user_meta' => ["owner", "key", "value"]
+ 'arsse_users' => ['id', 'num'],
+ 'arsse_user_meta' => ["owner", "key", "value"],
]);
$state['arsse_users']['rows'][2][0] = "juan.doe@example.com";
$state['arsse_user_meta']['rows'][6][0] = "juan.doe@example.com";
diff --git a/tests/cases/Misc/TestRule.php b/tests/cases/Misc/TestRule.php
index 0d72f5fd..88503292 100644
--- a/tests/cases/Misc/TestRule.php
+++ b/tests/cases/Misc/TestRule.php
@@ -7,7 +7,6 @@ declare(strict_types=1);
namespace JKingWeb\Arsse\TestCase\Misc;
use JKingWeb\Arsse\Rule\Rule;
-use JKingWeb\Arsse\Rule\Exception;
/** @covers \JKingWeb\Arsse\Rule\Rule */
class TestRule extends \JKingWeb\Arsse\Test\AbstractTest {
@@ -32,12 +31,7 @@ class TestRule extends \JKingWeb\Arsse\Test\AbstractTest {
public function testApplyRules(string $keepRule, string $blockRule, string $title, array $categories, $exp): void {
$keepRule = Rule::prep($keepRule);
$blockRule = Rule::prep($blockRule);
- if ($exp instanceof \Exception) {
- $this->assertException($exp);
- Rule::apply($keepRule, $blockRule, $title, $categories);
- } else {
- $this->assertSame($exp, Rule::apply($keepRule, $blockRule, $title, $categories));
- }
+ $this->assertSame($exp, Rule::apply($keepRule, $blockRule, $title, $categories));
}
public function provideApplications(): iterable {
diff --git a/tests/cases/REST/Miniflux/TestV1.php b/tests/cases/REST/Miniflux/TestV1.php
index 255d9a55..496df378 100644
--- a/tests/cases/REST/Miniflux/TestV1.php
+++ b/tests/cases/REST/Miniflux/TestV1.php
@@ -451,7 +451,7 @@ class TestV1 extends \JKingWeb\Arsse\Test\AbstractTest {
["", new ErrorResponse(["InvalidCategory", 'title' => ""], 422)],
[" ", new ErrorResponse(["InvalidCategory", 'title' => " "], 422)],
[null, new ErrorResponse(["MissingInputValue", 'field' => "title"], 422)],
- [false, new ErrorResponse(["InvalidInputType", 'field' => "title", 'actual' => "boolean", 'expected' => "string"],422)],
+ [false, new ErrorResponse(["InvalidInputType", 'field' => "title", 'actual' => "boolean", 'expected' => "string"], 422)],
];
}
diff --git a/tests/cases/User/TestInternal.php b/tests/cases/User/TestInternal.php
index 858a8765..fa42de17 100644
--- a/tests/cases/User/TestInternal.php
+++ b/tests/cases/User/TestInternal.php
@@ -9,7 +9,6 @@ namespace JKingWeb\Arsse\TestCase\User;
use JKingWeb\Arsse\Arsse;
use JKingWeb\Arsse\Database;
use JKingWeb\Arsse\User\Driver as DriverInterface;
-use JKingWeb\Arsse\User\ExceptionConflict;
use JKingWeb\Arsse\User\Internal\Driver;
/** @covers \JKingWeb\Arsse\User\Internal\Driver */
diff --git a/tests/cases/User/TestUser.php b/tests/cases/User/TestUser.php
index 7c87e0c3..e9a45c9f 100644
--- a/tests/cases/User/TestUser.php
+++ b/tests/cases/User/TestUser.php
@@ -200,7 +200,7 @@ class TestUser extends \JKingWeb\Arsse\Test\AbstractTest {
\Phake::when($this->drv)->userRename->thenReturn(true);
$u = new User($this->drv);
$old = "john.doe@example.com";
- $new = "jane.doe@example.com";
+ $new = "jane.doe@example.com";
$this->assertTrue($u->rename($old, $new));
\Phake::inOrder(
\Phake::verify($this->drv)->userRename($old, $new),
@@ -222,7 +222,7 @@ class TestUser extends \JKingWeb\Arsse\Test\AbstractTest {
\Phake::when($this->drv)->userRename->thenReturn(true);
$u = new User($this->drv);
$old = "john.doe@example.com";
- $new = "jane.doe@example.com";
+ $new = "jane.doe@example.com";
$this->assertTrue($u->rename($old, $new));
\Phake::inOrder(
\Phake::verify($this->drv)->userRename($old, $new),
From 9e29235d87e57fdc4ca4a529c0319dacc287cb34 Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Fri, 8 Jan 2021 16:46:21 -0500
Subject: [PATCH 106/366] Don't fetch from example.com during tests
---
tests/cases/ImportExport/TestOPML.php | 44 +++++++++----------
tests/docroot/Feed/Caching/200Future.php | 2 +-
tests/docroot/Feed/Caching/200Multiple.php | 2 +-
tests/docroot/Feed/Caching/200None.php | 2 +-
tests/docroot/Feed/Caching/200Past.php | 2 +-
tests/docroot/Feed/Caching/200PubDateOnly.php | 2 +-
tests/docroot/Feed/Caching/200UpdateDate.php | 2 +-
.../Feed/Deduplication/Hashes-Dates1.php | 2 +-
.../Feed/Deduplication/Hashes-Dates2.php | 2 +-
.../Feed/Deduplication/Hashes-Dates3.php | 2 +-
tests/docroot/Feed/Deduplication/Hashes.php | 2 +-
tests/docroot/Feed/Deduplication/ID-Dates.php | 2 +-
.../Feed/Deduplication/IdenticalHashes.php | 2 +-
.../Feed/Deduplication/Permalink-Dates.php | 10 ++---
tests/docroot/Feed/Discovery/Feed.php | 2 +-
tests/docroot/Feed/Fetching/TooLarge.php | 2 +-
tests/docroot/Feed/Matching/1.php | 2 +-
tests/docroot/Feed/Matching/2.php | 2 +-
tests/docroot/Feed/Matching/3.php | 2 +-
tests/docroot/Feed/Matching/4.php | 2 +-
tests/docroot/Feed/Matching/5.php | 2 +-
tests/docroot/Feed/NextFetch/1h.php | 2 +-
tests/docroot/Feed/NextFetch/3-36h.php | 2 +-
tests/docroot/Feed/NextFetch/30m.php | 2 +-
tests/docroot/Feed/NextFetch/36h.php | 2 +-
tests/docroot/Feed/NextFetch/3h.php | 2 +-
tests/docroot/Feed/NextFetch/Fallback.php | 2 +-
tests/docroot/Feed/Parsing/Valid.php | 2 +-
tests/docroot/Feed/Parsing/XEEAttack.php | 10 ++---
tests/docroot/Feed/Parsing/XXEAttack.php | 10 ++---
tests/docroot/Feed/Scraping/Feed.php | 2 +-
tests/docroot/Import/OPML/BrokenOPML.2.opml | 2 +-
tests/docroot/Import/OPML/FeedsOnly.opml | 10 ++---
tests/docroot/Import/some-feed.php | 2 +-
tests/docroot/index.php | 4 ++
tests/server.php | 3 ++
36 files changed, 78 insertions(+), 71 deletions(-)
create mode 100644 tests/docroot/index.php
diff --git a/tests/cases/ImportExport/TestOPML.php b/tests/cases/ImportExport/TestOPML.php
index 3c61688e..469c674b 100644
--- a/tests/cases/ImportExport/TestOPML.php
+++ b/tests/cases/ImportExport/TestOPML.php
@@ -22,12 +22,12 @@ class TestOPML extends \JKingWeb\Arsse\Test\AbstractTest {
['id' => 1, 'parent' => null, 'children' => 1, 'feeds' => 1, 'name' => "Science"],
];
protected $subscriptions = [
- ['id' => 3, 'folder' => 1, 'top_folder' => 1, 'unread' => 2, 'updated' => "2016-05-23 06:40:02", 'err_msg' => 'argh', 'title' => 'Ars Technica', 'url' => "http://example.com/3", 'favicon' => 'http://example.com/3.png'],
- ['id' => 4, 'folder' => 6, 'top_folder' => 3, 'unread' => 6, 'updated' => "2017-10-09 15:58:34", 'err_msg' => '', 'title' => 'CBC News', 'url' => "http://example.com/4", 'favicon' => 'http://example.com/4.png'],
- ['id' => 6, 'folder' => null, 'top_folder' => null, 'unread' => 0, 'updated' => "2010-02-12 20:08:47", 'err_msg' => '', 'title' => 'Eurogamer', 'url' => "http://example.com/6", 'favicon' => 'http://example.com/6.png'],
- ['id' => 1, 'folder' => 2, 'top_folder' => 1, 'unread' => 5, 'updated' => "2017-09-15 22:54:16", 'err_msg' => '', 'title' => 'NASA JPL', 'url' => "http://example.com/1", 'favicon' => null],
- ['id' => 5, 'folder' => 6, 'top_folder' => 3, 'unread' => 12, 'updated' => "2017-07-07 17:07:17", 'err_msg' => '', 'title' => 'Ottawa Citizen', 'url' => "http://example.com/5", 'favicon' => ''],
- ['id' => 2, 'folder' => 5, 'top_folder' => 3, 'unread' => 10, 'updated' => "2011-11-11 11:11:11", 'err_msg' => 'oops', 'title' => 'Toronto Star', 'url' => "http://example.com/2", 'favicon' => 'http://example.com/2.png'],
+ ['id' => 3, 'folder' => 1, 'top_folder' => 1, 'unread' => 2, 'updated' => "2016-05-23 06:40:02", 'err_msg' => 'argh', 'title' => 'Ars Technica', 'url' => "http://localhost:8000/3", 'favicon' => 'http://localhost:8000/3.png'],
+ ['id' => 4, 'folder' => 6, 'top_folder' => 3, 'unread' => 6, 'updated' => "2017-10-09 15:58:34", 'err_msg' => '', 'title' => 'CBC News', 'url' => "http://localhost:8000/4", 'favicon' => 'http://localhost:8000/4.png'],
+ ['id' => 6, 'folder' => null, 'top_folder' => null, 'unread' => 0, 'updated' => "2010-02-12 20:08:47", 'err_msg' => '', 'title' => 'Eurogamer', 'url' => "http://localhost:8000/6", 'favicon' => 'http://localhost:8000/6.png'],
+ ['id' => 1, 'folder' => 2, 'top_folder' => 1, 'unread' => 5, 'updated' => "2017-09-15 22:54:16", 'err_msg' => '', 'title' => 'NASA JPL', 'url' => "http://localhost:8000/1", 'favicon' => null],
+ ['id' => 5, 'folder' => 6, 'top_folder' => 3, 'unread' => 12, 'updated' => "2017-07-07 17:07:17", 'err_msg' => '', 'title' => 'Ottawa Citizen', 'url' => "http://localhost:8000/5", 'favicon' => ''],
+ ['id' => 2, 'folder' => 5, 'top_folder' => 3, 'unread' => 10, 'updated' => "2011-11-11 11:11:11", 'err_msg' => 'oops', 'title' => 'Toronto Star', 'url' => "http://localhost:8000/2", 'favicon' => 'http://localhost:8000/2.png'],
];
protected $tags = [
['id' => 1, 'name' => "Canada", 'subscription' => 2],
@@ -47,20 +47,20 @@ class TestOPML extends \JKingWeb\Arsse\Test\AbstractTest {
-
+
-
-
+
+
-
+
-
+
-
+
-
-
-
-
-
-
+
+
+
+
+
+
OPML_EXPORT_SERIALIZATION;
@@ -129,10 +129,10 @@ OPML_EXPORT_SERIALIZATION;
["Empty.2.opml", false, [[], []]],
["Empty.3.opml", false, [[], []]],
["FeedsOnly.opml", false, [[
- ['url' => "http://example.com/1", 'title' => "Feed 1", 'folder' => 0, 'tags' => []],
- ['url' => "http://example.com/2", 'title' => "", 'folder' => 0, 'tags' => []],
- ['url' => "http://example.com/3", 'title' => "", 'folder' => 0, 'tags' => []],
- ['url' => "http://example.com/4", 'title' => "", 'folder' => 0, 'tags' => []],
+ ['url' => "http://localhost:8000/1", 'title' => "Feed 1", 'folder' => 0, 'tags' => []],
+ ['url' => "http://localhost:8000/2", 'title' => "", 'folder' => 0, 'tags' => []],
+ ['url' => "http://localhost:8000/3", 'title' => "", 'folder' => 0, 'tags' => []],
+ ['url' => "http://localhost:8000/4", 'title' => "", 'folder' => 0, 'tags' => []],
['url' => "", 'title' => "", 'folder' => 0, 'tags' => ["whee"]],
['url' => "", 'title' => "", 'folder' => 0, 'tags' => ["whee", "whoo"]],
], []]],
diff --git a/tests/docroot/Feed/Caching/200Future.php b/tests/docroot/Feed/Caching/200Future.php
index ef2ae714..ad43e361 100644
--- a/tests/docroot/Feed/Caching/200Future.php
+++ b/tests/docroot/Feed/Caching/200Future.php
@@ -6,7 +6,7 @@
Test feed
- http://example.com/
+ http://localhost:8000/
A basic feed for testing
diff --git a/tests/docroot/Feed/Caching/200Multiple.php b/tests/docroot/Feed/Caching/200Multiple.php
index 583b6633..ebbd8a29 100644
--- a/tests/docroot/Feed/Caching/200Multiple.php
+++ b/tests/docroot/Feed/Caching/200Multiple.php
@@ -6,7 +6,7 @@
Test feed
- http://example.com/
+ http://localhost:8000/
A basic feed for testing
-
diff --git a/tests/docroot/Feed/Caching/200None.php b/tests/docroot/Feed/Caching/200None.php
index 562554cf..ebe7721f 100644
--- a/tests/docroot/Feed/Caching/200None.php
+++ b/tests/docroot/Feed/Caching/200None.php
@@ -6,7 +6,7 @@
Test feed
- http://example.com/
+ http://localhost:8000/
A basic feed for testing
-
diff --git a/tests/docroot/Feed/Caching/200Past.php b/tests/docroot/Feed/Caching/200Past.php
index 361d7670..64da54c5 100644
--- a/tests/docroot/Feed/Caching/200Past.php
+++ b/tests/docroot/Feed/Caching/200Past.php
@@ -6,7 +6,7 @@
Test feed
- http://example.com/
+ http://localhost:8000/
A basic feed for testing
diff --git a/tests/docroot/Feed/Caching/200PubDateOnly.php b/tests/docroot/Feed/Caching/200PubDateOnly.php
index 5b8df9b0..93ce6370 100644
--- a/tests/docroot/Feed/Caching/200PubDateOnly.php
+++ b/tests/docroot/Feed/Caching/200PubDateOnly.php
@@ -6,7 +6,7 @@
Test feed
- http://example.com/
+ http://localhost:8000/
A basic feed for testing
-
diff --git a/tests/docroot/Feed/Caching/200UpdateDate.php b/tests/docroot/Feed/Caching/200UpdateDate.php
index e7f9a20b..3315d289 100644
--- a/tests/docroot/Feed/Caching/200UpdateDate.php
+++ b/tests/docroot/Feed/Caching/200UpdateDate.php
@@ -6,7 +6,7 @@
Test feed
- http://example.com/
+ http://localhost:8000/
A basic feed for testing
-
diff --git a/tests/docroot/Feed/Deduplication/Hashes-Dates1.php b/tests/docroot/Feed/Deduplication/Hashes-Dates1.php
index 4709e807..8449beed 100644
--- a/tests/docroot/Feed/Deduplication/Hashes-Dates1.php
+++ b/tests/docroot/Feed/Deduplication/Hashes-Dates1.php
@@ -4,7 +4,7 @@
Test feed
- http://example.com/
+ http://localhost:8000/
A basic feed for testing
-
diff --git a/tests/docroot/Feed/Deduplication/Hashes-Dates2.php b/tests/docroot/Feed/Deduplication/Hashes-Dates2.php
index 321d675d..b460c7d8 100644
--- a/tests/docroot/Feed/Deduplication/Hashes-Dates2.php
+++ b/tests/docroot/Feed/Deduplication/Hashes-Dates2.php
@@ -4,7 +4,7 @@
Test feed
- http://example.com/
+ http://localhost:8000/
A basic feed for testing
-
diff --git a/tests/docroot/Feed/Deduplication/Hashes-Dates3.php b/tests/docroot/Feed/Deduplication/Hashes-Dates3.php
index 01d0916d..ce950194 100644
--- a/tests/docroot/Feed/Deduplication/Hashes-Dates3.php
+++ b/tests/docroot/Feed/Deduplication/Hashes-Dates3.php
@@ -4,7 +4,7 @@
Test feed
- http://example.com/
+ http://localhost:8000/
A basic feed for testing
-
diff --git a/tests/docroot/Feed/Deduplication/Hashes.php b/tests/docroot/Feed/Deduplication/Hashes.php
index bc6eaec4..2f2a9677 100644
--- a/tests/docroot/Feed/Deduplication/Hashes.php
+++ b/tests/docroot/Feed/Deduplication/Hashes.php
@@ -4,7 +4,7 @@
Test feed
- http://example.com/
+ http://localhost:8000/
A basic feed for testing
-
diff --git a/tests/docroot/Feed/Deduplication/ID-Dates.php b/tests/docroot/Feed/Deduplication/ID-Dates.php
index f26cfc50..90f70260 100644
--- a/tests/docroot/Feed/Deduplication/ID-Dates.php
+++ b/tests/docroot/Feed/Deduplication/ID-Dates.php
@@ -4,7 +4,7 @@
Test feed
- http://example.com/
+ http://localhost:8000/
A basic feed for testing
-
diff --git a/tests/docroot/Feed/Deduplication/IdenticalHashes.php b/tests/docroot/Feed/Deduplication/IdenticalHashes.php
index 138b7b44..b9e64667 100644
--- a/tests/docroot/Feed/Deduplication/IdenticalHashes.php
+++ b/tests/docroot/Feed/Deduplication/IdenticalHashes.php
@@ -4,7 +4,7 @@
Test feed
- http://example.com/
+ http://localhost:8000/
A basic feed for testing
-
diff --git a/tests/docroot/Feed/Deduplication/Permalink-Dates.php b/tests/docroot/Feed/Deduplication/Permalink-Dates.php
index 304211a8..afb91548 100644
--- a/tests/docroot/Feed/Deduplication/Permalink-Dates.php
+++ b/tests/docroot/Feed/Deduplication/Permalink-Dates.php
@@ -4,29 +4,29 @@
Test feed
- http://example.com/
+ http://localhost:8000/
A basic feed for testing
-
-
http://example.com/1
+ http://localhost:8000/1
Sample article 1
Sun, 18 May 1995 15:21:36 GMT
2002-02-19T15:21:36Z
-
-
http://example.com/1
+ http://localhost:8000/1
Sample article 2
Sun, 19 May 2002 15:21:36 GMT
2002-04-19T15:21:36Z
-
-
http://example.com/1
+ http://localhost:8000/1
Sample article 3
Sun, 18 May 2000 15:21:36 GMT
1999-05-19T15:21:36Z
-
-
http://example.com/2
+ http://localhost:8000/2
Sample article 4
Sun, 18 May 2000 15:21:36 GMT
1999-05-19T15:21:36Z
diff --git a/tests/docroot/Feed/Discovery/Feed.php b/tests/docroot/Feed/Discovery/Feed.php
index a13398ac..bb413510 100644
--- a/tests/docroot/Feed/Discovery/Feed.php
+++ b/tests/docroot/Feed/Discovery/Feed.php
@@ -4,7 +4,7 @@
Test feed
- http://example.com/
+ http://localhost:8000/
Example newsfeed title
diff --git a/tests/docroot/Feed/Fetching/TooLarge.php b/tests/docroot/Feed/Fetching/TooLarge.php
index 0fef567b..d13f89c6 100644
--- a/tests/docroot/Feed/Fetching/TooLarge.php
+++ b/tests/docroot/Feed/Fetching/TooLarge.php
@@ -9,7 +9,7 @@ return [
Test feed
- http://example.com/
+ http://localhost:8000/
Example newsfeed title
$item
diff --git a/tests/docroot/Feed/Matching/1.php b/tests/docroot/Feed/Matching/1.php
index fdc2d676..ef9dfcd0 100644
--- a/tests/docroot/Feed/Matching/1.php
+++ b/tests/docroot/Feed/Matching/1.php
@@ -4,7 +4,7 @@
Example feed title
urn:uuid:0fd8f6d8-43df-11e7-8511-9b59a0324eb8
-
+
urn:uuid:df329114-43df-11e7-9f23-a938604d62f8
diff --git a/tests/docroot/Feed/Matching/2.php b/tests/docroot/Feed/Matching/2.php
index b5e2d51c..0a4ae553 100644
--- a/tests/docroot/Feed/Matching/2.php
+++ b/tests/docroot/Feed/Matching/2.php
@@ -4,7 +4,7 @@
Example feed title
urn:uuid:0fd8f6d8-43df-11e7-8511-9b59a0324eb8
-
+
urn:uuid:df329114-43df-11e7-9f23-a938604d62f8
diff --git a/tests/docroot/Feed/Matching/3.php b/tests/docroot/Feed/Matching/3.php
index d2c8c0d3..665d5d3a 100644
--- a/tests/docroot/Feed/Matching/3.php
+++ b/tests/docroot/Feed/Matching/3.php
@@ -4,7 +4,7 @@
Example feed title
urn:uuid:0fd8f6d8-43df-11e7-8511-9b59a0324eb8
-
+
urn:uuid:df329114-43df-11e7-9f23-a938604d62f8
diff --git a/tests/docroot/Feed/Matching/4.php b/tests/docroot/Feed/Matching/4.php
index a68c0e05..5cd92493 100644
--- a/tests/docroot/Feed/Matching/4.php
+++ b/tests/docroot/Feed/Matching/4.php
@@ -4,7 +4,7 @@
Example feed title
urn:uuid:0fd8f6d8-43df-11e7-8511-9b59a0324eb8
-
+
urn:uuid:df329114-43df-11e7-9f23-a938604d62f8
diff --git a/tests/docroot/Feed/Matching/5.php b/tests/docroot/Feed/Matching/5.php
index efb5a9b3..de61d767 100644
--- a/tests/docroot/Feed/Matching/5.php
+++ b/tests/docroot/Feed/Matching/5.php
@@ -4,7 +4,7 @@
Example feed title
urn:uuid:0fd8f6d8-43df-11e7-8511-9b59a0324eb8
-
+
urn:uuid:3d5f5154-43e1-11e7-ba11-1dcae392a974
diff --git a/tests/docroot/Feed/NextFetch/1h.php b/tests/docroot/Feed/NextFetch/1h.php
index dd016507..ca9cdac6 100644
--- a/tests/docroot/Feed/NextFetch/1h.php
+++ b/tests/docroot/Feed/NextFetch/1h.php
@@ -4,7 +4,7 @@
Example title
- http://example.com
+ http://localhost:8000/
Example description
-
diff --git a/tests/docroot/Feed/NextFetch/3-36h.php b/tests/docroot/Feed/NextFetch/3-36h.php
index 41d799f8..414d2f08 100644
--- a/tests/docroot/Feed/NextFetch/3-36h.php
+++ b/tests/docroot/Feed/NextFetch/3-36h.php
@@ -4,7 +4,7 @@
Example title
- http://example.com
+ http://localhost:8000/
Example description
-
diff --git a/tests/docroot/Feed/NextFetch/30m.php b/tests/docroot/Feed/NextFetch/30m.php
index a7dce241..397871ac 100644
--- a/tests/docroot/Feed/NextFetch/30m.php
+++ b/tests/docroot/Feed/NextFetch/30m.php
@@ -4,7 +4,7 @@
Example title
- http://example.com
+ http://localhost:8000/
Example description
-
diff --git a/tests/docroot/Feed/NextFetch/36h.php b/tests/docroot/Feed/NextFetch/36h.php
index 359ed9e9..251a456a 100644
--- a/tests/docroot/Feed/NextFetch/36h.php
+++ b/tests/docroot/Feed/NextFetch/36h.php
@@ -4,7 +4,7 @@
Example title
- http://example.com
+ http://localhost:8000/
Example description
-
diff --git a/tests/docroot/Feed/NextFetch/3h.php b/tests/docroot/Feed/NextFetch/3h.php
index e2f5758d..dfdd3272 100644
--- a/tests/docroot/Feed/NextFetch/3h.php
+++ b/tests/docroot/Feed/NextFetch/3h.php
@@ -4,7 +4,7 @@
Example title
- http://example.com
+ http://localhost:8000/
Example description
-
diff --git a/tests/docroot/Feed/NextFetch/Fallback.php b/tests/docroot/Feed/NextFetch/Fallback.php
index 04cc8ac0..d127c3a3 100644
--- a/tests/docroot/Feed/NextFetch/Fallback.php
+++ b/tests/docroot/Feed/NextFetch/Fallback.php
@@ -4,7 +4,7 @@
Example title
- http://example.com
+ http://localhost:8000/
Example description
-
diff --git a/tests/docroot/Feed/Parsing/Valid.php b/tests/docroot/Feed/Parsing/Valid.php
index f56bd66b..ab953fc9 100644
--- a/tests/docroot/Feed/Parsing/Valid.php
+++ b/tests/docroot/Feed/Parsing/Valid.php
@@ -4,7 +4,7 @@
Test feed
- http://example.com/
+ http://localhost:8000/
Example newsfeed title
-
diff --git a/tests/docroot/Feed/Parsing/XEEAttack.php b/tests/docroot/Feed/Parsing/XEEAttack.php
index 12c4cbf7..a4fa7fe7 100644
--- a/tests/docroot/Feed/Parsing/XEEAttack.php
+++ b/tests/docroot/Feed/Parsing/XEEAttack.php
@@ -16,30 +16,30 @@
Test feed
- http://example.com/
+ http://localhost:8000/
Example newsfeed title
-
urn:uuid:43fb1908-42ec-11e7-b61b-2b118faca2f2
- http://example.com/1
+ http://localhost:8000/1
urn:uuid:4c8dbc84-42eb-11e7-9f61-6f83db96854f
-
urn:uuid:43fb1908-42ec-11e7-b61b-2b118faca2f2
- http://example.com/1
+ http://localhost:8000/1
-
urn:uuid:43fb1908-42ec-11e7-b61b-2b118faca2f2
-
-
http://example.com/2
+ http://localhost:8000/2
-
Example title
-
Example content
-
+
diff --git a/tests/docroot/Feed/Parsing/XXEAttack.php b/tests/docroot/Feed/Parsing/XXEAttack.php
index 8a38e142..c1c51487 100644
--- a/tests/docroot/Feed/Parsing/XXEAttack.php
+++ b/tests/docroot/Feed/Parsing/XXEAttack.php
@@ -7,30 +7,30 @@
Test feed
- http://example.com/
+ http://localhost:8000/
&xxe;
-
urn:uuid:43fb1908-42ec-11e7-b61b-2b118faca2f2
- http://example.com/1
+ http://localhost:8000/1
urn:uuid:4c8dbc84-42eb-11e7-9f61-6f83db96854f
-
urn:uuid:43fb1908-42ec-11e7-b61b-2b118faca2f2
- http://example.com/1
+ http://localhost:8000/1
-
urn:uuid:43fb1908-42ec-11e7-b61b-2b118faca2f2
-
-
http://example.com/2
+ http://localhost:8000/2
-
Example title
-
Example content
-
+
diff --git a/tests/docroot/Feed/Scraping/Feed.php b/tests/docroot/Feed/Scraping/Feed.php
index 71bf40ec..514dcfd0 100644
--- a/tests/docroot/Feed/Scraping/Feed.php
+++ b/tests/docroot/Feed/Scraping/Feed.php
@@ -4,7 +4,7 @@
Test feed
- http://example.com/
+ http://localhost:8000/
Example newsfeed title
-
diff --git a/tests/docroot/Import/OPML/BrokenOPML.2.opml b/tests/docroot/Import/OPML/BrokenOPML.2.opml
index ac70153f..691c2bd7 100644
--- a/tests/docroot/Import/OPML/BrokenOPML.2.opml
+++ b/tests/docroot/Import/OPML/BrokenOPML.2.opml
@@ -1,2 +1,2 @@
-
+
diff --git a/tests/docroot/Import/OPML/FeedsOnly.opml b/tests/docroot/Import/OPML/FeedsOnly.opml
index 4e682600..88fab76d 100644
--- a/tests/docroot/Import/OPML/FeedsOnly.opml
+++ b/tests/docroot/Import/OPML/FeedsOnly.opml
@@ -1,10 +1,10 @@
-
-
-
-
-
+
+
+
+
+
diff --git a/tests/docroot/Import/some-feed.php b/tests/docroot/Import/some-feed.php
index eec58567..7f48836f 100644
--- a/tests/docroot/Import/some-feed.php
+++ b/tests/docroot/Import/some-feed.php
@@ -4,7 +4,7 @@
Some feed
- http://example.com/
+ http://localhost:8000/
Just a generic feed
-
diff --git a/tests/docroot/index.php b/tests/docroot/index.php
new file mode 100644
index 00000000..4a6611e6
--- /dev/null
+++ b/tests/docroot/index.php
@@ -0,0 +1,4 @@
+ 204,
+ 'content' => "",
+];
diff --git a/tests/server.php b/tests/server.php
index 2d738c95..2e7d8d47 100644
--- a/tests/server.php
+++ b/tests/server.php
@@ -41,6 +41,9 @@ $defaults = [ // default values for response
];
$url = explode("?", $_SERVER['REQUEST_URI'])[0];
+if ($url === "/") {
+ $url = "/index";
+}
$base = BASE."tests".\DIRECTORY_SEPARATOR."docroot";
$test = $base.str_replace("/", \DIRECTORY_SEPARATOR, $url).".php";
if (!file_exists($test)) {
From a4146ec129f9337ab7236897137b75ae9a19897e Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Mon, 11 Jan 2021 09:53:09 -0500
Subject: [PATCH 107/366] Start on test for filtering during feed parsing
---
tests/cases/Feed/TestFeed.php | 14 +++++++
tests/docroot/Feed/Filtering/1.php | 61 ++++++++++++++++++++++++++++++
2 files changed, 75 insertions(+)
create mode 100644 tests/docroot/Feed/Filtering/1.php
diff --git a/tests/cases/Feed/TestFeed.php b/tests/cases/Feed/TestFeed.php
index cb94c5e7..a10a476a 100644
--- a/tests/cases/Feed/TestFeed.php
+++ b/tests/cases/Feed/TestFeed.php
@@ -95,6 +95,8 @@ class TestFeed extends \JKingWeb\Arsse\Test\AbstractTest {
self::clearData();
self::setConf();
Arsse::$db = \Phake::mock(Database::class);
+ \Phake::when(Arsse::$db)->feedMatchLatest->thenReturn(new Result([]));
+ \Phake::when(Arsse::$db)->feedMatchIds->thenReturn(new Result([]));
\Phake::when(Arsse::$db)->feedRulesGet->thenReturn([]);
}
@@ -377,4 +379,16 @@ class TestFeed extends \JKingWeb\Arsse\Test\AbstractTest {
$this->assertSame("image/gif", $f->iconType);
$this->assertSame($d, $f->iconData);
}
+
+ public function testApplyFilterRules(): void {
+ \Phake::when(Arsse::$db)->feedMatchIds->thenReturn(new Result([
+ ['id' => 7, 'guid' => '0f2a218c311e3d8105f1b075142a5d26dabf056ffc61abe77e96c8f071bbf4a7', 'url_title_hash' => "", 'url_content_hash' => '', 'title_content_hash' => ''],
+ ['id' => 47, 'guid' => '1c19e3b9018bc246b7414ae919ddebc88d0c575129e8c4a57b84b826c00f6db5', 'url_title_hash' => "", 'url_content_hash' => '', 'title_content_hash' => ''],
+ ['id' => 2112, 'guid' => '964db0b9292ad0c7a6c225f2e0966f3bda53486fae65db0310c97409974e65b8', 'url_title_hash' => "", 'url_content_hash' => '', 'title_content_hash' => ''],
+ ['id' => 1, 'guid' => '436070cda5713a0d9a8fdc8652c7ab142f0550697acfd5206a16c18aee355039', 'url_title_hash' => "", 'url_content_hash' => '', 'title_content_hash' => ''],
+ ['id' => 42, 'guid' => '1a731433a1904220ef26e731ada7262e1d5bcecae53e7b5df9e1f5713af6e5d3', 'url_title_hash' => "", 'url_content_hash' => '', 'title_content_hash' => ''],
+ ]));
+ $f = new Feed(null, $this->base."Filtering/1");
+ $this->markTestIncomplete();
+ }
}
diff --git a/tests/docroot/Feed/Filtering/1.php b/tests/docroot/Feed/Filtering/1.php
new file mode 100644
index 00000000..d7a1d222
--- /dev/null
+++ b/tests/docroot/Feed/Filtering/1.php
@@ -0,0 +1,61 @@
+ "application/atom+xml",
+ 'content' => <<
+ Example feed title
+ urn:uuid:0fd8f6d8-43df-11e7-8511-9b59a0324eb8
+
+
+
+ urn:uuid:6d4c7964-43e1-11e7-92bd-4fed65d89790
+ A
+ Z
+
+
+ urn:uuid:6d4c7964-43e1-11e7-92bd-4fed65d89791
+ B
+ Y
+
+
+ urn:uuid:6d4c7964-43e1-11e7-92bd-4fed65d89792
+ C
+ X
+
+
+ urn:uuid:6d4c7964-43e1-11e7-92bd-4fed65d89793
+ D
+ W
+
+
+ urn:uuid:6d4c7964-43e1-11e7-92bd-4fed65d89794
+ E
+ V
+
+
+ urn:uuid:6d4c7964-43e1-11e7-92bd-4fed65d89795
+ F
+ U
+
+
+ urn:uuid:6d4c7964-43e1-11e7-92bd-4fed65d89796
+ T
+ Z
+
+
+ urn:uuid:6d4c7964-43e1-11e7-92bd-4fed65d89797
+ S
+ Z
+
+
+ urn:uuid:6d4c7964-43e1-11e7-92bd-4fed65d89798
+ R
+ Z
+
+
+ urn:uuid:6d4c7964-43e1-11e7-92bd-4fed65d89799
+ Q
+ Z
+
+
+MESSAGE_BODY
+];
From 097362881b6f62197f619984a411a5de6783a032 Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Mon, 11 Jan 2021 23:12:43 -0500
Subject: [PATCH 108/366] Tests for filtering during feed parsing
---
lib/Feed.php | 2 +-
tests/cases/Feed/TestFeed.php | 23 ++++++++++++++++-------
tests/docroot/Feed/Filtering/1.php | 16 ++++++++--------
3 files changed, 25 insertions(+), 16 deletions(-)
diff --git a/lib/Feed.php b/lib/Feed.php
index b0e91290..e96d064c 100644
--- a/lib/Feed.php
+++ b/lib/Feed.php
@@ -286,7 +286,7 @@ class Feed {
$articles = Arsse::$db->feedMatchLatest($feedID, sizeof($items))->getAll();
// perform a first pass matching the latest articles against items in the feed
[$this->newItems, $this->changedItems] = $this->matchItems($items, $articles);
- if (sizeof($this->newItems) && sizeof($items) <= sizeof($articles)) {
+ if (sizeof($this->newItems)) {
// if we need to, perform a second pass on the database looking specifically for IDs and hashes of the new items
$ids = $hashesUT = $hashesUC = $hashesTC = [];
foreach ($this->newItems as $i) {
diff --git a/tests/cases/Feed/TestFeed.php b/tests/cases/Feed/TestFeed.php
index a10a476a..51799940 100644
--- a/tests/cases/Feed/TestFeed.php
+++ b/tests/cases/Feed/TestFeed.php
@@ -382,13 +382,22 @@ class TestFeed extends \JKingWeb\Arsse\Test\AbstractTest {
public function testApplyFilterRules(): void {
\Phake::when(Arsse::$db)->feedMatchIds->thenReturn(new Result([
- ['id' => 7, 'guid' => '0f2a218c311e3d8105f1b075142a5d26dabf056ffc61abe77e96c8f071bbf4a7', 'url_title_hash' => "", 'url_content_hash' => '', 'title_content_hash' => ''],
- ['id' => 47, 'guid' => '1c19e3b9018bc246b7414ae919ddebc88d0c575129e8c4a57b84b826c00f6db5', 'url_title_hash' => "", 'url_content_hash' => '', 'title_content_hash' => ''],
- ['id' => 2112, 'guid' => '964db0b9292ad0c7a6c225f2e0966f3bda53486fae65db0310c97409974e65b8', 'url_title_hash' => "", 'url_content_hash' => '', 'title_content_hash' => ''],
- ['id' => 1, 'guid' => '436070cda5713a0d9a8fdc8652c7ab142f0550697acfd5206a16c18aee355039', 'url_title_hash' => "", 'url_content_hash' => '', 'title_content_hash' => ''],
- ['id' => 42, 'guid' => '1a731433a1904220ef26e731ada7262e1d5bcecae53e7b5df9e1f5713af6e5d3', 'url_title_hash' => "", 'url_content_hash' => '', 'title_content_hash' => ''],
+ // these are the sixth through tenth entries in the feed; the title hashes have been omitted for brevity
+ ['id' => 7, 'guid' => '0f2a218c311e3d8105f1b075142a5d26dabf056ffc61abe77e96c8f071bbf4a7', 'edited' => null, 'url_title_hash' => "", 'url_content_hash' => '', 'title_content_hash' => ''],
+ ['id' => 47, 'guid' => '1c19e3b9018bc246b7414ae919ddebc88d0c575129e8c4a57b84b826c00f6db5', 'edited' => null, 'url_title_hash' => "", 'url_content_hash' => '', 'title_content_hash' => ''],
+ ['id' => 2112, 'guid' => '964db0b9292ad0c7a6c225f2e0966f3bda53486fae65db0310c97409974e65b8', 'edited' => null, 'url_title_hash' => "", 'url_content_hash' => '', 'title_content_hash' => ''],
+ ['id' => 1, 'guid' => '436070cda5713a0d9a8fdc8652c7ab142f0550697acfd5206a16c18aee355039', 'edited' => null, 'url_title_hash' => "", 'url_content_hash' => '', 'title_content_hash' => ''],
+ ['id' => 42, 'guid' => '1a731433a1904220ef26e731ada7262e1d5bcecae53e7b5df9e1f5713af6e5d3', 'edited' => null, 'url_title_hash' => "", 'url_content_hash' => '', 'title_content_hash' => ''],
]));
- $f = new Feed(null, $this->base."Filtering/1");
- $this->markTestIncomplete();
+ \Phake::when(Arsse::$db)->feedRulesGet->thenReturn([
+ 'jack' => ['keep' => "", 'block' => '`A|W|J|S`u'],
+ 'sam' => ['keep' => "`B|T|X`u", 'block' => '`C`u'],
+ ]);
+ $f = new Feed(5, $this->base."Filtering/1");
+ $exp = [
+ 'jack' => ['new' => [false, true, true, false, true], 'changed' => [7 => true, 47 => true, 2112 => false, 1 => true, 42 => false]],
+ 'sam' => ['new' => [false, true, false, false, false], 'changed' => [7 => false, 47 => true, 2112 => false, 1 => false, 42 => false]],
+ ];
+ $this->assertSame($exp, $f->filteredItems);
}
}
diff --git a/tests/docroot/Feed/Filtering/1.php b/tests/docroot/Feed/Filtering/1.php
index d7a1d222..311ac578 100644
--- a/tests/docroot/Feed/Filtering/1.php
+++ b/tests/docroot/Feed/Filtering/1.php
@@ -38,23 +38,23 @@
urn:uuid:6d4c7964-43e1-11e7-92bd-4fed65d89796
- T
- Z
+ G
+ T
urn:uuid:6d4c7964-43e1-11e7-92bd-4fed65d89797
- S
- Z
+ H
+ S
urn:uuid:6d4c7964-43e1-11e7-92bd-4fed65d89798
- R
- Z
+ I
+ R
urn:uuid:6d4c7964-43e1-11e7-92bd-4fed65d89799
- Q
- Z
+ J
+ Q
MESSAGE_BODY
From 7a6186f2d77a4f1751eefac6a0b30ec85546168e Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Wed, 13 Jan 2021 14:43:29 -0500
Subject: [PATCH 109/366] Update Miniflux documentation
---
docs/en/030_Supported_Protocols/005_Miniflux.md | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/docs/en/030_Supported_Protocols/005_Miniflux.md b/docs/en/030_Supported_Protocols/005_Miniflux.md
index b29c0825..6a063bfa 100644
--- a/docs/en/030_Supported_Protocols/005_Miniflux.md
+++ b/docs/en/030_Supported_Protocols/005_Miniflux.md
@@ -15,7 +15,7 @@
The Miniflux protocol is a fairly well-designed protocol supporting a wide variety of operations on newsfeeds, folders (termed "categories"), and articles; it also allows for user administration, and native OPML importing and exporting. Architecturally it is similar to the Nextcloud News protocol, but is generally more efficient and has more capabilities.
-Miniflux version 2.0.26 is emulated, though not all features are implemented
+Miniflux version 2.0.27 is emulated, though not all features are implemented
# Missing features
@@ -39,7 +39,7 @@ Miniflux version 2.0.26 is emulated, though not all features are implemented
The Miniflux documentation gives only a brief example of a pattern for its filtering rules; the allowed syntax is described in full [in Google's documentation for RE2](https://github.com/google/re2/wiki/Syntax). Being a PHP application, The Arsse instead accepts [PCRE syntax](http://www.pcre.org/original/doc/html/pcresyntax.html) (or since PHP 7.3 [PCRE2 syntax](https://www.pcre.org/current/doc/html/pcre2syntax.html)), specifically in UTF-8 mode. Delimiters should not be included, and slashes should not be escaped; anchors may be used if desired. For example `^(?i)RE/MAX$` is a valid pattern.
-For convenience the patterns are tested after collapsing whitespace. Unlike Miniflux, The Arsse tests the patterns against an article's author-supplied categories if they do not match its title.
+For convenience the patterns are tested after collapsing whitespace. Unlike Miniflux, The Arsse tests the patterns against an article's author-supplied categories if they do not match its title. Also unlike Miniflux, when filter rules are modified they are re-evaluated against all applicable articles immediately.
# Special handling of the "All" category
From 618fd67f8024333e6abdfcc556f87b81d21ec9b9 Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Wed, 13 Jan 2021 14:54:22 -0500
Subject: [PATCH 110/366] Set marks for filtered articles on feed refresh
---
lib/Database.php | 50 ++++++++++++++++++++++++++---
tests/cases/Database/SeriesFeed.php | 30 +++++++++--------
2 files changed, 61 insertions(+), 19 deletions(-)
diff --git a/lib/Database.php b/lib/Database.php
index 5e44d384..d60a0281 100644
--- a/lib/Database.php
+++ b/lib/Database.php
@@ -1118,8 +1118,9 @@ class Database {
$icon = $this->db->prepare("INSERT INTO arsse_icons(url, type, data) values(?, ?, ?)", "str", "str", "blob")->run($feed->iconUrl, $feed->iconType, $feed->iconData)->lastId();
}
}
- // actually perform updates
- foreach ($feed->newItems as $article) {
+ $articleMap = [];
+ // actually perform updates, starting with inserting new articles
+ foreach ($feed->newItems as $k => $article) {
$articleID = $qInsertArticle->run(
$article->url,
$article->title,
@@ -1133,14 +1134,20 @@ class Database {
$article->titleContentHash,
$feedID
)->lastId();
+ // note the new ID for later use
+ $articleMap[$k] = $articleID;
+ // insert any enclosures
if ($article->enclosureUrl) {
$qInsertEnclosure->run($articleID, $article->enclosureUrl, $article->enclosureType);
}
+ // insert any categories
foreach ($article->categories as $c) {
$qInsertCategory->run($articleID, $c);
}
+ // assign a new edition ID to the article
$qInsertEdition->run($articleID);
}
+ // next update existing artricles which have been edited
foreach ($feed->changedItems as $articleID => $article) {
$qUpdateArticle->run(
$article->url,
@@ -1155,6 +1162,7 @@ class Database {
$article->titleContentHash,
$articleID
);
+ // delete all enclosures and categories and re-insert them
$qDeleteEnclosures->run($articleID);
$qDeleteCategories->run($articleID);
if ($article->enclosureUrl) {
@@ -1163,9 +1171,33 @@ class Database {
foreach ($article->categories as $c) {
$qInsertCategory->run($articleID, $c);
}
+ // assign a new edition ID to this version of the article
$qInsertEdition->run($articleID);
$qClearReadMarks->run($articleID);
}
+ // hide or unhide any filtered articles
+ foreach ($feed->filteredItems as $user => $filterData) {
+ $hide = [];
+ $unhide = [];
+ foreach ($filterData['new'] as $index => $keep) {
+ if (!$keep) {
+ $hide[] = $articleMap[$index];
+ }
+ }
+ foreach ($filterData['changed'] as $article => $keep) {
+ if (!$keep) {
+ $hide[] = $article;
+ } else {
+ $unhide[] = $article;
+ }
+ }
+ if ($hide) {
+ $this->articleMark($user, ['hidden' => true], (new Context)->articles($hide), false);
+ }
+ if ($unhide) {
+ $this->articleMark($user, ['hidden' => false], (new Context)->articles($unhide), false);
+ }
+ }
// lastly update the feed database itself with updated information.
$this->db->prepareArray(
"UPDATE arsse_feeds SET title = ?, source = ?, updated = CURRENT_TIMESTAMP, modified = ?, etag = ?, err_count = 0, err_msg = '', next_fetch = ?, size = ?, icon = ? WHERE id = ?",
@@ -1693,8 +1725,9 @@ class Database {
* @param string $user The user who owns the articles to be modified
* @param array $data An associative array of properties to modify. Anything not specified will remain unchanged
* @param Context $context The query context to match articles against
+ * @param bool $updateTimestamp Whether to also update the timestamp. This should only be false if a mark is changed as a result of an automated action not taken by the user
*/
- public function articleMark(string $user, array $data, Context $context = null): int {
+ public function articleMark(string $user, array $data, Context $context = null, bool $updateTimestamp = true): int {
$data = [
'read' => $data['read'] ?? null,
'starred' => $data['starred'] ?? null,
@@ -1743,7 +1776,11 @@ class Database {
$this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues());
}
// finally set the modification date for all touched marks and return the number of affected marks
- $out = $this->db->query("UPDATE arsse_marks set modified = CURRENT_TIMESTAMP, touched = 0 where touched = 1")->changes();
+ if ($updateTimestamp) {
+ $out = $this->db->query("UPDATE arsse_marks set modified = CURRENT_TIMESTAMP, touched = 0 where touched = 1")->changes();
+ } else {
+ $out = $this->db->query("UPDATE arsse_marks set touched = 0 where touched = 1")->changes();
+ }
} else {
if (!isset($data['read']) && ($context->edition() || $context->editions())) {
// get the articles associated with the requested editions
@@ -1763,7 +1800,10 @@ class Database {
return isset($v);
});
[$set, $setTypes, $setValues] = $this->generateSet($data, ['read' => "bool", 'starred' => "bool", 'hidden' => "bool", 'note' => "str"]);
- $q->setBody("UPDATE arsse_marks set $set, modified = CURRENT_TIMESTAMP where article in(select article from target_articles) and subscription in(select distinct subscription from target_articles)", $setTypes, $setValues);
+ if ($updateTimestamp) {
+ $set .= ", modified = CURRENT_TIMESTAMP";
+ }
+ $q->setBody("UPDATE arsse_marks set $set where article in(select article from target_articles) and subscription in(select distinct subscription from target_articles)", $setTypes, $setValues);
$out = $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues())->changes();
}
$tr->commit();
diff --git a/tests/cases/Database/SeriesFeed.php b/tests/cases/Database/SeriesFeed.php
index 65a2931f..5cc0d84c 100644
--- a/tests/cases/Database/SeriesFeed.php
+++ b/tests/cases/Database/SeriesFeed.php
@@ -80,7 +80,7 @@ trait SeriesFeed {
[3,'john.doe@example.com',3,'\w+',null],
[4,'john.doe@example.com',4,'\w+',"["], // invalid rule leads to both rules being ignored
[5,'john.doe@example.com',5,null,'and/or'],
- [6,'jane.doe@example.com',1,'^(?i)[a-z]+','bluberry'],
+ [6,'jane.doe@example.com',1,'^(?i)[a-z]+','3|6'],
],
],
'arsse_articles' => [
@@ -129,19 +129,20 @@ trait SeriesFeed {
'subscription' => "int",
'read' => "bool",
'starred' => "bool",
+ 'hidden' => "bool",
'modified' => "datetime",
],
'rows' => [
// Jane's marks
- [1,6,1,0,$past],
- [2,6,1,0,$past],
- [3,6,1,1,$past],
- [4,6,1,0,$past],
- [5,6,1,1,$past],
+ [1,6,1,0,0,$past],
+ [2,6,1,0,0,$past],
+ [3,6,1,1,0,$past],
+ [4,6,1,0,1,$past],
+ [5,6,1,1,0,$past],
// John's marks
- [1,1,1,0,$past],
- [3,1,1,0,$past],
- [4,1,0,1,$past],
+ [1,1,1,0,0,$past],
+ [3,1,1,0,0,$past],
+ [4,1,0,1,0,$past],
],
],
'arsse_enclosures' => [
@@ -210,7 +211,7 @@ trait SeriesFeed {
public function provideFilterRules(): iterable {
return [
- [1, ['jane.doe@example.com' => ['keep' => "`^(?i)[a-z]+`u", 'block' => "`bluberry`u"], 'john.doe@example.com' => ['keep' => "", 'block' => "`^Sport$`u"]]],
+ [1, ['jane.doe@example.com' => ['keep' => "`^(?i)[a-z]+`u", 'block' => "`3|6`u"], 'john.doe@example.com' => ['keep' => "", 'block' => "`^Sport$`u"]]],
[2, []],
[3, ['john.doe@example.com' => ['keep' => '`\w+`u', 'block' => ""]]],
[4, []],
@@ -225,7 +226,7 @@ trait SeriesFeed {
$state = $this->primeExpectations($this->data, [
'arsse_articles' => ["id", "feed","url","title","author","published","edited","content","guid","url_title_hash","url_content_hash","title_content_hash","modified"],
'arsse_editions' => ["id","article","modified"],
- 'arsse_marks' => ["subscription","article","read","starred","modified"],
+ 'arsse_marks' => ["subscription","article","read","starred","hidden","modified"],
'arsse_feeds' => ["id","size"],
]);
$state['arsse_articles']['rows'][2] = [3,1,'http://example.com/3','Article title 3 (updated)','','2000-01-03 00:00:00','2000-01-03 00:00:00','Article content 3
','31a6594500a48b59fcc8a075ce82b946c9c3c782460d088bd7b8ef3ede97ad92','6cc99be662ef3486fef35a890123f18d74c29a32d714802d743c5b4ef713315a','b278380e984cefe63f0e412b88ffc9cb0befdfa06fdc00bace1da99a8daff406','d5faccc13bf8267850a1e8e61f95950a0f34167df2c8c58011c0aaa6367026ac',$now];
@@ -236,9 +237,10 @@ trait SeriesFeed {
[7,3,$now],
[8,4,$now],
]);
- $state['arsse_marks']['rows'][2] = [6,3,0,1,$now];
- $state['arsse_marks']['rows'][3] = [6,4,0,0,$now];
- $state['arsse_marks']['rows'][6] = [1,3,0,0,$now];
+ $state['arsse_marks']['rows'][2] = [6,3,0,1,1,$now];
+ $state['arsse_marks']['rows'][3] = [6,4,0,0,0,$now];
+ $state['arsse_marks']['rows'][6] = [1,3,0,0,0,$now];
+ $state['arsse_marks']['rows'][] = [6,8,0,0,1,null];
$state['arsse_feeds']['rows'][0] = [1,6];
$this->compareExpectations(static::$drv, $state);
// update a valid feed which previously had an error
From 9f2b8d4f8333ddb32e6a9d0cdfcfb8146e73fdcd Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Thu, 14 Jan 2021 12:42:33 -0500
Subject: [PATCH 111/366] Imprement setting of filter rules
---
lib/AbstractException.php | 1 +
lib/Database.php | 132 +++++++++++++++-----
locale/en.php | 1 +
tests/cases/Database/SeriesSubscription.php | 52 ++++----
4 files changed, 131 insertions(+), 55 deletions(-)
diff --git a/lib/AbstractException.php b/lib/AbstractException.php
index 5a575c34..b6696c92 100644
--- a/lib/AbstractException.php
+++ b/lib/AbstractException.php
@@ -46,6 +46,7 @@ abstract class AbstractException extends \Exception {
"Db/Exception.savepointStale" => 10227,
"Db/Exception.resultReused" => 10228,
"Db/ExceptionRetry.schemaChange" => 10229,
+ "Db/ExceptionInput.invalidValue" => 10230,
"Db/ExceptionInput.missing" => 10231,
"Db/ExceptionInput.whitespace" => 10232,
"Db/ExceptionInput.tooLong" => 10233,
diff --git a/lib/Database.php b/lib/Database.php
index d60a0281..255c2acd 100644
--- a/lib/Database.php
+++ b/lib/Database.php
@@ -754,6 +754,28 @@ class Database {
}
/** Lists a user's subscriptions, returning various data
+ *
+ * Each record has the following keys:
+ *
+ * - "id": The numeric identifier of the subscription
+ * - "feed": The numeric identifier of the underlying newsfeed
+ * - "url": The URL of the newsfeed, after discovery and HTTP redirects
+ * - "title": The title of the newsfeed
+ * - "source": The URL of the source of the newsfeed i.e. its parent Web site
+ * - "favicon": The URL of an icon representing the newsfeed or its source
+ * - "folder": The numeric identifier (or null) of the subscription's folder
+ * - "top_folder": The numeric identifier (or null) of the top-level folder for the subscription
+ * - "pinned": Whether the subscription is pinned
+ * - "err_count": The count of times attempting to refresh the newsfeed has resulted in an error since the last successful retrieval
+ * - "err_msg": The error message of the last unsuccessful retrieval
+ * - "order_type": Whether articles should be sorted in reverse cronological order (2), chronological order (1), or the default (0)
+ * - "keep_rule": The subscription's "keep" filter rule; articles which do not match this are hidden
+ * - "block_rule": The subscription's "block" filter rule; articles which match this are hidden
+ * - "added": The date and time at which the subscription was added
+ * - "updated": The date and time at which the newsfeed was last updated in the database
+ * - "edited": The date and time at which the newsfeed was last modified by its authors
+ * - "modified": The date and time at which the subscription properties were last changed by the user
+ * - "unread": The number of unread articles associated with the subscription
*
* @param string $user The user whose subscriptions are to be listed
* @param integer|null $folder The identifier of the folder under which to list subscriptions; by default the root folder is used
@@ -817,7 +839,11 @@ class Database {
return $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues());
}
- /** Returns the number of subscriptions in a folder, counting recursively */
+ /** Returns the number of subscriptions in a folder, counting recursively
+ *
+ * @param string $user The user whose subscriptions are to be counted
+ * @param integer|null $folder The identifier of the folder under which to count subscriptions; by default the root folder is used
+ */
public function subscriptionCount(string $user, $folder = null): int {
// validate inputs
$folder = $this->folderValidateId($user, $folder)['id'];
@@ -851,24 +877,7 @@ class Database {
return true;
}
- /** Retrieves data about a particular subscription, as an associative array with the following keys:
- *
- * - "id": The numeric identifier of the subscription
- * - "feed": The numeric identifier of the underlying newsfeed
- * - "url": The URL of the newsfeed, after discovery and HTTP redirects
- * - "title": The title of the newsfeed
- * - "favicon": The URL of an icon representing the newsfeed or its source
- * - "source": The URL of the source of the newsfeed i.e. its parent Web site
- * - "folder": The numeric identifier (or null) of the subscription's folder
- * - "top_folder": The numeric identifier (or null) of the top-level folder for the subscription
- * - "pinned": Whether the subscription is pinned
- * - "err_count": The count of times attempting to refresh the newsfeed has resulted in an error since the last successful retrieval
- * - "err_msg": The error message of the last unsuccessful retrieval
- * - "order_type": Whether articles should be sorted in reverse cronological order (2), chronological order (1), or the default (0)
- * - "added": The date and time at which the subscription was added
- * - "updated": The date and time at which the newsfeed was last updated (not when it was last refreshed)
- * - "unread": The number of unread articles associated with the subscription
- */
+ /** Retrieves data about a particular subscription, as an associative array; see subscriptionList for details */
public function subscriptionPropertiesGet(string $user, $id): array {
if (!V::id($id)) {
throw new Db\ExceptionInput("typeViolation", ["action" => __FUNCTION__, "field" => "feed", 'type' => "int > 0"]);
@@ -884,10 +893,12 @@ class Database {
*
* The $data array must contain one or more of the following keys:
*
- * - "title": The title of the newsfeed
+ * - "title": The title of the subscription
* - "folder": The numeric identifier (or null) of the subscription's folder
* - "pinned": Whether the subscription is pinned
* - "order_type": Whether articles should be sorted in reverse cronological order (2), chronological order (1), or the default (0)
+ * - "keep_rule": The subscription's "keep" filter rule; articles which do not match this are hidden
+ * - "block_rule": The subscription's "block" filter rule; articles which match this are hidden
*
* @param string $user The user whose subscription is to be modified
* @param integer $id the numeric identifier of the subscription to modfify
@@ -896,29 +907,45 @@ class Database {
public function subscriptionPropertiesSet(string $user, $id, array $data): bool {
$tr = $this->db->begin();
// validate the ID
- $id = $this->subscriptionValidateId($user, $id, true)['id'];
+ $id = (int) $this->subscriptionValidateId($user, $id, true)['id'];
if (array_key_exists("folder", $data)) {
// ensure the target folder exists and belong to the user
$data['folder'] = $this->folderValidateId($user, $data['folder'])['id'];
}
- if (array_key_exists("title", $data)) {
+ if (isset($data['title'])) {
// if the title is null, this signals intended use of the default title; otherwise make sure it's not effectively an empty string
- if (!is_null($data['title'])) {
- $info = V::str($data['title']);
- if ($info & V::EMPTY) {
- throw new Db\ExceptionInput("missing", ["action" => __FUNCTION__, "field" => "title"]);
- } elseif ($info & V::WHITE) {
- throw new Db\ExceptionInput("whitespace", ["action" => __FUNCTION__, "field" => "title"]);
- } elseif (!($info & V::VALID)) {
- throw new Db\ExceptionInput("typeViolation", ["action" => __FUNCTION__, "field" => "title", 'type' => "string"]);
- }
+ $info = V::str($data['title']);
+ if ($info & V::EMPTY) {
+ throw new Db\ExceptionInput("missing", ["action" => __FUNCTION__, "field" => "title"]);
+ } elseif ($info & V::WHITE) {
+ throw new Db\ExceptionInput("whitespace", ["action" => __FUNCTION__, "field" => "title"]);
+ } elseif (!($info & V::VALID)) {
+ throw new Db\ExceptionInput("typeViolation", ["action" => __FUNCTION__, "field" => "title", 'type' => "string"]);
}
}
+ // validate any filter rules
+ if (isset($data['keep_rule'])) {
+ if (!is_string($data['keep_rule'])) {
+ throw new Db\ExceptionInput("typeViolation", ["action" => __FUNCTION__, "field" => "keep_rule", 'type' => "string"]);
+ } elseif (!Rule::validate($data['keep_rule'])) {
+ throw new Db\ExceptionInput("invalidValue", ["action" => __FUNCTION__, "field" => "keep_rule"]);
+ }
+ }
+ if (isset($data['block_rule'])) {
+ if (!is_string($data['block_rule'])) {
+ throw new Db\ExceptionInput("typeViolation", ["action" => __FUNCTION__, "field" => "block_rule", 'type' => "string"]);
+ } elseif (!Rule::validate($data['block_rule'])) {
+ throw new Db\ExceptionInput("invalidValue", ["action" => __FUNCTION__, "field" => "block_rule"]);
+ }
+ }
+ // perform the update
$valid = [
'title' => "str",
'folder' => "int",
'order_type' => "strict int",
'pinned' => "strict bool",
+ 'keep_rule' => "str",
+ 'block_rule' => "str",
];
[$setClause, $setTypes, $setValues] = $this->generateSet($data, $valid);
if (!$setClause) {
@@ -927,6 +954,10 @@ class Database {
}
$out = (bool) $this->db->prepare("UPDATE arsse_subscriptions set $setClause, modified = CURRENT_TIMESTAMP where owner = ? and id = ?", $setTypes, "str", "int")->run($setValues, $user, $id)->changes();
$tr->commit();
+ // if filter rules were changed, apply them
+ if (array_key_exists("keep_rule", $data) || array_key_exists("block_rule", $data)) {
+ $this->subscriptionRulesApply($user, $id);
+ }
return $out;
}
@@ -984,6 +1015,45 @@ class Database {
return V::normalize($out, V::T_DATE | V::M_NULL, "sql");
}
+ /** Evalutes the filter rules specified for a subscription against every article associated with the subscription's feed
+ *
+ * @param string $user The user who owns the subscription
+ * @param integer $id The identifier of the subscription whose rules are to be evaluated
+ */
+ protected function subscriptionRulesApply(string $user, int $id): void {
+ $sub = $this->db->prepare("SELECT feed, coalesce(keep_rule, '') as keep, coalesce(block_rule, '') as block from arsse_subscriptions where owner = ? and id = ?", "str", "int")->run($user, $id)->getRow();
+ try {
+ $keep = Rule::prep($sub['keep']);
+ $block = Rule::prep($sub['block']);
+ $feed = $sub['feed'];
+ } catch (RuleException $e) {
+ // invalid rules should not normally appear in the database, but it's possible
+ // in this case we should halt evaluation and just leave things as they are
+ return;
+ }
+ $articles = $this->db->prepare("SELECT id, title, coalesce(categories, 0) as categories from arsse_articles as a join (select article, count(*) as categories from arsse_categories group by article) as c on a.id = c.article where a.feed = ?", "int")->run($feed)->getAll();
+ $hide = [];
+ $unhide = [];
+ foreach ($articles as $r) {
+ // retrieve the list of categories if the article has any
+ $categories = $r['categories'] ? $this->articleCategoriesGet($user, $r['id']) : [];
+ // evaluate the rule for the article
+ if (Rule::apply($keep, $block, $r['title'], $categories)) {
+ $unhide[] = $r['id'];
+ } else {
+ $hide[] = $r['id'];
+ }
+ }
+ // apply any marks
+ if ($hide) {
+ $this->articleMark($user, ['hidden' => true], (new Context)->articles($hide), false);
+ }
+ if ($unhide) {
+ $this->articleMark($user, ['hidden' => false], (new Context)->articles($unhide), false);
+ }
+ }
+
+
/** Ensures the specified subscription exists and raises an exception otherwise
*
* Returns an associative array containing the id of the subscription and the id of the underlying newsfeed
diff --git a/locale/en.php b/locale/en.php
index 1927eaf7..b8098959 100644
--- a/locale/en.php
+++ b/locale/en.php
@@ -138,6 +138,7 @@ return [
// indicates programming error
'Exception.JKingWeb/Arsse/Db/Exception.resultReused' => 'Result set already iterated',
'Exception.JKingWeb/Arsse/Db/ExceptionRetry.schemaChange' => '{0}',
+ 'Exception.JKingWeb/Arsse/Db/ExceptionInput.invalidValue' => 'Value of field "{field}" of action "{action}" is invalid',
'Exception.JKingWeb/Arsse/Db/ExceptionInput.missing' => 'Required field "{field}" missing while performing action "{action}"',
'Exception.JKingWeb/Arsse/Db/ExceptionInput.whitespace' => 'Field "{field}" of action "{action}" may not contain only whitespace',
'Exception.JKingWeb/Arsse/Db/ExceptionInput.tooLong' => 'Field "{field}" of action "{action}" has a maximum length of {max}',
diff --git a/tests/cases/Database/SeriesSubscription.php b/tests/cases/Database/SeriesSubscription.php
index 7fe700a8..4ae83439 100644
--- a/tests/cases/Database/SeriesSubscription.php
+++ b/tests/cases/Database/SeriesSubscription.php
@@ -77,12 +77,14 @@ trait SeriesSubscription {
'folder' => "int",
'pinned' => "bool",
'order_type' => "int",
+ 'keep_rule' => "str",
+ 'block_rule' => "str",
],
'rows' => [
- [1,"john.doe@example.com",2,null,null,1,2],
- [2,"jane.doe@example.com",2,null,null,0,0],
- [3,"john.doe@example.com",3,"Ook",2,0,1],
- [4,"jill.doe@example.com",2,null,null,0,0],
+ [1,"john.doe@example.com",2,null,null,1,2,null,null],
+ [2,"jane.doe@example.com",2,null,null,0,0,null,null],
+ [3,"john.doe@example.com",3,"Ook",2,0,1,null,null],
+ [4,"jill.doe@example.com",2,null,null,0,0,null,null],
],
],
'arsse_tags' => [
@@ -369,17 +371,21 @@ trait SeriesSubscription {
'folder' => 3,
'pinned' => false,
'order_type' => 0,
+ 'keep_rule' => "ook",
+ 'block_rule' => "eek",
]);
$state = $this->primeExpectations($this->data, [
'arsse_feeds' => ['id','url','username','password','title'],
- 'arsse_subscriptions' => ['id','owner','feed','title','folder','pinned','order_type'],
+ 'arsse_subscriptions' => ['id','owner','feed','title','folder','pinned','order_type','keep_rule','block_rule'],
]);
- $state['arsse_subscriptions']['rows'][0] = [1,"john.doe@example.com",2,"Ook Ook",3,0,0];
+ $state['arsse_subscriptions']['rows'][0] = [1,"john.doe@example.com",2,"Ook Ook",3,0,0,"ook","eek"];
$this->compareExpectations(static::$drv, $state);
Arsse::$db->subscriptionPropertiesSet($this->user, 1, [
- 'title' => null,
+ 'title' => null,
+ 'keep_rule' => null,
+ 'block_rule' => null,
]);
- $state['arsse_subscriptions']['rows'][0] = [1,"john.doe@example.com",2,null,3,0,0];
+ $state['arsse_subscriptions']['rows'][0] = [1,"john.doe@example.com",2,null,3,0,0,null,null];
$this->compareExpectations(static::$drv, $state);
// making no changes is a valid result
Arsse::$db->subscriptionPropertiesSet($this->user, 1, ['unhinged' => true]);
@@ -395,30 +401,28 @@ trait SeriesSubscription {
$this->assertTrue(Arsse::$db->subscriptionPropertiesSet($this->user, 3, ['folder' => null]));
}
- public function testRenameASubscriptionToABlankTitle(): void {
- $this->assertException("missing", "Db", "ExceptionInput");
- Arsse::$db->subscriptionPropertiesSet($this->user, 1, ['title' => ""]);
+ /** @dataProvider provideInvalidSubscriptionProperties */
+ public function testSetThePropertiesOfASubscriptionToInvalidValues(array $data, string $exp): void {
+ $this->assertException($exp, "Db", "ExceptionInput");
+ Arsse::$db->subscriptionPropertiesSet($this->user, 1, $data);
}
- public function testRenameASubscriptionToAWhitespaceTitle(): void {
- $this->assertException("whitespace", "Db", "ExceptionInput");
- Arsse::$db->subscriptionPropertiesSet($this->user, 1, ['title' => " "]);
- }
-
- public function testRenameASubscriptionToFalse(): void {
- $this->assertException("typeViolation", "Db", "ExceptionInput");
- Arsse::$db->subscriptionPropertiesSet($this->user, 1, ['title' => false]);
+ public function provideInvalidSubscriptionProperties(): iterable {
+ return [
+ 'Empty title' => [['title' => ""], "missing"],
+ 'Whitespace title' => [['title' => " "], "whitespace"],
+ 'Non-string title' => [['title' => []], "typeViolation"],
+ 'Non-string keep rule' => [['keep_rule' => 0], "typeViolation"],
+ 'Invalid keep rule' => [['keep_rule' => "*"], "invalidValue"],
+ 'Non-string block rule' => [['block_rule' => 0], "typeViolation"],
+ 'Invalid block rule' => [['block_rule' => "*"], "invalidValue"],
+ ];
}
public function testRenameASubscriptionToZero(): void {
$this->assertTrue(Arsse::$db->subscriptionPropertiesSet($this->user, 1, ['title' => 0]));
}
- public function testRenameASubscriptionToAnArray(): void {
- $this->assertException("typeViolation", "Db", "ExceptionInput");
- Arsse::$db->subscriptionPropertiesSet($this->user, 1, ['title' => []]);
- }
-
public function testSetThePropertiesOfAMissingSubscription(): void {
$this->assertException("subjectMissing", "Db", "ExceptionInput");
Arsse::$db->subscriptionPropertiesSet($this->user, 2112, ['folder' => null]);
From 2536c9fe03207f6ce93c94e1b4823d15c4a86a08 Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Fri, 15 Jan 2021 23:02:33 -0500
Subject: [PATCH 112/366] Last tests for article filters
---
lib/Database.php | 10 +--
tests/cases/Database/SeriesSubscription.php | 79 ++++++++++++++++-----
2 files changed, 68 insertions(+), 21 deletions(-)
diff --git a/lib/Database.php b/lib/Database.php
index 255c2acd..1424bccb 100644
--- a/lib/Database.php
+++ b/lib/Database.php
@@ -1026,17 +1026,17 @@ class Database {
$keep = Rule::prep($sub['keep']);
$block = Rule::prep($sub['block']);
$feed = $sub['feed'];
- } catch (RuleException $e) {
+ } catch (RuleException $e) { // @codeCoverageIgnore
// invalid rules should not normally appear in the database, but it's possible
// in this case we should halt evaluation and just leave things as they are
- return;
+ return; // @codeCoverageIgnore
}
- $articles = $this->db->prepare("SELECT id, title, coalesce(categories, 0) as categories from arsse_articles as a join (select article, count(*) as categories from arsse_categories group by article) as c on a.id = c.article where a.feed = ?", "int")->run($feed)->getAll();
+ $articles = $this->db->prepare("SELECT id, title, coalesce(categories, 0) as categories from arsse_articles as a left join (select article, count(*) as categories from arsse_categories group by article) as c on a.id = c.article where a.feed = ?", "int")->run($feed)->getAll();
$hide = [];
$unhide = [];
foreach ($articles as $r) {
// retrieve the list of categories if the article has any
- $categories = $r['categories'] ? $this->articleCategoriesGet($user, $r['id']) : [];
+ $categories = $r['categories'] ? $this->articleCategoriesGet($user, (int) $r['id']) : [];
// evaluate the rule for the article
if (Rule::apply($keep, $block, $r['title'], $categories)) {
$unhide[] = $r['id'];
@@ -2006,7 +2006,7 @@ class Database {
FROM arsse_articles
join arsse_subscriptions on arsse_subscriptions.feed = arsse_articles.feed
WHERE arsse_articles.id = ? and arsse_subscriptions.owner = ?
- ) as articles join arsse_editions on arsse_editions.article = articles.article group by articles.article",
+ ) as articles left join arsse_editions on arsse_editions.article = articles.article group by articles.article",
["int", "str"]
)->run($id, $user)->getRow();
if (!$out) {
diff --git a/tests/cases/Database/SeriesSubscription.php b/tests/cases/Database/SeriesSubscription.php
index 4ae83439..abbdab39 100644
--- a/tests/cases/Database/SeriesSubscription.php
+++ b/tests/cases/Database/SeriesSubscription.php
@@ -24,6 +24,7 @@ trait SeriesSubscription {
["jane.doe@example.com", "", 1],
["john.doe@example.com", "", 2],
["jill.doe@example.com", "", 3],
+ ["jack.doe@example.com", "", 4],
],
],
'arsse_folders' => [
@@ -85,6 +86,7 @@ trait SeriesSubscription {
[2,"jane.doe@example.com",2,null,null,0,0,null,null],
[3,"john.doe@example.com",3,"Ook",2,0,1,null,null],
[4,"jill.doe@example.com",2,null,null,0,0,null,null],
+ [5,"jack.doe@example.com",2,null,null,1,2,"","3|E"],
],
],
'arsse_tags' => [
@@ -121,16 +123,48 @@ trait SeriesSubscription {
'url_title_hash' => "str",
'url_content_hash' => "str",
'title_content_hash' => "str",
+ 'title' => "str",
],
'rows' => [
- [1,2,"","",""],
- [2,2,"","",""],
- [3,2,"","",""],
- [4,2,"","",""],
- [5,2,"","",""],
- [6,3,"","",""],
- [7,3,"","",""],
- [8,3,"","",""],
+ [1,2,"","","","Title 1"],
+ [2,2,"","","","Title 2"],
+ [3,2,"","","","Title 3"],
+ [4,2,"","","","Title 4"],
+ [5,2,"","","","Title 5"],
+ [6,3,"","","","Title 6"],
+ [7,3,"","","","Title 7"],
+ [8,3,"","","","Title 8"],
+ ],
+ ],
+ 'arsse_editions' => [
+ 'columns' => [
+ 'id' => "int",
+ 'article' => "int",
+ ],
+ 'rows' => [
+ [1,1],
+ [2,2],
+ [3,3],
+ [4,4],
+ [5,5],
+ [6,6],
+ [7,7],
+ [8,8],
+ ],
+ ],
+ 'arsse_categories' => [
+ 'columns' => [
+ 'article' => "int",
+ 'name' => "str",
+ ],
+ 'rows' => [
+ [1,"A"],
+ [2,"B"],
+ [4,"D"],
+ [5,"E"],
+ [6,"F"],
+ [7,"G"],
+ [8,"H"],
],
],
'arsse_marks' => [
@@ -139,16 +173,21 @@ trait SeriesSubscription {
'subscription' => "int",
'read' => "bool",
'starred' => "bool",
+ 'hidden' => "bool",
],
'rows' => [
- [1,2,1,0],
- [2,2,1,0],
- [3,2,1,0],
- [4,2,1,0],
- [5,2,1,0],
- [1,1,1,0],
- [7,3,1,0],
- [8,3,0,0],
+ [1,2,1,0,0],
+ [2,2,1,0,0],
+ [3,2,1,0,0],
+ [4,2,1,0,0],
+ [5,2,1,0,0],
+ [1,1,1,0,0],
+ [7,3,1,0,0],
+ [8,3,0,0,0],
+ [1,5,1,0,0],
+ [3,5,1,0,1],
+ [4,5,0,0,0],
+ [5,5,0,0,1],
],
],
];
@@ -483,4 +522,12 @@ trait SeriesSubscription {
$this->assertException("subjectMissing", "Db", "ExceptionInput");
$this->assertTime(strtotime("now - 1 hour"), Arsse::$db->subscriptionRefreshed("john.doe@example.com", 2));
}
+
+ public function testSetTheFilterRulesOfASubscriptionCheckingMarks(): void {
+ Arsse::$db->subscriptionPropertiesSet("jack.doe@example.com", 5, ['keep_rule' => "1|B|3|D", 'block_rule' => "4"]);
+ $state = $this->primeExpectations($this->data, ['arsse_marks' => ['article', 'subscription', 'hidden']]);
+ $state['arsse_marks']['rows'][9][2] = 0;
+ $state['arsse_marks']['rows'][10][2] = 1;
+ $this->compareExpectations(static::$drv, $state);
+ }
}
From e74b44cc397ada29c70b2d647ce4afa511ecbbff Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Fri, 15 Jan 2021 23:15:22 -0500
Subject: [PATCH 113/366] Change favicon to icon_url and add icon_id
---
lib/Database.php | 6 ++++--
lib/REST/NextcloudNews/V1_2.php | 2 +-
lib/REST/TinyTinyRSS/API.php | 6 +++---
tests/cases/ImportExport/TestOPML.php | 12 ++++++------
tests/cases/REST/Fever/TestAPI.php | 6 +++---
tests/cases/REST/NextcloudNews/TestV1_2.php | 6 +++---
tests/cases/REST/TinyTinyRSS/TestAPI.php | 12 ++++++------
7 files changed, 26 insertions(+), 24 deletions(-)
diff --git a/lib/Database.php b/lib/Database.php
index 1424bccb..6e72cf6f 100644
--- a/lib/Database.php
+++ b/lib/Database.php
@@ -762,7 +762,8 @@ class Database {
* - "url": The URL of the newsfeed, after discovery and HTTP redirects
* - "title": The title of the newsfeed
* - "source": The URL of the source of the newsfeed i.e. its parent Web site
- * - "favicon": The URL of an icon representing the newsfeed or its source
+ * - "icon_id": The numeric identifier of an icon representing the newsfeed or its source
+ * - "icon_url": The URL of an icon representing the newsfeed or its source
* - "folder": The numeric identifier (or null) of the subscription's folder
* - "top_folder": The numeric identifier (or null) of the top-level folder for the subscription
* - "pinned": Whether the subscription is pinned
@@ -795,7 +796,8 @@ class Database {
f.updated as updated,
f.modified as edited,
s.modified as modified,
- i.url as favicon,
+ i.id as icon_id,
+ i.url as icon_url,
t.top as top_folder,
coalesce(s.title, f.title) as title,
coalesce((articles - hidden - marked), articles) as unread
diff --git a/lib/REST/NextcloudNews/V1_2.php b/lib/REST/NextcloudNews/V1_2.php
index 32405f87..57d1e732 100644
--- a/lib/REST/NextcloudNews/V1_2.php
+++ b/lib/REST/NextcloudNews/V1_2.php
@@ -181,7 +181,7 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler {
'added' => "added",
'pinned' => "pinned",
'link' => "source",
- 'faviconLink' => "favicon",
+ 'faviconLink' => "icon_url",
'folderId' => "top_folder",
'unreadCount' => "unread",
'ordering' => "order_type",
diff --git a/lib/REST/TinyTinyRSS/API.php b/lib/REST/TinyTinyRSS/API.php
index 9f8ea590..3de48637 100644
--- a/lib/REST/TinyTinyRSS/API.php
+++ b/lib/REST/TinyTinyRSS/API.php
@@ -256,7 +256,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler {
// prepare data for each subscription; we also add unread counts for their host categories
foreach (Arsse::$db->subscriptionList($user) as $f) {
// add the feed to the list of feeds
- $feeds[] = ['id' => (string) $f['id'], 'updated' => Date::transform($f['updated'], "iso8601", "sql"),'counter' => (int) $f['unread'], 'has_img' => (int) (strlen((string) $f['favicon']) > 0)]; // ID is cast to string for consistency with TTRSS
+ $feeds[] = ['id' => (string) $f['id'], 'updated' => Date::transform($f['updated'], "iso8601", "sql"),'counter' => (int) $f['unread'], 'has_img' => (int) (strlen((string) $f['icon_url']) > 0)]; // ID is cast to string for consistency with TTRSS
// add the feed's unread count to the global unread count
$countAll += $f['unread'];
// add the feed's unread count to its category unread count
@@ -441,7 +441,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler {
'name' => $s['title'],
'id' => "FEED:".$s['id'],
'bare_id' => (int) $s['id'],
- 'icon' => $s['favicon'] ? "feed-icons/".$s['id'].".ico" : false,
+ 'icon' => $s['icon_url'] ? "feed-icons/".$s['id'].".ico" : false,
'error' => (string) $s['err_msg'],
'param' => Date::transform($s['updated'], "iso8601", "sql"),
'unread' => 0,
@@ -794,7 +794,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler {
'unread' => (int) $s['unread'],
'cat_id' => (int) $s['folder'],
'feed_url' => $s['url'],
- 'has_icon' => (bool) $s['favicon'],
+ 'has_icon' => (bool) $s['icon_url'],
'last_updated' => (int) Date::transform($s['updated'], "unix", "sql"),
'order_id' => $order,
];
diff --git a/tests/cases/ImportExport/TestOPML.php b/tests/cases/ImportExport/TestOPML.php
index 469c674b..e527db0d 100644
--- a/tests/cases/ImportExport/TestOPML.php
+++ b/tests/cases/ImportExport/TestOPML.php
@@ -22,12 +22,12 @@ class TestOPML extends \JKingWeb\Arsse\Test\AbstractTest {
['id' => 1, 'parent' => null, 'children' => 1, 'feeds' => 1, 'name' => "Science"],
];
protected $subscriptions = [
- ['id' => 3, 'folder' => 1, 'top_folder' => 1, 'unread' => 2, 'updated' => "2016-05-23 06:40:02", 'err_msg' => 'argh', 'title' => 'Ars Technica', 'url' => "http://localhost:8000/3", 'favicon' => 'http://localhost:8000/3.png'],
- ['id' => 4, 'folder' => 6, 'top_folder' => 3, 'unread' => 6, 'updated' => "2017-10-09 15:58:34", 'err_msg' => '', 'title' => 'CBC News', 'url' => "http://localhost:8000/4", 'favicon' => 'http://localhost:8000/4.png'],
- ['id' => 6, 'folder' => null, 'top_folder' => null, 'unread' => 0, 'updated' => "2010-02-12 20:08:47", 'err_msg' => '', 'title' => 'Eurogamer', 'url' => "http://localhost:8000/6", 'favicon' => 'http://localhost:8000/6.png'],
- ['id' => 1, 'folder' => 2, 'top_folder' => 1, 'unread' => 5, 'updated' => "2017-09-15 22:54:16", 'err_msg' => '', 'title' => 'NASA JPL', 'url' => "http://localhost:8000/1", 'favicon' => null],
- ['id' => 5, 'folder' => 6, 'top_folder' => 3, 'unread' => 12, 'updated' => "2017-07-07 17:07:17", 'err_msg' => '', 'title' => 'Ottawa Citizen', 'url' => "http://localhost:8000/5", 'favicon' => ''],
- ['id' => 2, 'folder' => 5, 'top_folder' => 3, 'unread' => 10, 'updated' => "2011-11-11 11:11:11", 'err_msg' => 'oops', 'title' => 'Toronto Star', 'url' => "http://localhost:8000/2", 'favicon' => 'http://localhost:8000/2.png'],
+ ['id' => 3, 'folder' => 1, 'top_folder' => 1, 'unread' => 2, 'updated' => "2016-05-23 06:40:02", 'err_msg' => 'argh', 'title' => 'Ars Technica', 'url' => "http://localhost:8000/3", 'icon_url' => 'http://localhost:8000/3.png'],
+ ['id' => 4, 'folder' => 6, 'top_folder' => 3, 'unread' => 6, 'updated' => "2017-10-09 15:58:34", 'err_msg' => '', 'title' => 'CBC News', 'url' => "http://localhost:8000/4", 'icon_url' => 'http://localhost:8000/4.png'],
+ ['id' => 6, 'folder' => null, 'top_folder' => null, 'unread' => 0, 'updated' => "2010-02-12 20:08:47", 'err_msg' => '', 'title' => 'Eurogamer', 'url' => "http://localhost:8000/6", 'icon_url' => 'http://localhost:8000/6.png'],
+ ['id' => 1, 'folder' => 2, 'top_folder' => 1, 'unread' => 5, 'updated' => "2017-09-15 22:54:16", 'err_msg' => '', 'title' => 'NASA JPL', 'url' => "http://localhost:8000/1", 'icon_url' => null],
+ ['id' => 5, 'folder' => 6, 'top_folder' => 3, 'unread' => 12, 'updated' => "2017-07-07 17:07:17", 'err_msg' => '', 'title' => 'Ottawa Citizen', 'url' => "http://localhost:8000/5", 'icon_url' => ''],
+ ['id' => 2, 'folder' => 5, 'top_folder' => 3, 'unread' => 10, 'updated' => "2011-11-11 11:11:11", 'err_msg' => 'oops', 'title' => 'Toronto Star', 'url' => "http://localhost:8000/2", 'icon_url' => 'http://localhost:8000/2.png'],
];
protected $tags = [
['id' => 1, 'name' => "Canada", 'subscription' => 2],
diff --git a/tests/cases/REST/Fever/TestAPI.php b/tests/cases/REST/Fever/TestAPI.php
index 1aa77ba3..2d41dd15 100644
--- a/tests/cases/REST/Fever/TestAPI.php
+++ b/tests/cases/REST/Fever/TestAPI.php
@@ -273,9 +273,9 @@ class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest {
public function testListFeeds(): void {
\Phake::when(Arsse::$db)->subscriptionList(Arsse::$user->id)->thenReturn(new Result([
- ['id' => 1, 'feed' => 5, 'title' => "Ankh-Morpork News", 'url' => "http://example.com/feed", 'source' => "http://example.com/", 'edited' => "2019-01-01 21:12:00", 'favicon' => "http://example.com/favicon.ico"],
- ['id' => 2, 'feed' => 9, 'title' => "Ook, Ook Eek Ook!", 'url' => "http://example.net/feed", 'source' => "http://example.net/", 'edited' => "1988-06-24 12:21:00", 'favicon' => ""],
- ['id' => 3, 'feed' => 1, 'title' => "The Last Soul", 'url' => "http://example.org/feed", 'source' => "http://example.org/", 'edited' => "1991-08-12 03:22:00", 'favicon' => "http://example.org/favicon.ico"],
+ ['id' => 1, 'feed' => 5, 'title' => "Ankh-Morpork News", 'url' => "http://example.com/feed", 'source' => "http://example.com/", 'edited' => "2019-01-01 21:12:00", 'icon_url' => "http://example.com/favicon.ico"],
+ ['id' => 2, 'feed' => 9, 'title' => "Ook, Ook Eek Ook!", 'url' => "http://example.net/feed", 'source' => "http://example.net/", 'edited' => "1988-06-24 12:21:00", 'icon_url' => ""],
+ ['id' => 3, 'feed' => 1, 'title' => "The Last Soul", 'url' => "http://example.org/feed", 'source' => "http://example.org/", 'edited' => "1991-08-12 03:22:00", 'icon_url' => "http://example.org/favicon.ico"],
]));
\Phake::when(Arsse::$db)->tagSummarize(Arsse::$user->id)->thenReturn(new Result([
['id' => 1, 'name' => "Fascinating", 'subscription' => 1],
diff --git a/tests/cases/REST/NextcloudNews/TestV1_2.php b/tests/cases/REST/NextcloudNews/TestV1_2.php
index a88eb81e..eccc0cc1 100644
--- a/tests/cases/REST/NextcloudNews/TestV1_2.php
+++ b/tests/cases/REST/NextcloudNews/TestV1_2.php
@@ -28,7 +28,7 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest {
[
'id' => 2112,
'url' => 'http://example.com/news.atom',
- 'favicon' => 'http://example.com/favicon.png',
+ 'icon_url' => 'http://example.com/favicon.png',
'source' => 'http://example.com/',
'folder' => null,
'top_folder' => null,
@@ -43,7 +43,7 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest {
[
'id' => 42,
'url' => 'http://example.org/news.atom',
- 'favicon' => 'http://example.org/favicon.png',
+ 'icon_url' => 'http://example.org/favicon.png',
'source' => 'http://example.org/',
'folder' => 12,
'top_folder' => 8,
@@ -58,7 +58,7 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest {
[
'id' => 47,
'url' => 'http://example.net/news.atom',
- 'favicon' => 'http://example.net/favicon.png',
+ 'icon_url' => 'http://example.net/favicon.png',
'source' => 'http://example.net/',
'folder' => null,
'top_folder' => null,
diff --git a/tests/cases/REST/TinyTinyRSS/TestAPI.php b/tests/cases/REST/TinyTinyRSS/TestAPI.php
index 923380dd..ff6d8954 100644
--- a/tests/cases/REST/TinyTinyRSS/TestAPI.php
+++ b/tests/cases/REST/TinyTinyRSS/TestAPI.php
@@ -40,12 +40,12 @@ class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest {
['id' => 1, 'parent' => null, 'children' => 1, 'feeds' => 1, 'name' => "Science"],
];
protected $subscriptions = [
- ['id' => 3, 'folder' => 1, 'top_folder' => 1, 'unread' => 2, 'updated' => "2016-05-23 06:40:02", 'err_msg' => 'argh', 'title' => 'Ars Technica', 'url' => " http://example.com/3", 'favicon' => 'http://example.com/3.png'],
- ['id' => 4, 'folder' => 6, 'top_folder' => 3, 'unread' => 6, 'updated' => "2017-10-09 15:58:34", 'err_msg' => '', 'title' => 'CBC News', 'url' => " http://example.com/4", 'favicon' => 'http://example.com/4.png'],
- ['id' => 6, 'folder' => null, 'top_folder' => null, 'unread' => 0, 'updated' => "2010-02-12 20:08:47", 'err_msg' => '', 'title' => 'Eurogamer', 'url' => " http://example.com/6", 'favicon' => 'http://example.com/6.png'],
- ['id' => 1, 'folder' => 2, 'top_folder' => 1, 'unread' => 5, 'updated' => "2017-09-15 22:54:16", 'err_msg' => '', 'title' => 'NASA JPL', 'url' => " http://example.com/1", 'favicon' => null],
- ['id' => 5, 'folder' => 6, 'top_folder' => 3, 'unread' => 12, 'updated' => "2017-07-07 17:07:17", 'err_msg' => '', 'title' => 'Ottawa Citizen', 'url' => " http://example.com/5", 'favicon' => ''],
- ['id' => 2, 'folder' => 5, 'top_folder' => 3, 'unread' => 10, 'updated' => "2011-11-11 11:11:11", 'err_msg' => 'oops', 'title' => 'Toronto Star', 'url' => " http://example.com/2", 'favicon' => 'http://example.com/2.png'],
+ ['id' => 3, 'folder' => 1, 'top_folder' => 1, 'unread' => 2, 'updated' => "2016-05-23 06:40:02", 'err_msg' => 'argh', 'title' => 'Ars Technica', 'url' => " http://example.com/3", 'icon_url' => 'http://example.com/3.png'],
+ ['id' => 4, 'folder' => 6, 'top_folder' => 3, 'unread' => 6, 'updated' => "2017-10-09 15:58:34", 'err_msg' => '', 'title' => 'CBC News', 'url' => " http://example.com/4", 'icon_url' => 'http://example.com/4.png'],
+ ['id' => 6, 'folder' => null, 'top_folder' => null, 'unread' => 0, 'updated' => "2010-02-12 20:08:47", 'err_msg' => '', 'title' => 'Eurogamer', 'url' => " http://example.com/6", 'icon_url' => 'http://example.com/6.png'],
+ ['id' => 1, 'folder' => 2, 'top_folder' => 1, 'unread' => 5, 'updated' => "2017-09-15 22:54:16", 'err_msg' => '', 'title' => 'NASA JPL', 'url' => " http://example.com/1", 'icon_url' => null],
+ ['id' => 5, 'folder' => 6, 'top_folder' => 3, 'unread' => 12, 'updated' => "2017-07-07 17:07:17", 'err_msg' => '', 'title' => 'Ottawa Citizen', 'url' => " http://example.com/5", 'icon_url' => ''],
+ ['id' => 2, 'folder' => 5, 'top_folder' => 3, 'unread' => 10, 'updated' => "2011-11-11 11:11:11", 'err_msg' => 'oops', 'title' => 'Toronto Star', 'url' => " http://example.com/2", 'icon_url' => 'http://example.com/2.png'],
];
protected $labels = [
['id' => 3, 'articles' => 100, 'read' => 94, 'unread' => 6, 'name' => "Fascinating"],
From 4cb23dd1980e75a8d78da7f6da9194b343e15992 Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Sat, 16 Jan 2021 14:24:01 -0500
Subject: [PATCH 114/366] Partial implementation of proper content scraping
---
lib/Database.php | 27 ++++++++++++++++++---------
lib/Feed.php | 2 +-
sql/MySQL/6.sql | 4 ++++
sql/PostgreSQL/6.sql | 4 ++++
sql/SQLite3/6.sql | 28 ++++++++++++++++++++++++++--
5 files changed, 53 insertions(+), 12 deletions(-)
diff --git a/lib/Database.php b/lib/Database.php
index 6e72cf6f..a78ba370 100644
--- a/lib/Database.php
+++ b/lib/Database.php
@@ -1126,12 +1126,19 @@ class Database {
if (!V::id($feedID)) {
throw new Db\ExceptionInput("typeViolation", ["action" => __FUNCTION__, "field" => "feed", 'id' => $feedID, 'type' => "int > 0"]);
}
- $f = $this->db->prepare("SELECT url, username, password, modified, etag, err_count, scrape FROM arsse_feeds where id = ?", "int")->run($feedID)->getRow();
+ $f = $this->db->prepareArray(
+ "SELECT
+ url, username, password, modified, etag, err_count, scrapers
+ FROM arsse_feeds as f
+ left join (select feed, count(*) as scrapers from arsse_subscriptions where scrape = 1 group by feed) as s on f.id = s.feed
+ where id = ?",
+ ["int"]
+ )->run($feedID)->getRow();
if (!$f) {
throw new Db\ExceptionInput("subjectMissing", ["action" => __FUNCTION__, "field" => "feed", 'id' => $feedID]);
}
// determine whether the feed's items should be scraped for full content from the source Web site
- $scrape = (Arsse::$conf->fetchEnableScraping && $f['scrape']);
+ $scrape = (Arsse::$conf->fetchEnableScraping && $f['scrapers']);
// the Feed object throws an exception when there are problems, but that isn't ideal
// here. When an exception is thrown it should update the database with the
// error instead of failing; if other exceptions are thrown, we should simply roll back
@@ -1161,8 +1168,8 @@ class Database {
}
if (sizeof($feed->newItems)) {
$qInsertArticle = $this->db->prepareArray(
- "INSERT INTO arsse_articles(url,title,author,published,edited,guid,content,url_title_hash,url_content_hash,title_content_hash,feed) values(?,?,?,?,?,?,?,?,?,?,?)",
- ['str', 'str', 'str', 'datetime', 'datetime', 'str', 'str', 'str', 'str', 'str', 'int']
+ "INSERT INTO arsse_articles(url,title,author,published,edited,guid,content,url_title_hash,url_content_hash,title_content_hash,feed,content_scraped) values(?,?,?,?,?,?,?,?,?,?,?,?)",
+ ["str", "str", "str", "datetime", "datetime", "str", "str", "str", "str", "str", "int", "str"]
);
}
if (sizeof($feed->changedItems)) {
@@ -1170,8 +1177,8 @@ class Database {
$qDeleteCategories = $this->db->prepare("DELETE FROM arsse_categories WHERE article = ?", 'int');
$qClearReadMarks = $this->db->prepare("UPDATE arsse_marks SET \"read\" = 0, modified = CURRENT_TIMESTAMP WHERE article = ? and \"read\" = 1", 'int');
$qUpdateArticle = $this->db->prepareArray(
- "UPDATE arsse_articles SET url = ?, title = ?, author = ?, published = ?, edited = ?, modified = CURRENT_TIMESTAMP, guid = ?, content = ?, url_title_hash = ?, url_content_hash = ?, title_content_hash = ? WHERE id = ?",
- ['str', 'str', 'str', 'datetime', 'datetime', 'str', 'str', 'str', 'str', 'str', 'int']
+ "UPDATE arsse_articles SET url = ?, title = ?, author = ?, published = ?, edited = ?, modified = CURRENT_TIMESTAMP, guid = ?, content = ?, url_title_hash = ?, url_content_hash = ?, title_content_hash = ?, content_scraped = ? WHERE id = ?",
+ ["str", "str", "str", "datetime", "datetime", "str", "str", "str", "str", "str", "str", "int"]
);
}
// determine if the feed icon needs to be updated, and update it if appropriate
@@ -1204,7 +1211,8 @@ class Database {
$article->urlTitleHash,
$article->urlContentHash,
$article->titleContentHash,
- $feedID
+ $feedID,
+ $article->scrapedContent ?? null
)->lastId();
// note the new ID for later use
$articleMap[$k] = $articleID;
@@ -1232,6 +1240,7 @@ class Database {
$article->urlTitleHash,
$article->urlContentHash,
$article->titleContentHash,
+ $article->scrapedContent ?? null,
$articleID
);
// delete all enclosures and categories and re-insert them
@@ -1273,7 +1282,7 @@ class Database {
// lastly update the feed database itself with updated information.
$this->db->prepareArray(
"UPDATE arsse_feeds SET title = ?, source = ?, updated = CURRENT_TIMESTAMP, modified = ?, etag = ?, err_count = 0, err_msg = '', next_fetch = ?, size = ?, icon = ? WHERE id = ?",
- ['str', 'str', 'datetime', 'strict str', 'datetime', 'int', 'int', 'int']
+ ["str", "str", "datetime", "strict str", "datetime", "int", "int", "int"]
)->run(
$feed->data->title,
$feed->data->siteUrl,
@@ -1429,7 +1438,7 @@ class Database {
'url' => "arsse_articles.url",
'title' => "arsse_articles.title",
'author' => "arsse_articles.author",
- 'content' => "arsse_articles.content",
+ 'content' => "coalesce(case when arsse_subscriptions.scrape = 1 then arsse_articles.content_scraped end, arsse_articles.content)",
'guid' => "arsse_articles.guid",
'fingerprint' => "arsse_articles.url_title_hash || ':' || arsse_articles.url_content_hash || ':' || arsse_articles.title_content_hash",
'folder' => "coalesce(arsse_subscriptions.folder,0)",
diff --git a/lib/Feed.php b/lib/Feed.php
index e96d064c..af43f22e 100644
--- a/lib/Feed.php
+++ b/lib/Feed.php
@@ -448,7 +448,7 @@ class Feed {
$scraper->setUrl($item->url);
$scraper->execute();
if ($scraper->hasRelevantContent()) {
- $item->content = $scraper->getFilteredContent();
+ $item->scrapedContent = $scraper->getFilteredContent();
}
}
}
diff --git a/sql/MySQL/6.sql b/sql/MySQL/6.sql
index c2f8b532..7d9eb128 100644
--- a/sql/MySQL/6.sql
+++ b/sql/MySQL/6.sql
@@ -32,6 +32,10 @@ create table arsse_user_meta(
primary key(owner,"key")
) character set utf8mb4 collate utf8mb4_unicode_ci;
+alter table arsse_subscriptions add column scrape boolean not null default 0;
+alter table arsse_feeds drop column scrape;
+alter table arsse_articles add column content_scraped longtext;
+
create table arsse_icons(
id serial primary key,
url varchar(767) unique not null,
diff --git a/sql/PostgreSQL/6.sql b/sql/PostgreSQL/6.sql
index a27b87a6..825f67de 100644
--- a/sql/PostgreSQL/6.sql
+++ b/sql/PostgreSQL/6.sql
@@ -32,6 +32,10 @@ create table arsse_user_meta(
primary key(owner,key)
);
+alter table arsse_subscriptions add column scrape smallint not null default 0;
+alter table arsse_feeds drop column scrape;
+alter table arsse_articles add column content_scraped text;
+
create table arsse_icons(
id bigserial primary key,
url text unique not null,
diff --git a/sql/SQLite3/6.sql b/sql/SQLite3/6.sql
index 3c5f3589..e43c4ea3 100644
--- a/sql/SQLite3/6.sql
+++ b/sql/SQLite3/6.sql
@@ -44,8 +44,11 @@ create table arsse_user_meta(
primary key(owner,key)
) without rowid;
+-- Add a "scrape" column for subscriptions
+alter table arsse_subscriptions add column scrape boolean not null default 0;
-- Add a separate table for feed icons and replace their URLs in the feeds table with their IDs
+-- Also remove the "scrape" column of the feeds table, which was never an advertised feature
create table arsse_icons(
-- Icons associated with feeds
-- At a minimum the URL of the icon must be known, but its content may be missing
@@ -76,16 +79,37 @@ create table arsse_feeds_new(
username text not null default '', -- HTTP authentication username
password text not null default '', -- HTTP authentication password (this is stored in plain text)
size integer not null default 0, -- number of articles in the feed at last fetch
- scrape boolean not null default 0, -- whether to use picoFeed's content scraper with this feed
icon integer references arsse_icons(id) on delete set null, -- numeric identifier of any associated icon
unique(url,username,password) -- a URL with particular credentials should only appear once
);
insert into arsse_feeds_new
- select f.id, f.url, title, source, updated, f.modified, f.next_fetch, f.orphaned, f.etag, err_count, err_msg, username, password, size, scrape, i.id
+ select f.id, f.url, title, source, updated, f.modified, f.next_fetch, f.orphaned, f.etag, err_count, err_msg, username, password, size, i.id
from arsse_feeds as f left join arsse_icons as i on f.favicon = i.url;
drop table arsse_feeds;
alter table arsse_feeds_new rename to arsse_feeds;
+-- Add a column for scraped article content, and re-order some column
+create table arsse_articles_new(
+-- entries in newsfeeds
+ id integer primary key, -- sequence number
+ feed integer not null references arsse_feeds(id) on delete cascade, -- feed for the subscription
+ url text, -- URL of article
+ title text collate nocase, -- article title
+ author text collate nocase, -- author's name
+ published text, -- time of original publication
+ edited text, -- time of last edit by author
+ modified text not null default CURRENT_TIMESTAMP, -- time when article was last modified in database
+ guid text, -- GUID
+ url_title_hash text not null, -- hash of URL + title; used when checking for updates and for identification if there is no guid.
+ url_content_hash text not null, -- hash of URL + content, enclosure URL, & content type; used when checking for updates and for identification if there is no guid.
+ title_content_hash text not null, -- hash of title + content, enclosure URL, & content type; used when checking for updates and for identification if there is no guid.
+ content_scraped text, -- scraped content, as HTML
+ content text -- content, as HTML
+);
+insert into arsse_articles_new select id, feed, url, title, author, published, edited, modified, guid, url_title_hash, url_content_hash, title_content_hash, null, content from arsse_articles;
+drop table arsse_articles;
+alter table arsse_articles_new rename to arsse_articles;
+
-- set version marker
pragma user_version = 7;
update arsse_meta set value = '7' where "key" = 'schema_version';
From 76f70119fde6b903242e7d72fae9291ac5e5b3b5 Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Sat, 16 Jan 2021 16:48:35 -0500
Subject: [PATCH 115/366] More work on scraping
---
tests/cases/Database/SeriesArticle.php | 80 +++++++++++++-------------
tests/cases/Feed/TestFeed.php | 2 +
2 files changed, 43 insertions(+), 39 deletions(-)
diff --git a/tests/cases/Database/SeriesArticle.php b/tests/cases/Database/SeriesArticle.php
index c444977f..09342c9a 100644
--- a/tests/cases/Database/SeriesArticle.php
+++ b/tests/cases/Database/SeriesArticle.php
@@ -93,22 +93,23 @@ trait SeriesArticle {
'feed' => "int",
'folder' => "int",
'title' => "str",
+ 'scrape' => "bool",
],
'rows' => [
- [1, "john.doe@example.com",1, null,"Subscription 1"],
- [2, "john.doe@example.com",2, null,null],
- [3, "john.doe@example.com",3, 1,"Subscription 3"],
- [4, "john.doe@example.com",4, 6,null],
- [5, "john.doe@example.com",10, 5,"Subscription 5"],
- [6, "jane.doe@example.com",1, null,null],
- [7, "jane.doe@example.com",10,null,"Subscription 7"],
- [8, "john.doe@example.org",11,null,null],
- [9, "john.doe@example.org",12,null,"Subscription 9"],
- [10,"john.doe@example.org",13,null,null],
- [11,"john.doe@example.net",10,null,"Subscription 11"],
- [12,"john.doe@example.net",2, 9,null],
- [13,"john.doe@example.net",3, 8,"Subscription 13"],
- [14,"john.doe@example.net",4, 7,null],
+ [1, "john.doe@example.com",1, null,"Subscription 1",0],
+ [2, "john.doe@example.com",2, null,null,0],
+ [3, "john.doe@example.com",3, 1,"Subscription 3",0],
+ [4, "john.doe@example.com",4, 6,null,0],
+ [5, "john.doe@example.com",10, 5,"Subscription 5",0],
+ [6, "jane.doe@example.com",1, null,null,0],
+ [7, "jane.doe@example.com",10,null,"Subscription 7",0],
+ [8, "john.doe@example.org",11,null,null,0],
+ [9, "john.doe@example.org",12,null,"Subscription 9",0],
+ [10,"john.doe@example.org",13,null,null,0],
+ [11,"john.doe@example.net",10,null,"Subscription 11",0],
+ [12,"john.doe@example.net",2, 9,null,0],
+ [13,"john.doe@example.net",3, 8,"Subscription 13",0],
+ [14,"john.doe@example.net",4, 7,null,0],
],
],
'arsse_tag_members' => [
@@ -145,33 +146,34 @@ trait SeriesArticle {
'url_content_hash' => "str",
'title_content_hash' => "str",
'modified' => "datetime",
+ 'content_scraped' => "str",
],
'rows' => [
- [1,1,null,"Title one", null,null,null,"First article", null,"","","","2000-01-01T00:00:00Z"],
- [2,1,null,"Title two", null,null,null,"Second article",null,"","","","2010-01-01T00:00:00Z"],
- [3,2,null,"Title three",null,null,null,"Third article", null,"","","","2000-01-01T00:00:00Z"],
- [4,2,null,null,"John Doe",null,null,null,null,"","","","2010-01-01T00:00:00Z"],
- [5,3,null,null,"John Doe",null,null,null,null,"","","","2000-01-01T00:00:00Z"],
- [6,3,null,null,"Jane Doe",null,null,null,null,"","","","2010-01-01T00:00:00Z"],
- [7,4,null,null,"Jane Doe",null,null,null,null,"","","","2000-01-01T00:00:00Z"],
- [8,4,null,null,null,null,null,null,null,"","","","2010-01-01T00:00:00Z"],
- [9,5,null,null,null,null,null,null,null,"","","","2000-01-01T00:00:00Z"],
- [10,5,null,null,null,null,null,null,null,"","","","2010-01-01T00:00:00Z"],
- [11,6,null,null,null,null,null,null,null,"","","","2000-01-01T00:00:00Z"],
- [12,6,null,null,null,null,null,null,null,"","","","2010-01-01T00:00:00Z"],
- [13,7,null,null,null,null,null,null,null,"","","","2000-01-01T00:00:00Z"],
- [14,7,null,null,null,null,null,null,null,"","","","2010-01-01T00:00:00Z"],
- [15,8,null,null,null,null,null,null,null,"","","","2000-01-01T00:00:00Z"],
- [16,8,null,null,null,null,null,null,null,"","","","2010-01-01T00:00:00Z"],
- [17,9,null,null,null,null,null,null,null,"","","","2000-01-01T00:00:00Z"],
- [18,9,null,null,null,null,null,null,null,"","","","2010-01-01T00:00:00Z"],
- [19,10,null,null,null,null,null,null,null,"","","","2000-01-01T00:00:00Z"],
- [20,10,null,null,null,null,null,null,null,"","","","2010-01-01T00:00:00Z"],
- [101,11,'http://example.com/1','Article title 1','','2000-01-01 00:00:00','2000-01-01 00:00:01','Article content 1
','e433653cef2e572eee4215fa299a4a5af9137b2cefd6283c85bd69a32915beda','f5cb8bfc1c7396dc9816af212a3e2ac5221585c2a00bf7ccb6aabd95dcfcd6a6','fb0bc8f8cb08913dc5a497db700e327f1d34e4987402687d494a5891f24714d4','18fdd4fa93d693128c43b004399e5c9cea6c261ddfa002518d3669f55d8c2207','2000-01-01 01:00:00'],
- [102,11,'http://example.com/2','Article title 2','','2000-01-02 00:00:00','2000-01-02 00:00:02','Article content 2
','5be8a5a46ecd52ed132191c8d27fb1af6b3d4edc00234c5d9f8f0e10562ed3b7','0e86d2de822a174fe3c44a466953e63ca1f1a58a19cbf475fce0855d4e3d5153','13075894189c47ffcfafd1dfe7fbb539f7c74a69d35a399b3abf8518952714f9','2abd0a8cba83b8214a66c8f0293ba63e467d720540e29ff8ddcdab069d4f1c9e','2000-01-02 02:00:00'],
- [103,12,'http://example.com/3','Article title 3','','2000-01-03 00:00:00','2000-01-03 00:00:03','Article content 3
','31a6594500a48b59fcc8a075ce82b946c9c3c782460d088bd7b8ef3ede97ad92','f74b06b240bd08abf4d3fdfc20dba6a6f6eb8b4f1a00e9a617efd63a87180a4b','b278380e984cefe63f0e412b88ffc9cb0befdfa06fdc00bace1da99a8daff406','ad622b31e739cd3a3f3c788991082cf4d2f7a8773773008e75f0572e58cd373b','2000-01-03 03:00:00'],
- [104,12,'http://example.com/4','Article title 4','','2000-01-04 00:00:00','2000-01-04 00:00:04','Article content 4
','804e517d623390e71497982c77cf6823180342ebcd2e7d5e32da1e55b09dd180','f3615c7f16336d3ea242d35cf3fc17dbc4ee3afb78376bf49da2dd7a5a25dec8','f11c2b4046f207579aeb9c69a8c20ca5461cef49756ccfa5ba5e2344266da3b3','ab2da63276acce431250b18d3d49b988b226a99c7faadf275c90b751aee05be9','2000-01-04 04:00:00'],
- [105,13,'http://example.com/5','Article title 5','','2000-01-05 00:00:00','2000-01-05 00:00:05','Article content 5
','db3e736c2c492f5def5c5da33ddcbea1824040e9ced2142069276b0a6e291a41','d40da96e39eea6c55948ccbe9b3d275b5f931298288dbe953990c5f496097022','834240f84501b5341d375414718204ec421561f3825d34c22bf9182203e42900','43b970ac6ec5f8a9647b2c7e4eed8b1d7f62e154a95eed748b0294c1256764ba','2000-01-05 05:00:00'],
+ [1,1,null,"Title one", null,null,null,"First article", null,"","","","2000-01-01T00:00:00Z",null],
+ [2,1,null,"Title two", null,null,null,"Second article",null,"","","","2010-01-01T00:00:00Z",null],
+ [3,2,null,"Title three",null,null,null,"Third article", null,"","","","2000-01-01T00:00:00Z",null],
+ [4,2,null,null,"John Doe",null,null,null,null,"","","","2010-01-01T00:00:00Z",null],
+ [5,3,null,null,"John Doe",null,null,null,null,"","","","2000-01-01T00:00:00Z",null],
+ [6,3,null,null,"Jane Doe",null,null,null,null,"","","","2010-01-01T00:00:00Z",null],
+ [7,4,null,null,"Jane Doe",null,null,null,null,"","","","2000-01-01T00:00:00Z",null],
+ [8,4,null,null,null,null,null,null,null,"","","","2010-01-01T00:00:00Z",null],
+ [9,5,null,null,null,null,null,null,null,"","","","2000-01-01T00:00:00Z",null],
+ [10,5,null,null,null,null,null,null,null,"","","","2010-01-01T00:00:00Z",null],
+ [11,6,null,null,null,null,null,null,null,"","","","2000-01-01T00:00:00Z",null],
+ [12,6,null,null,null,null,null,null,null,"","","","2010-01-01T00:00:00Z",null],
+ [13,7,null,null,null,null,null,null,null,"","","","2000-01-01T00:00:00Z",null],
+ [14,7,null,null,null,null,null,null,null,"","","","2010-01-01T00:00:00Z",null],
+ [15,8,null,null,null,null,null,null,null,"","","","2000-01-01T00:00:00Z",null],
+ [16,8,null,null,null,null,null,null,null,"","","","2010-01-01T00:00:00Z",null],
+ [17,9,null,null,null,null,null,null,null,"","","","2000-01-01T00:00:00Z",null],
+ [18,9,null,null,null,null,null,null,null,"","","","2010-01-01T00:00:00Z",null],
+ [19,10,null,null,null,null,null,null,null,"","","","2000-01-01T00:00:00Z",null],
+ [20,10,null,null,null,null,null,null,null,"","","","2010-01-01T00:00:00Z",null],
+ [101,11,'http://example.com/1','Article title 1','','2000-01-01 00:00:00','2000-01-01 00:00:01','Article content 1
','e433653cef2e572eee4215fa299a4a5af9137b2cefd6283c85bd69a32915beda','f5cb8bfc1c7396dc9816af212a3e2ac5221585c2a00bf7ccb6aabd95dcfcd6a6','fb0bc8f8cb08913dc5a497db700e327f1d34e4987402687d494a5891f24714d4','18fdd4fa93d693128c43b004399e5c9cea6c261ddfa002518d3669f55d8c2207','2000-01-01 01:00:00',"Scraped content 1
"],
+ [102,11,'http://example.com/2','Article title 2','','2000-01-02 00:00:00','2000-01-02 00:00:02','Article content 2
','5be8a5a46ecd52ed132191c8d27fb1af6b3d4edc00234c5d9f8f0e10562ed3b7','0e86d2de822a174fe3c44a466953e63ca1f1a58a19cbf475fce0855d4e3d5153','13075894189c47ffcfafd1dfe7fbb539f7c74a69d35a399b3abf8518952714f9','2abd0a8cba83b8214a66c8f0293ba63e467d720540e29ff8ddcdab069d4f1c9e','2000-01-02 02:00:00',null],
+ [103,12,'http://example.com/3','Article title 3','','2000-01-03 00:00:00','2000-01-03 00:00:03','Article content 3
','31a6594500a48b59fcc8a075ce82b946c9c3c782460d088bd7b8ef3ede97ad92','f74b06b240bd08abf4d3fdfc20dba6a6f6eb8b4f1a00e9a617efd63a87180a4b','b278380e984cefe63f0e412b88ffc9cb0befdfa06fdc00bace1da99a8daff406','ad622b31e739cd3a3f3c788991082cf4d2f7a8773773008e75f0572e58cd373b','2000-01-03 03:00:00',null],
+ [104,12,'http://example.com/4','Article title 4','','2000-01-04 00:00:00','2000-01-04 00:00:04','Article content 4
','804e517d623390e71497982c77cf6823180342ebcd2e7d5e32da1e55b09dd180','f3615c7f16336d3ea242d35cf3fc17dbc4ee3afb78376bf49da2dd7a5a25dec8','f11c2b4046f207579aeb9c69a8c20ca5461cef49756ccfa5ba5e2344266da3b3','ab2da63276acce431250b18d3d49b988b226a99c7faadf275c90b751aee05be9','2000-01-04 04:00:00',null],
+ [105,13,'http://example.com/5','Article title 5','','2000-01-05 00:00:00','2000-01-05 00:00:05','Article content 5
','db3e736c2c492f5def5c5da33ddcbea1824040e9ced2142069276b0a6e291a41','d40da96e39eea6c55948ccbe9b3d275b5f931298288dbe953990c5f496097022','834240f84501b5341d375414718204ec421561f3825d34c22bf9182203e42900','43b970ac6ec5f8a9647b2c7e4eed8b1d7f62e154a95eed748b0294c1256764ba','2000-01-05 05:00:00',null],
],
],
'arsse_enclosures' => [
diff --git a/tests/cases/Feed/TestFeed.php b/tests/cases/Feed/TestFeed.php
index 51799940..cdfcec1e 100644
--- a/tests/cases/Feed/TestFeed.php
+++ b/tests/cases/Feed/TestFeed.php
@@ -369,6 +369,8 @@ class TestFeed extends \JKingWeb\Arsse\Test\AbstractTest {
// now try to scrape and get different content
$f = new Feed(null, $this->base."Scraping/Feed", "", "", "", "", true);
$exp = "Partial content, followed by more content
";
+ $this->assertSame($exp, $f->newItems[0]->scrapedContent);
+ $exp = "Partial content
";
$this->assertSame($exp, $f->newItems[0]->content);
}
From 7897585d9887a6f7e58d1630d421b01fd81de4a5 Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Sat, 16 Jan 2021 17:58:31 -0500
Subject: [PATCH 116/366] Test scraping
Text search should also match scraped content when appropriate
---
lib/Database.php | 16 +++++++++---
tests/cases/Database/SeriesArticle.php | 35 +++++++++++++++++++++++---
2 files changed, 43 insertions(+), 8 deletions(-)
diff --git a/lib/Database.php b/lib/Database.php
index a78ba370..ea70d953 100644
--- a/lib/Database.php
+++ b/lib/Database.php
@@ -1711,10 +1711,10 @@ class Database {
}
// handle text-matching context options
$options = [
- "titleTerms" => ["arsse_articles.title"],
- "searchTerms" => ["arsse_articles.title", "arsse_articles.content"],
- "authorTerms" => ["arsse_articles.author"],
- "annotationTerms" => ["arsse_marks.note"],
+ "titleTerms" => ["title"],
+ "searchTerms" => ["title", "content"],
+ "authorTerms" => ["author"],
+ "annotationTerms" => ["note"],
];
foreach ($options as $m => $columns) {
if (!$context->$m()) {
@@ -1722,6 +1722,10 @@ class Database {
} elseif (!$context->$m) {
throw new Db\ExceptionInput("tooShort", ['field' => $m, 'action' => $this->caller(), 'min' => 1]); // must have at least one array element
}
+ $columns = array_map(function ($c) use ($colDefs) {
+ assert(isset($colDefs[$c]), new Exception("constantUnknown", $c));
+ return $colDefs[$c];
+ }, $columns);
$q->setWhere(...$this->generateSearch($context->$m, $columns));
}
// further handle exclusionary text-matching context options
@@ -1729,6 +1733,10 @@ class Database {
if (!$context->not->$m() || !$context->not->$m) {
continue;
}
+ $columns = array_map(function ($c) use ($colDefs) {
+ assert(isset($colDefs[$c]), new Exception("constantUnknown", $c));
+ return $colDefs[$c];
+ }, $columns);
$q->setWhereNot(...$this->generateSearch($context->not->$m, $columns, true));
}
// return the query
diff --git a/tests/cases/Database/SeriesArticle.php b/tests/cases/Database/SeriesArticle.php
index 09342c9a..6302f5df 100644
--- a/tests/cases/Database/SeriesArticle.php
+++ b/tests/cases/Database/SeriesArticle.php
@@ -22,10 +22,11 @@ trait SeriesArticle {
'num' => 'int',
],
'rows' => [
- ["jane.doe@example.com", "",1],
- ["john.doe@example.com", "",2],
- ["john.doe@example.org", "",3],
- ["john.doe@example.net", "",4],
+ ["jane.doe@example.com", "", 1],
+ ["john.doe@example.com", "", 2],
+ ["john.doe@example.org", "", 3],
+ ["john.doe@example.net", "", 4],
+ ["jill.doe@example.com", "", 5],
],
],
'arsse_feeds' => [
@@ -110,6 +111,7 @@ trait SeriesArticle {
[12,"john.doe@example.net",2, 9,null,0],
[13,"john.doe@example.net",3, 8,"Subscription 13",0],
[14,"john.doe@example.net",4, 7,null,0],
+ [15,"jill.doe@example.com",11,null,null,1],
],
],
'arsse_tag_members' => [
@@ -1149,4 +1151,29 @@ trait SeriesArticle {
$state = $this->primeExpectations($this->data, $this->checkTables);
$this->compareExpectations(static::$drv, $state);
}
+
+ public function testSelectScrapedContent(): void {
+ $exp = [
+ ['id' => 101, 'content' => "Article content 1
"],
+ ['id' => 102, 'content' => "Article content 2
"],
+ ];
+ $this->assertResult($exp, Arsse::$db->articleList("john.doe@example.org", (new Context)->subscription(8), ["id", "content"]));
+ $exp = [
+ ['id' => 101, 'content' => "Scraped content 1
"],
+ ['id' => 102, 'content' => "Article content 2
"],
+ ];
+ $this->assertResult($exp, Arsse::$db->articleList("jill.doe@example.com", (new Context)->subscription(15), ["id", "content"]));
+ }
+
+ public function testSearchScrapedContent(): void {
+ $exp = [
+ ['id' => 101, 'content' => "Scraped content 1
"],
+ ['id' => 102, 'content' => "Article content 2
"],
+ ];
+ $this->assertResult($exp, Arsse::$db->articleList("jill.doe@example.com", (new Context)->subscription(15)->searchTerms(["article"]), ["id", "content"]));
+ $exp = [
+ ['id' => 101, 'content' => "Scraped content 1
"],
+ ];
+ $this->assertResult($exp, Arsse::$db->articleList("jill.doe@example.com", (new Context)->subscription(15)->searchTerms(["scraped"]), ["id", "content"]));
+ }
}
From 86897af0b3e085f3e3e7dd7895a487e34aa898ab Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Sat, 16 Jan 2021 19:06:20 -0500
Subject: [PATCH 117/366] Add ability to enable scraper
Also transfer any existing scraper booleans on database upgrade. It was
previously possible to enable scraping manually by editing the database,
and these settings will be honoured.
---
lib/Database.php | 2 +
sql/MySQL/6.sql | 1 +
sql/PostgreSQL/6.sql | 1 +
sql/SQLite3/6.sql | 47 +++++++++++----------
tests/cases/Database/SeriesSubscription.php | 18 ++++----
tests/cases/Db/BaseUpdate.php | 31 ++++++++++----
6 files changed, 61 insertions(+), 39 deletions(-)
diff --git a/lib/Database.php b/lib/Database.php
index ea70d953..a69d2465 100644
--- a/lib/Database.php
+++ b/lib/Database.php
@@ -898,6 +898,7 @@ class Database {
* - "title": The title of the subscription
* - "folder": The numeric identifier (or null) of the subscription's folder
* - "pinned": Whether the subscription is pinned
+ * - "scrape": Whether to scrape full article contents from the HTML article
* - "order_type": Whether articles should be sorted in reverse cronological order (2), chronological order (1), or the default (0)
* - "keep_rule": The subscription's "keep" filter rule; articles which do not match this are hidden
* - "block_rule": The subscription's "block" filter rule; articles which match this are hidden
@@ -948,6 +949,7 @@ class Database {
'pinned' => "strict bool",
'keep_rule' => "str",
'block_rule' => "str",
+ 'scrape' => "bool",
];
[$setClause, $setTypes, $setValues] = $this->generateSet($data, $valid);
if (!$setClause) {
diff --git a/sql/MySQL/6.sql b/sql/MySQL/6.sql
index 7d9eb128..789900ef 100644
--- a/sql/MySQL/6.sql
+++ b/sql/MySQL/6.sql
@@ -33,6 +33,7 @@ create table arsse_user_meta(
) character set utf8mb4 collate utf8mb4_unicode_ci;
alter table arsse_subscriptions add column scrape boolean not null default 0;
+update arsse_subscriptions set scrape = 1 where feed in (select id from arsse_feeds where scrape = 1);
alter table arsse_feeds drop column scrape;
alter table arsse_articles add column content_scraped longtext;
diff --git a/sql/PostgreSQL/6.sql b/sql/PostgreSQL/6.sql
index 825f67de..0f559a87 100644
--- a/sql/PostgreSQL/6.sql
+++ b/sql/PostgreSQL/6.sql
@@ -33,6 +33,7 @@ create table arsse_user_meta(
);
alter table arsse_subscriptions add column scrape smallint not null default 0;
+update arsse_subscriptions set scrape = 1 where feed in (select id from arsse_feeds where scrape = 1);
alter table arsse_feeds drop column scrape;
alter table arsse_articles add column content_scraped text;
diff --git a/sql/SQLite3/6.sql b/sql/SQLite3/6.sql
index e43c4ea3..2be4fed5 100644
--- a/sql/SQLite3/6.sql
+++ b/sql/SQLite3/6.sql
@@ -44,8 +44,31 @@ create table arsse_user_meta(
primary key(owner,key)
) without rowid;
--- Add a "scrape" column for subscriptions
+-- Add a "scrape" column for subscriptions and copy any existing scraping
alter table arsse_subscriptions add column scrape boolean not null default 0;
+update arsse_subscriptions set scrape = 1 where feed in (select id from arsse_feeds where scrape = 1);
+
+-- Add a column for scraped article content, and re-order some columns
+create table arsse_articles_new(
+-- entries in newsfeeds
+ id integer primary key, -- sequence number
+ feed integer not null references arsse_feeds(id) on delete cascade, -- feed for the subscription
+ url text, -- URL of article
+ title text collate nocase, -- article title
+ author text collate nocase, -- author's name
+ published text, -- time of original publication
+ edited text, -- time of last edit by author
+ modified text not null default CURRENT_TIMESTAMP, -- time when article was last modified in database
+ guid text, -- GUID
+ url_title_hash text not null, -- hash of URL + title; used when checking for updates and for identification if there is no guid.
+ url_content_hash text not null, -- hash of URL + content, enclosure URL, & content type; used when checking for updates and for identification if there is no guid.
+ title_content_hash text not null, -- hash of title + content, enclosure URL, & content type; used when checking for updates and for identification if there is no guid.
+ content_scraped text, -- scraped content, as HTML
+ content text -- content, as HTML
+);
+insert into arsse_articles_new select id, feed, url, title, author, published, edited, modified, guid, url_title_hash, url_content_hash, title_content_hash, null, content from arsse_articles;
+drop table arsse_articles;
+alter table arsse_articles_new rename to arsse_articles;
-- Add a separate table for feed icons and replace their URLs in the feeds table with their IDs
-- Also remove the "scrape" column of the feeds table, which was never an advertised feature
@@ -88,28 +111,6 @@ insert into arsse_feeds_new
drop table arsse_feeds;
alter table arsse_feeds_new rename to arsse_feeds;
--- Add a column for scraped article content, and re-order some column
-create table arsse_articles_new(
--- entries in newsfeeds
- id integer primary key, -- sequence number
- feed integer not null references arsse_feeds(id) on delete cascade, -- feed for the subscription
- url text, -- URL of article
- title text collate nocase, -- article title
- author text collate nocase, -- author's name
- published text, -- time of original publication
- edited text, -- time of last edit by author
- modified text not null default CURRENT_TIMESTAMP, -- time when article was last modified in database
- guid text, -- GUID
- url_title_hash text not null, -- hash of URL + title; used when checking for updates and for identification if there is no guid.
- url_content_hash text not null, -- hash of URL + content, enclosure URL, & content type; used when checking for updates and for identification if there is no guid.
- title_content_hash text not null, -- hash of title + content, enclosure URL, & content type; used when checking for updates and for identification if there is no guid.
- content_scraped text, -- scraped content, as HTML
- content text -- content, as HTML
-);
-insert into arsse_articles_new select id, feed, url, title, author, published, edited, modified, guid, url_title_hash, url_content_hash, title_content_hash, null, content from arsse_articles;
-drop table arsse_articles;
-alter table arsse_articles_new rename to arsse_articles;
-
-- set version marker
pragma user_version = 7;
update arsse_meta set value = '7' where "key" = 'schema_version';
diff --git a/tests/cases/Database/SeriesSubscription.php b/tests/cases/Database/SeriesSubscription.php
index abbdab39..389495d3 100644
--- a/tests/cases/Database/SeriesSubscription.php
+++ b/tests/cases/Database/SeriesSubscription.php
@@ -80,13 +80,14 @@ trait SeriesSubscription {
'order_type' => "int",
'keep_rule' => "str",
'block_rule' => "str",
+ 'scrape' => "bool",
],
'rows' => [
- [1,"john.doe@example.com",2,null,null,1,2,null,null],
- [2,"jane.doe@example.com",2,null,null,0,0,null,null],
- [3,"john.doe@example.com",3,"Ook",2,0,1,null,null],
- [4,"jill.doe@example.com",2,null,null,0,0,null,null],
- [5,"jack.doe@example.com",2,null,null,1,2,"","3|E"],
+ [1,"john.doe@example.com",2,null,null,1,2,null,null,0],
+ [2,"jane.doe@example.com",2,null,null,0,0,null,null,0],
+ [3,"john.doe@example.com",3,"Ook",2,0,1,null,null,0],
+ [4,"jill.doe@example.com",2,null,null,0,0,null,null,0],
+ [5,"jack.doe@example.com",2,null,null,1,2,"","3|E",0],
],
],
'arsse_tags' => [
@@ -409,22 +410,23 @@ trait SeriesSubscription {
'title' => "Ook Ook",
'folder' => 3,
'pinned' => false,
+ 'scrape' => true,
'order_type' => 0,
'keep_rule' => "ook",
'block_rule' => "eek",
]);
$state = $this->primeExpectations($this->data, [
'arsse_feeds' => ['id','url','username','password','title'],
- 'arsse_subscriptions' => ['id','owner','feed','title','folder','pinned','order_type','keep_rule','block_rule'],
+ 'arsse_subscriptions' => ['id','owner','feed','title','folder','pinned','order_type','keep_rule','block_rule','scrape'],
]);
- $state['arsse_subscriptions']['rows'][0] = [1,"john.doe@example.com",2,"Ook Ook",3,0,0,"ook","eek"];
+ $state['arsse_subscriptions']['rows'][0] = [1,"john.doe@example.com",2,"Ook Ook",3,0,0,"ook","eek",1];
$this->compareExpectations(static::$drv, $state);
Arsse::$db->subscriptionPropertiesSet($this->user, 1, [
'title' => null,
'keep_rule' => null,
'block_rule' => null,
]);
- $state['arsse_subscriptions']['rows'][0] = [1,"john.doe@example.com",2,null,3,0,0,null,null];
+ $state['arsse_subscriptions']['rows'][0] = [1,"john.doe@example.com",2,null,3,0,0,null,null,1];
$this->compareExpectations(static::$drv, $state);
// making no changes is a valid result
Arsse::$db->subscriptionPropertiesSet($this->user, 1, ['unhinged' => true]);
diff --git a/tests/cases/Db/BaseUpdate.php b/tests/cases/Db/BaseUpdate.php
index bce4dbcf..4e1ed79b 100644
--- a/tests/cases/Db/BaseUpdate.php
+++ b/tests/cases/Db/BaseUpdate.php
@@ -139,14 +139,22 @@ class BaseUpdate extends \JKingWeb\Arsse\Test\AbstractTest {
$this->drv->schemaUpdate(6);
$this->drv->exec(
<<drv->schemaUpdate(7);
@@ -168,9 +176,16 @@ QUERY_TEXT
['url' => 'https://example.com/', 'icon' => 1],
['url' => 'http://example.net/', 'icon' => null],
];
+ $subs = [
+ ['id' => 1, 'scrape' => 1],
+ ['id' => 2, 'scrape' => 1],
+ ['id' => 3, 'scrape' => 0],
+ ['id' => 4, 'scrape' => 0],
+ ];
$this->assertEquals($users, $this->drv->query("SELECT id, password, num from arsse_users order by id")->getAll());
$this->assertEquals($folders, $this->drv->query("SELECT owner, name from arsse_folders order by owner")->getAll());
$this->assertEquals($icons, $this->drv->query("SELECT id, url from arsse_icons order by id")->getAll());
$this->assertEquals($feeds, $this->drv->query("SELECT url, icon from arsse_feeds order by id")->getAll());
+ $this->assertEquals($subs, $this->drv->query("SELECT id, scrape from arsse_subscriptions order by id")->getAll());
}
}
From 2cf4bf0d4d7c3af1df5adad4672b2a76dfdd07d6 Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Sat, 16 Jan 2021 22:52:07 -0500
Subject: [PATCH 118/366] Prototype Miniflux feed listing
---
lib/Database.php | 5 ++++-
lib/REST/Miniflux/V1.php | 47 ++++++++++++++++++++++++++++++++++++++++
2 files changed, 51 insertions(+), 1 deletion(-)
diff --git a/lib/Database.php b/lib/Database.php
index a69d2465..77af92ca 100644
--- a/lib/Database.php
+++ b/lib/Database.php
@@ -776,7 +776,9 @@ class Database {
* - "updated": The date and time at which the newsfeed was last updated in the database
* - "edited": The date and time at which the newsfeed was last modified by its authors
* - "modified": The date and time at which the subscription properties were last changed by the user
+ * - "next_fetch": The date and time and which the feed will next be fetched
* - "unread": The number of unread articles associated with the subscription
+ * - "etag": The ETag header-field in the last fetch response
*
* @param string $user The user whose subscriptions are to be listed
* @param integer|null $folder The identifier of the folder under which to list subscriptions; by default the root folder is used
@@ -792,10 +794,11 @@ class Database {
"SELECT
s.id as id,
s.feed as feed,
- f.url,source,folder,pinned,err_count,err_msg,order_type,added,keep_rule,block_rule,
+ f.url,source,folder,pinned,err_count,err_msg,order_type,added,keep_rule,block_rule,f.etag,s.scrape,
f.updated as updated,
f.modified as edited,
s.modified as modified,
+ f.next_fetch,
i.id as icon_id,
i.url as icon_url,
t.top as top_folder,
diff --git a/lib/REST/Miniflux/V1.php b/lib/REST/Miniflux/V1.php
index f048e798..c1bc8e57 100644
--- a/lib/REST/Miniflux/V1.php
+++ b/lib/REST/Miniflux/V1.php
@@ -22,6 +22,7 @@ use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\ResponseInterface;
use Laminas\Diactoros\Response\EmptyResponse;
use Laminas\Diactoros\Response\JsonResponse as Response;
+use Laminas\Diactoros\Uri;
class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
public const VERSION = "2.0.26";
@@ -590,6 +591,52 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
return new EmptyResponse(204);
}
+ protected function getFeeds(): ResponseInterface {
+ $meta = Arsse::$user->propertiesGet(Arsse::$user->id, false);
+ $tr = Arsse::$db->begin();
+ $out = [];
+ // compile the list of folders; the feed list includes folder names
+ $folders = [0 => ['id' => 1, 'title' => $meta['root_folder_name'] ?? Arsse::$lang->msg("API.Miniflux.DefaultCategoryName"), 'user_id' => $meta['num']]];
+ foreach(Arsse::$db->folderList(Arsse::$user->id, null, false) as $r) {
+ $folders[(int) $r['id']] = [
+ 'id' => ((int) $r['id']) + 1,
+ 'title' => $r['name'],
+ 'user_id' => $meta['num'],
+ ];
+ }
+ // next compile the list of feeds
+ foreach (Arsse::$db->subscriptionList(Arsse::$user->id) as $r) {
+ $url = new Uri($r['url']);
+ $out = [
+ 'id' => (int) $r['id'],
+ 'user_id' => $meta['num'],
+ 'feed_url' => $url->withUserInfo(""),
+ 'site_url' => $r['source'],
+ 'title' => $r['title'],
+ 'checked_at' => Date::transform($r['updated'], "iso8601", "sql"),
+ 'next_check_at' => Date::transform($r['next_fetch'], "iso8601", "sql") ?? "0001-01-01T00:00:00Z",
+ 'etag_header' => $r['etag'] ?? "",
+ 'last_modified_header' => (string) Date::transform($r['edited'], "http", "sql"),
+ 'parsing_error_message' => (string) $r['err_msg'],
+ 'parsing_error_count' => (int) $r['err_count'],
+ 'scraper_rules' => "",
+ 'rewrite_rules' => "",
+ 'crawler' => (bool) $r['scrape'],
+ 'blocklist_rules' => (string) $r['block_rule'],
+ 'keeplist_rules' => (string) $r['keep_rule'],
+ 'user_agent' => "",
+ 'username' => explode(":", $url->getUserInfo(), 2)[0] ?? "",
+ 'password' => explode(":", $url->getUserInfo(), 2)[1] ?? "",
+ 'disabled' => false,
+ 'ignore_http_cache' => false,
+ 'fetch_via_proxy' => false,
+ 'category' => $folders[$r['top_folder']],
+ 'icon' => $r['icon_id'] ? ['feed_id' => (int) $r['id'], 'icon_id' => (int) $r['icon_id']] : null,
+ ];
+ return new Response($out);
+ }
+ }
+
public static function tokenGenerate(string $user, string $label): string {
// Miniflux produces tokens in base64url alphabet
$t = str_replace(["+", "/"], ["-", "_"], base64_encode(random_bytes(self::TOKEN_LENGTH)));
From 14d2d19ae1fa50614bae5b6f2bf243abf6637e74 Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Sun, 17 Jan 2021 13:02:31 -0500
Subject: [PATCH 119/366] Tests for Miniflux feed listing
---
.../030_Supported_Protocols/005_Miniflux.md | 1 +
lib/Database.php | 3 +-
lib/REST/Miniflux/V1.php | 75 ++++++++++--------
tests/cases/REST/Miniflux/TestV1.php | 76 +++++++++++++++++++
4 files changed, 121 insertions(+), 34 deletions(-)
diff --git a/docs/en/030_Supported_Protocols/005_Miniflux.md b/docs/en/030_Supported_Protocols/005_Miniflux.md
index 6a063bfa..a85a61ea 100644
--- a/docs/en/030_Supported_Protocols/005_Miniflux.md
+++ b/docs/en/030_Supported_Protocols/005_Miniflux.md
@@ -34,6 +34,7 @@ Miniflux version 2.0.27 is emulated, though not all features are implemented
- The "All" category is treated specially (see below for details)
- Category names consisting only of whitespace are rejected along with the empty string
- Filtering rules may not function identically (see below for details)
+- The `checked_at` field of feeds indicates when the feed was last updated rather than when it was last checked
# Behaviour of filtering (block and keep) rules
diff --git a/lib/Database.php b/lib/Database.php
index 77af92ca..3866ddaf 100644
--- a/lib/Database.php
+++ b/lib/Database.php
@@ -777,8 +777,9 @@ class Database {
* - "edited": The date and time at which the newsfeed was last modified by its authors
* - "modified": The date and time at which the subscription properties were last changed by the user
* - "next_fetch": The date and time and which the feed will next be fetched
- * - "unread": The number of unread articles associated with the subscription
* - "etag": The ETag header-field in the last fetch response
+ * - "scrape": Whether the user wants scrape full-article content
+ * - "unread": The number of unread articles associated with the subscription
*
* @param string $user The user whose subscriptions are to be listed
* @param integer|null $folder The identifier of the folder under which to list subscriptions; by default the root folder is used
diff --git a/lib/REST/Miniflux/V1.php b/lib/REST/Miniflux/V1.php
index c1bc8e57..43d9e36c 100644
--- a/lib/REST/Miniflux/V1.php
+++ b/lib/REST/Miniflux/V1.php
@@ -591,50 +591,59 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
return new EmptyResponse(204);
}
- protected function getFeeds(): ResponseInterface {
+ protected function mapFolders(): array {
$meta = Arsse::$user->propertiesGet(Arsse::$user->id, false);
- $tr = Arsse::$db->begin();
- $out = [];
- // compile the list of folders; the feed list includes folder names
$folders = [0 => ['id' => 1, 'title' => $meta['root_folder_name'] ?? Arsse::$lang->msg("API.Miniflux.DefaultCategoryName"), 'user_id' => $meta['num']]];
- foreach(Arsse::$db->folderList(Arsse::$user->id, null, false) as $r) {
+ foreach (Arsse::$db->folderList(Arsse::$user->id, null, false) as $r) {
$folders[(int) $r['id']] = [
'id' => ((int) $r['id']) + 1,
'title' => $r['name'],
'user_id' => $meta['num'],
];
}
+ return $folders;
+ }
+
+ protected function transformFeed(array $sub, array $folders): array {
+ $url = new Uri($sub['url']);
+ return [
+ 'id' => (int) $sub['id'],
+ 'user_id' => $folders[0]['user_id'],
+ 'feed_url' => (string) $url->withUserInfo(""),
+ 'site_url' => (string) $sub['source'],
+ 'title' => (string) $sub['title'],
+ 'checked_at' => Date::transform($sub['updated'], "iso8601m", "sql"),
+ 'next_check_at' => Date::transform($sub['next_fetch'], "iso8601m", "sql") ?? "0001-01-01T00:00:00.000000Z",
+ 'etag_header' => (string) $sub['etag'],
+ 'last_modified_header' => (string) Date::transform($sub['edited'], "http", "sql"),
+ 'parsing_error_message' => (string) $sub['err_msg'],
+ 'parsing_error_count' => (int) $sub['err_count'],
+ 'scraper_rules' => "",
+ 'rewrite_rules' => "",
+ 'crawler' => (bool) $sub['scrape'],
+ 'blocklist_rules' => (string) $sub['block_rule'],
+ 'keeplist_rules' => (string) $sub['keep_rule'],
+ 'user_agent' => "",
+ 'username' => rawurldecode(explode(":", $url->getUserInfo(), 2)[0] ?? ""),
+ 'password' => rawurldecode(explode(":", $url->getUserInfo(), 2)[1] ?? ""),
+ 'disabled' => false,
+ 'ignore_http_cache' => false,
+ 'fetch_via_proxy' => false,
+ 'category' => $folders[(int) $sub['top_folder']],
+ 'icon' => $sub['icon_id'] ? ['feed_id' => (int) $sub['id'], 'icon_id' => (int) $sub['icon_id']] : null,
+ ];
+ }
+
+ protected function getFeeds(): ResponseInterface {
+ $tr = Arsse::$db->begin();
+ // compile the list of folders; the feed list includes folder names
+ $folders = $this->mapFolders();
// next compile the list of feeds
+ $out = [];
foreach (Arsse::$db->subscriptionList(Arsse::$user->id) as $r) {
- $url = new Uri($r['url']);
- $out = [
- 'id' => (int) $r['id'],
- 'user_id' => $meta['num'],
- 'feed_url' => $url->withUserInfo(""),
- 'site_url' => $r['source'],
- 'title' => $r['title'],
- 'checked_at' => Date::transform($r['updated'], "iso8601", "sql"),
- 'next_check_at' => Date::transform($r['next_fetch'], "iso8601", "sql") ?? "0001-01-01T00:00:00Z",
- 'etag_header' => $r['etag'] ?? "",
- 'last_modified_header' => (string) Date::transform($r['edited'], "http", "sql"),
- 'parsing_error_message' => (string) $r['err_msg'],
- 'parsing_error_count' => (int) $r['err_count'],
- 'scraper_rules' => "",
- 'rewrite_rules' => "",
- 'crawler' => (bool) $r['scrape'],
- 'blocklist_rules' => (string) $r['block_rule'],
- 'keeplist_rules' => (string) $r['keep_rule'],
- 'user_agent' => "",
- 'username' => explode(":", $url->getUserInfo(), 2)[0] ?? "",
- 'password' => explode(":", $url->getUserInfo(), 2)[1] ?? "",
- 'disabled' => false,
- 'ignore_http_cache' => false,
- 'fetch_via_proxy' => false,
- 'category' => $folders[$r['top_folder']],
- 'icon' => $r['icon_id'] ? ['feed_id' => (int) $r['id'], 'icon_id' => (int) $r['icon_id']] : null,
- ];
- return new Response($out);
+ $out[] = $this->transformFeed($r, $folders);
}
+ return new Response($out);
}
public static function tokenGenerate(string $user, string $label): string {
diff --git a/tests/cases/REST/Miniflux/TestV1.php b/tests/cases/REST/Miniflux/TestV1.php
index 496df378..54e03435 100644
--- a/tests/cases/REST/Miniflux/TestV1.php
+++ b/tests/cases/REST/Miniflux/TestV1.php
@@ -525,4 +525,80 @@ class TestV1 extends \JKingWeb\Arsse\Test\AbstractTest {
\Phake::verify(Arsse::$db)->articleMark("john.doe@example.com", ['read' => true], (new Context)->folder(2111))
);
}
+
+ public function testListReeds(): void {
+ \Phake::when(Arsse::$db)->folderList->thenReturn(new Result([
+ ['id' => 5, 'name' => "Cat Ook"],
+ ]));
+ \Phake::when(Arsse::$db)->subscriptionList->thenReturn(new Result([
+ ['id' => 1, 'feed' => 12, 'url' => "http://example.com/ook", 'title' => "Ook", 'source' => "http://example.com/", 'icon_id' => 47, 'icon_url' => "http://example.com/icon", 'folder' => 2112, 'top_folder' => 5, 'pinned' => 0, 'err_count' => 1, 'err_msg' => "Oopsie", 'order_type' => 0, 'keep_rule' => "this|that", 'block_rule' => "both", 'added' => "2020-12-21 21:12:00", 'updated' => "2021-01-05 13:51:32", 'edited' => "2021-01-01 00:00:00", 'modified' => "2020-11-30 04:08:52", 'next_fetch' => "2021-01-20 00:00:00", 'etag' => "OOKEEK", 'scrape' => 0, 'unread' => 42],
+ ['id' => 55, 'feed' => 12, 'url' => "http://j%20k:super%20secret@example.com/eek", 'title' => "Eek", 'source' => "http://example.com/", 'icon_id' => null, 'icon_url' => null, 'folder' => null, 'top_folder' => null, 'pinned' => 0, 'err_count' => 0, 'err_msg' => null, 'order_type' => 0, 'keep_rule' => null, 'block_rule' => null, 'added' => "2020-12-21 21:12:00", 'updated' => "2021-01-05 13:51:32", 'edited' => null, 'modified' => "2020-11-30 04:08:52", 'next_fetch' => null, 'etag' => null, 'scrape' => 1, 'unread' => 0],
+ ]));
+ $exp = new Response([
+ [
+ 'id' => 1,
+ 'user_id' => 42,
+ 'feed_url' => "http://example.com/ook",
+ 'site_url' => "http://example.com/",
+ 'title' => "Ook",
+ 'checked_at' => "2021-01-05T13:51:32.000000Z",
+ 'next_check_at' => "2021-01-20T00:00:00.000000Z",
+ 'etag_header' => "OOKEEK",
+ 'last_modified_header' => "Fri, 01 Jan 2021 00:00:00 GMT",
+ 'parsing_error_message' => "Oopsie",
+ 'parsing_error_count' => 1,
+ 'scraper_rules' => "",
+ 'rewrite_rules' => "",
+ 'crawler' => false,
+ 'blocklist_rules' => "both",
+ 'keeplist_rules' => "this|that",
+ 'user_agent' => "",
+ 'username' => "",
+ 'password' => "",
+ 'disabled' => false,
+ 'ignore_http_cache' => false,
+ 'fetch_via_proxy' => false,
+ 'category' => [
+ 'id' => 6,
+ 'title' => "Cat Ook",
+ 'user_id' => 42
+ ],
+ 'icon' => [
+ 'feed_id' => 1,
+ 'icon_id' => 47
+ ],
+ ],
+ [
+ 'id' => 55,
+ 'user_id' => 42,
+ 'feed_url' => "http://example.com/eek",
+ 'site_url' => "http://example.com/",
+ 'title' => "Eek",
+ 'checked_at' => "2021-01-05T13:51:32.000000Z",
+ 'next_check_at' => "0001-01-01T00:00:00.000000Z",
+ 'etag_header' => "",
+ 'last_modified_header' => "",
+ 'parsing_error_message' => "",
+ 'parsing_error_count' => 0,
+ 'scraper_rules' => "",
+ 'rewrite_rules' => "",
+ 'crawler' => true,
+ 'blocklist_rules' => "",
+ 'keeplist_rules' => "",
+ 'user_agent' => "",
+ 'username' => "j k",
+ 'password' => "super secret",
+ 'disabled' => false,
+ 'ignore_http_cache' => false,
+ 'fetch_via_proxy' => false,
+ 'category' => [
+ 'id' => 1,
+ 'title' => "All",
+ 'user_id' => 42
+ ],
+ 'icon' => null,
+ ],
+ ]);
+ $this->assertMessage($exp, $this->req("GET", "/feeds"));
+ }
}
From e7b2f541839270183e267a2bd280995ee080d4e8 Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Tue, 19 Jan 2021 23:17:03 -0500
Subject: [PATCH 120/366] Prototype feed creation
---
.../030_Supported_Protocols/005_Miniflux.md | 5 +-
lib/Database.php | 15 +++--
lib/REST/Miniflux/V1.php | 66 ++++++++++++++++---
locale/en.php | 4 +-
tests/cases/Database/SeriesSubscription.php | 6 +-
tests/cases/REST/Miniflux/TestV1.php | 22 ++++---
6 files changed, 90 insertions(+), 28 deletions(-)
diff --git a/docs/en/030_Supported_Protocols/005_Miniflux.md b/docs/en/030_Supported_Protocols/005_Miniflux.md
index a85a61ea..c3a19211 100644
--- a/docs/en/030_Supported_Protocols/005_Miniflux.md
+++ b/docs/en/030_Supported_Protocols/005_Miniflux.md
@@ -25,16 +25,17 @@ Miniflux version 2.0.27 is emulated, though not all features are implemented
- Custom User-Agent strings
- The `disabled`, `ignore_http_cache`, and `fetch_via_proxy` flags
- Changing the URL, username, or password of a feed
+- Titles and types are not available during feed discovery and are filled with generic data
# Differences
-- Various error messages differ due to significant implementation differences
+- Various error codes and messages differ due to significant implementation differences
- `PUT` requests which return a body respond with `200 OK` rather than `201 Created`
-- Only the URL should be considered reliable in feed discovery results
- The "All" category is treated specially (see below for details)
- Category names consisting only of whitespace are rejected along with the empty string
- Filtering rules may not function identically (see below for details)
- The `checked_at` field of feeds indicates when the feed was last updated rather than when it was last checked
+- Creating a feed with the `scrape` property set to `true` might not return scraped content for the initial synchronization
# Behaviour of filtering (block and keep) rules
diff --git a/lib/Database.php b/lib/Database.php
index 3866ddaf..de829dba 100644
--- a/lib/Database.php
+++ b/lib/Database.php
@@ -745,10 +745,11 @@ class Database {
* @param string $fetchUser The user name required to access the newsfeed, if applicable
* @param string $fetchPassword The password required to fetch the newsfeed, if applicable; this will be stored in cleartext
* @param boolean $discover Whether to perform newsfeed discovery if $url points to an HTML document
+ * @param boolean $scrape Whether the initial synchronization should scrape full-article content
*/
- public function subscriptionAdd(string $user, string $url, string $fetchUser = "", string $fetchPassword = "", bool $discover = true): int {
+ public function subscriptionAdd(string $user, string $url, string $fetchUser = "", string $fetchPassword = "", bool $discover = true, bool $scrape = false): int {
// get the ID of the underlying feed, or add it if it's not yet in the database
- $feedID = $this->feedAdd($url, $fetchUser, $fetchPassword, $discover);
+ $feedID = $this->feedAdd($url, $fetchUser, $fetchPassword, $discover, $scrape);
// Add the feed to the user's subscriptions and return the new subscription's ID.
return $this->db->prepare('INSERT INTO arsse_subscriptions(owner,feed) values(?,?)', 'str', 'int')->run($user, $feedID)->lastId();
}
@@ -1089,8 +1090,9 @@ class Database {
* @param string $fetchUser The user name required to access the newsfeed, if applicable
* @param string $fetchPassword The password required to fetch the newsfeed, if applicable; this will be stored in cleartext
* @param boolean $discover Whether to perform newsfeed discovery if $url points to an HTML document
+ * @param boolean $scrape Whether the initial synchronization should scrape full-article content
*/
- public function feedAdd(string $url, string $fetchUser = "", string $fetchPassword = "", bool $discover = true): int {
+ public function feedAdd(string $url, string $fetchUser = "", string $fetchPassword = "", bool $discover = true, bool $scrape = false): int {
// normalize the input URL
$url = URL::normalize($url);
// check to see if the feed already exists
@@ -1106,7 +1108,7 @@ class Database {
$feedID = $this->db->prepare('INSERT INTO arsse_feeds(url,username,password) values(?,?,?)', 'str', 'str', 'str')->run($url, $fetchUser, $fetchPassword)->lastId();
try {
// perform an initial update on the newly added feed
- $this->feedUpdate($feedID, true);
+ $this->feedUpdate($feedID, true, $scrape);
} catch (\Throwable $e) {
// if the update fails, delete the feed we just added
$this->db->prepare('DELETE from arsse_feeds where id = ?', 'int')->run($feedID);
@@ -1126,8 +1128,9 @@ class Database {
*
* @param integer $feedID The numerical identifier of the newsfeed to refresh
* @param boolean $throwError Whether to throw an exception on failure in addition to storing error information in the database
+ * @param boolean|null $scrapeOverride If not null, overrides information in the database signaling whether or not to scrape full-article content. This is intended for when there are no subscriptions for the feed in the database yet
*/
- public function feedUpdate($feedID, bool $throwError = false): bool {
+ public function feedUpdate($feedID, bool $throwError = false, ?bool $scrapeOverride = null): bool {
// check to make sure the feed exists
if (!V::id($feedID)) {
throw new Db\ExceptionInput("typeViolation", ["action" => __FUNCTION__, "field" => "feed", 'id' => $feedID, 'type' => "int > 0"]);
@@ -1144,7 +1147,7 @@ class Database {
throw new Db\ExceptionInput("subjectMissing", ["action" => __FUNCTION__, "field" => "feed", 'id' => $feedID]);
}
// determine whether the feed's items should be scraped for full content from the source Web site
- $scrape = (Arsse::$conf->fetchEnableScraping && $f['scrapers']);
+ $scrape = (Arsse::$conf->fetchEnableScraping && ($scrapeOverride ?? $f['scrapers']));
// the Feed object throws an exception when there are problems, but that isn't ideal
// here. When an exception is thrown it should update the database with the
// error instead of failing; if other exceptions are thrown, we should simply roll back
diff --git a/lib/REST/Miniflux/V1.php b/lib/REST/Miniflux/V1.php
index 43d9e36c..7d481da1 100644
--- a/lib/REST/Miniflux/V1.php
+++ b/lib/REST/Miniflux/V1.php
@@ -14,8 +14,10 @@ use JKingWeb\Arsse\Context\Context;
use JKingWeb\Arsse\Db\ExceptionInput;
use JKingWeb\Arsse\Misc\HTTP;
use JKingWeb\Arsse\Misc\Date;
+use JKingWeb\Arsse\Misc\URL;
use JKingWeb\Arsse\Misc\ValueInfo as V;
use JKingWeb\Arsse\REST\Exception;
+use JKingWeb\Arsse\Rule\Rule;
use JKingWeb\Arsse\User\ExceptionConflict;
use JKingWeb\Arsse\User\Exception as UserException;
use Psr\Http\Message\ServerRequestInterface;
@@ -31,12 +33,25 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
protected const ACCEPTED_TYPES_JSON = ["application/json"];
protected const TOKEN_LENGTH = 32;
protected const VALID_JSON = [
- // user properties which map directly to Arsse user metadata are listed separately
- 'url' => "string",
- 'username' => "string",
- 'password' => "string",
- 'user_agent' => "string",
- 'title' => "string",
+ // user properties which map directly to Arsse user metadata are listed separately;
+ // not all these properties are used by our implementation, but they are treated
+ // with the same strictness as in Miniflux to ease cross-compatibility
+ 'url' => "string",
+ 'username' => "string",
+ 'password' => "string",
+ 'user_agent' => "string",
+ 'title' => "string",
+ 'feed_url' => "string",
+ 'category_id' => "integer",
+ 'crawler' => "boolean",
+ 'user_agent' => "string",
+ 'scraper_rules' => "string",
+ 'rewrite_rules' => "string",
+ 'keeplist_rules' => "string",
+ 'blocklist_rules' => "string",
+ 'disabled' => "boolean",
+ 'ignore_http_cache' => "boolean",
+ 'fetch_via_proxy' => "boolean",
];
protected const USER_META_MAP = [
// Miniflux ID // Arsse ID Default value Extra
@@ -81,7 +96,7 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
],
'/feeds' => [
'GET' => ["getFeeds", false, false, false, false, []],
- 'POST' => ["createFeed", false, false, true, false, []],
+ 'POST' => ["createFeed", false, false, true, false, ["feed_url", "category_id"]],
],
'/feeds/1' => [
'GET' => ["getFeed", false, true, false, false, []],
@@ -263,6 +278,10 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
$body[$k] = null;
} elseif (gettype($body[$k]) !== $t) {
return new ErrorResponse(["InvalidInputType", 'field' => $k, 'expected' => $t, 'actual' => gettype($body[$k])], 422);
+ } elseif (in_array($k, ["keeplist_rules", "blocklist_rules"]) && !Rule::validate($body[$k])) {
+ return new ErrorResponse(["InvalidInputValue", 'field' => $k], 422);
+ } elseif (in_array($k, ["url", "feed_url"]) && !URL::absolute($body[$k])) {
+ return new ErrorResponse(["InvalidInputValue", 'field' => $k], 422);
}
}
//normalize user-specific input
@@ -377,7 +396,7 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
10506 => "Fetch403",
10507 => "Fetch401",
][$e->getCode()] ?? "FetchOther";
- return new ErrorResponse($msg, 500);
+ return new ErrorResponse($msg, 502);
}
$out = [];
foreach ($list as $url) {
@@ -646,6 +665,37 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
return new Response($out);
}
+ protected function createFeed(array $data): ResponseInterface {
+ $props = [
+ 'keep_rule' => $data['keeplist_rules'],
+ 'block_rule' => $data['blocklist_rules'],
+ 'folder' => $data['category_id'] - 1,
+ 'scrape' => (bool) $data['crawler'],
+ ];
+ try {
+ Arsse::$db->feedAdd($data['feed_url'], (string) $data['username'], (string) $data['password'], false, (bool) $data['crawler']);
+ $tr = Arsse::$db->begin();
+ $id = Arsse::$db->subscriptionAdd(Arsse::$user->id, $data['feed_url'], (string) $data['username'], (string) $data['password'], false, (bool) $data['crawler']);
+ Arsse::$db->subscriptionPropertiesSet(Arsse::$user->id, $id, $props);
+ $tr->commit();
+ } catch (FeedException $e) {
+ $msg = [
+ 10502 => "Fetch404",
+ 10506 => "Fetch403",
+ 10507 => "Fetch401",
+ ][$e->getCode()] ?? "FetchOther";
+ return new ErrorResponse($msg, 502);
+ } catch (ExceptionInput $e) {
+ switch ($e->getCode()) {
+ case 10235:
+ return new ErrorResponse("MissingCategory", 422);
+ case 10236:
+ return new ErrorResponse("DuplicateFeed", 409);
+ }
+ }
+ return new Response(['feed_id' => $id], 201);
+ }
+
public static function tokenGenerate(string $user, string $label): string {
// Miniflux produces tokens in base64url alphabet
$t = str_replace(["+", "/"], ["-", "_"], base64_encode(random_bytes(self::TOKEN_LENGTH)));
diff --git a/locale/en.php b/locale/en.php
index b8098959..1f917c4c 100644
--- a/locale/en.php
+++ b/locale/en.php
@@ -19,10 +19,12 @@ return [
'API.Miniflux.Error.Fetch401' => 'You are not authorized to access this resource (invalid username/password)',
'API.Miniflux.Error.Fetch403' => 'Unable to fetch this resource (Status Code = 403)',
'API.Miniflux.Error.FetchOther' => 'Unable to fetch this resource',
- 'API.Miniflux.Error.DuplicateCategory' => 'Category "{title}" already exists',
+ 'API.Miniflux.Error.DuplicateCategory' => 'This category already exists.',
'API.Miniflux.Error.InvalidCategory' => 'Invalid category title "{title}"',
+ 'API.Miniflux.Error.MissingCategory' => 'This category does not exist or does not belong to this user.',
'API.Miniflux.Error.InvalidElevation' => 'Only administrators can change permissions of standard users',
'API.Miniflux.Error.DuplicateUser' => 'The user name "{user}" already exists',
+ 'API.Miniflux.Error.DuplicateFeed' => 'This feed already exists.',
'API.TTRSS.Category.Uncategorized' => 'Uncategorized',
'API.TTRSS.Category.Special' => 'Special',
diff --git a/tests/cases/Database/SeriesSubscription.php b/tests/cases/Database/SeriesSubscription.php
index 389495d3..3cdeeead 100644
--- a/tests/cases/Database/SeriesSubscription.php
+++ b/tests/cases/Database/SeriesSubscription.php
@@ -221,7 +221,7 @@ trait SeriesSubscription {
$subID = $this->nextID("arsse_subscriptions");
\Phake::when(Arsse::$db)->feedUpdate->thenReturn(true);
$this->assertSame($subID, Arsse::$db->subscriptionAdd($this->user, $url, "", "", false));
- \Phake::verify(Arsse::$db)->feedUpdate($feedID, true);
+ \Phake::verify(Arsse::$db)->feedUpdate($feedID, true, false);
$state = $this->primeExpectations($this->data, [
'arsse_feeds' => ['id','url','username','password'],
'arsse_subscriptions' => ['id','owner','feed'],
@@ -238,7 +238,7 @@ trait SeriesSubscription {
$subID = $this->nextID("arsse_subscriptions");
\Phake::when(Arsse::$db)->feedUpdate->thenReturn(true);
$this->assertSame($subID, Arsse::$db->subscriptionAdd($this->user, $url, "", "", true));
- \Phake::verify(Arsse::$db)->feedUpdate($feedID, true);
+ \Phake::verify(Arsse::$db)->feedUpdate($feedID, true, false);
$state = $this->primeExpectations($this->data, [
'arsse_feeds' => ['id','url','username','password'],
'arsse_subscriptions' => ['id','owner','feed'],
@@ -256,7 +256,7 @@ trait SeriesSubscription {
try {
Arsse::$db->subscriptionAdd($this->user, $url, "", "", false);
} finally {
- \Phake::verify(Arsse::$db)->feedUpdate($feedID, true);
+ \Phake::verify(Arsse::$db)->feedUpdate($feedID, true, false);
$state = $this->primeExpectations($this->data, [
'arsse_feeds' => ['id','url','username','password'],
'arsse_subscriptions' => ['id','owner','feed'],
diff --git a/tests/cases/REST/Miniflux/TestV1.php b/tests/cases/REST/Miniflux/TestV1.php
index 54e03435..fbcf82c9 100644
--- a/tests/cases/REST/Miniflux/TestV1.php
+++ b/tests/cases/REST/Miniflux/TestV1.php
@@ -172,16 +172,22 @@ class TestV1 extends \JKingWeb\Arsse\Test\AbstractTest {
$this->assertMessage($exp, $this->req("POST", "/discover", ['url' => 2112]));
}
- public function testDiscoverFeeds(): void {
- $exp = new Response([
+ /** @dataProvider provideDiscoveries */
+ public function testDiscoverFeeds($in, ResponseInterface $exp): void {
+ $this->assertMessage($exp, $this->req("POST", "/discover", ['url' => $in]));
+ }
+
+ public function provideDiscoveries(): iterable {
+ self::clearData();
+ $discovered = [
['title' => "Feed", 'type' => "rss", 'url' => "http://localhost:8000/Feed/Discovery/Feed"],
['title' => "Feed", 'type' => "rss", 'url' => "http://localhost:8000/Feed/Discovery/Missing"],
- ]);
- $this->assertMessage($exp, $this->req("POST", "/discover", ['url' => "http://localhost:8000/Feed/Discovery/Valid"]));
- $exp = new Response([]);
- $this->assertMessage($exp, $this->req("POST", "/discover", ['url' => "http://localhost:8000/Feed/Discovery/Invalid"]));
- $exp = new ErrorResponse("Fetch404", 500);
- $this->assertMessage($exp, $this->req("POST", "/discover", ['url' => "http://localhost:8000/Feed/Discovery/Missing"]));
+ ];
+ return [
+ ["http://localhost:8000/Feed/Discovery/Valid", new Response($discovered)],
+ ["http://localhost:8000/Feed/Discovery/Invalid", new Response([])],
+ ["http://localhost:8000/Feed/Discovery/Missing", new ErrorResponse("Fetch404", 502)],
+ ];
}
/** @dataProvider provideUserQueries */
From fd25be5c27346ef3f2a67b3f829376edd1243dfd Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Wed, 20 Jan 2021 18:28:51 -0500
Subject: [PATCH 121/366] Basic tests for feed creation
---
lib/REST/Miniflux/V1.php | 8 ++--
tests/cases/REST/Miniflux/TestV1.php | 62 +++++++++++++++++++++++++++-
2 files changed, 66 insertions(+), 4 deletions(-)
diff --git a/lib/REST/Miniflux/V1.php b/lib/REST/Miniflux/V1.php
index 7d481da1..44c8c3b8 100644
--- a/lib/REST/Miniflux/V1.php
+++ b/lib/REST/Miniflux/V1.php
@@ -278,9 +278,11 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
$body[$k] = null;
} elseif (gettype($body[$k]) !== $t) {
return new ErrorResponse(["InvalidInputType", 'field' => $k, 'expected' => $t, 'actual' => gettype($body[$k])], 422);
- } elseif (in_array($k, ["keeplist_rules", "blocklist_rules"]) && !Rule::validate($body[$k])) {
- return new ErrorResponse(["InvalidInputValue", 'field' => $k], 422);
- } elseif (in_array($k, ["url", "feed_url"]) && !URL::absolute($body[$k])) {
+ } elseif (
+ (in_array($k, ["keeplist_rules", "blocklist_rules"]) && !Rule::validate($body[$k])) ||
+ (in_array($k, ["url", "feed_url"]) && !URL::absolute($body[$k])) ||
+ ($k === "category_id" && $body[$k] < 1)
+ ) {
return new ErrorResponse(["InvalidInputValue", 'field' => $k], 422);
}
}
diff --git a/tests/cases/REST/Miniflux/TestV1.php b/tests/cases/REST/Miniflux/TestV1.php
index fbcf82c9..84965ad0 100644
--- a/tests/cases/REST/Miniflux/TestV1.php
+++ b/tests/cases/REST/Miniflux/TestV1.php
@@ -184,9 +184,12 @@ class TestV1 extends \JKingWeb\Arsse\Test\AbstractTest {
['title' => "Feed", 'type' => "rss", 'url' => "http://localhost:8000/Feed/Discovery/Missing"],
];
return [
- ["http://localhost:8000/Feed/Discovery/Valid", new Response($discovered)],
+ ["http://localhost:8000/Feed/Discovery/Valid", new Response($discovered)],
["http://localhost:8000/Feed/Discovery/Invalid", new Response([])],
["http://localhost:8000/Feed/Discovery/Missing", new ErrorResponse("Fetch404", 502)],
+ [1, new ErrorResponse(["InvalidInputType", 'field' => "url", 'expected' => "string", 'actual' => "integer"], 422)],
+ ["Not a URL", new ErrorResponse(["InvalidInputValue", 'field' => "url"], 422)],
+ [null, new ErrorResponse(["MissingInputValue", 'field' => "url"], 422)],
];
}
@@ -607,4 +610,61 @@ class TestV1 extends \JKingWeb\Arsse\Test\AbstractTest {
]);
$this->assertMessage($exp, $this->req("GET", "/feeds"));
}
+
+ /** @dataProvider provideFeedCreations */
+ public function testCreateAFeed(array $in, $out1, $out2, $out3, ResponseInterface $exp): void {
+ if ($out1 instanceof \Exception) {
+ \Phake::when(Arsse::$db)->feedAdd->thenThrow($out1);
+ } else {
+ \Phake::when(Arsse::$db)->feedAdd->thenReturn($out1);
+ }
+ if ($out2 instanceof \Exception) {
+ \Phake::when(Arsse::$db)->subscriptionAdd->thenThrow($out2);
+ } else {
+ \Phake::when(Arsse::$db)->subscriptionAdd->thenReturn($out2);
+ }
+ if ($out3 instanceof \Exception) {
+ \Phake::when(Arsse::$db)->subscriptionPropertiesSet->thenThrow($out3);
+ } else {
+ \Phake::when(Arsse::$db)->subscriptionPropertiesSet->thenReturn($out3);
+ }
+ $this->assertMessage($exp, $this->req("POST", "/feeds", $in));
+ $in1 = $out1 !== null;
+ $in2 = $out2 !== null;
+ $in3 = $out3 !== null;
+ if ($in1) {
+ \Phake::verify(Arsse::$db)->feedAdd($in['feed_url'], $in['username'] ?? "", $in['password'] ?? "", true, $in['crawler'] ?? false);
+ } else {
+ \Phake::verify(Arsse::$db, \Phake::times(0))->feedAdd;
+ }
+ if ($in2) {
+ \Phake::verify(Arsse::$db)->subscriptionAdd("john.doe@example.com", $in['feed_url'], $in['username'] ?? "", $in['password'] ?? "", true, $in['crawler'] ?? false);
+ } else {
+ \Phake::verify(Arsse::$db, \Phake::times(0))->subscriptionAdd;
+ }
+ if ($in3) {
+ $props = [
+ 'keep_rule' => $in['keeplist_rules'],
+ 'block_rule' => $in['blocklist_rules'],
+ 'folder' => $in['category_id'] - 1,
+ 'scrape' => $in['crawler'] ?? false,
+ ];
+ \Phake::verify(Arsse::$db)->subscriptionPropertiesSet("john.doe@example.com", $out2, $props);
+ } else {
+ \Phake::verify(Arsse::$db, \Phake::times(0))->subscriptionPropertiesSet;
+ }
+ }
+
+ public function provideFeedCreations(): iterable {
+ self::clearData();
+ return [
+ [['category_id' => 1], null, null, null, new ErrorResponse(["MissingInputValue", 'field' => "feed_url"], 422)],
+ [['feed_url' => "http://example.com/"], null, null, null, new ErrorResponse(["MissingInputValue", 'field' => "category_id"], 422)],
+ [['feed_url' => "http://example.com/", 'category_id' => "1"], null, null, null, new ErrorResponse(["InvalidInputType", 'field' => "category_id", 'expected' => "integer", 'actual' => "string"], 422)],
+ [['feed_url' => "Not a URL", 'category_id' => 1], null, null, null, new ErrorResponse(["InvalidInputValue", 'field' => "feed_url"], 422)],
+ [['feed_url' => "http://example.com/", 'category_id' => 0], null, null, null, new ErrorResponse(["InvalidInputValue", 'field' => "category_id"], 422)],
+ [['feed_url' => "http://example.com/", 'category_id' => 1, 'keeplist_rules' => "["], null, null, null, new ErrorResponse(["InvalidInputValue", 'field' => "keeplist_rules"], 422)],
+ [['feed_url' => "http://example.com/", 'category_id' => 1, 'blocklist_rules' => "["], null, null, null, new ErrorResponse(["InvalidInputValue", 'field' => "blocklist_rules"], 422)],
+ ];
+ }
}
From 6936f365e4b10d565cd8ea81e264a54e4a7b8e5a Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Thu, 21 Jan 2021 11:11:25 -0500
Subject: [PATCH 122/366] Add calls coming in next version of Miniflux
---
lib/REST/Miniflux/V1.php | 9 +++++++++
1 file changed, 9 insertions(+)
diff --git a/lib/REST/Miniflux/V1.php b/lib/REST/Miniflux/V1.php
index 44c8c3b8..fdcac8a6 100644
--- a/lib/REST/Miniflux/V1.php
+++ b/lib/REST/Miniflux/V1.php
@@ -75,6 +75,15 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
'PUT' => ["updateCategory", false, true, true, false, ["title"]], // title is effectively required since no other field can be changed
'DELETE' => ["deleteCategory", false, true, false, false, []],
],
+ '/categories/1/entries' => [
+ 'GET' => ["getCategoryEntries", false, false, false, true, []],
+ ],
+ '/categories/1/entries/1' => [
+ 'GET' => ["getCategoryEntry", false, false, false, true, []],
+ ],
+ '/categories/1/feeds' => [
+ 'GET' => ["getCategoryFeeds", false, false, false, true, []],
+ ],
'/categories/1/mark-all-as-read' => [
'PUT' => ["markCategory", false, true, false, false, []],
],
From 4972c79e321c4c56e9ecad489b1d1863598538a7 Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Thu, 21 Jan 2021 22:44:22 -0500
Subject: [PATCH 123/366] Allow simpler feed exception creation
---
lib/Feed.php | 10 ++---
lib/Feed/Exception.php | 45 +++++++++++----------
tests/cases/CLI/TestCLI.php | 2 +-
tests/cases/Database/SeriesSubscription.php | 2 +-
tests/cases/Feed/TestException.php | 12 +++---
tests/cases/REST/NextcloudNews/TestV1_2.php | 2 +-
tests/cases/REST/TinyTinyRSS/TestAPI.php | 14 +++----
tests/lib/FeedException.php | 15 +++++++
8 files changed, 60 insertions(+), 42 deletions(-)
create mode 100644 tests/lib/FeedException.php
diff --git a/lib/Feed.php b/lib/Feed.php
index af43f22e..ce4ab4c1 100644
--- a/lib/Feed.php
+++ b/lib/Feed.php
@@ -39,7 +39,7 @@ class Feed {
if (!$links) {
// work around a PicoFeed memory leak
libxml_use_internal_errors(false);
- throw new Feed\Exception($url, new \PicoFeed\Reader\SubscriptionNotFoundException('Unable to find a subscription'));
+ throw new Feed\Exception("", ['url' => $url], new \PicoFeed\Reader\SubscriptionNotFoundException('Unable to find a subscription'));
} else {
$out = $links[0];
}
@@ -119,9 +119,9 @@ class Feed {
$client->reader = $reader;
return $client;
} catch (PicoFeedException $e) {
- throw new Feed\Exception($url, $e); // @codeCoverageIgnore
+ throw new Feed\Exception("", ['url' => $url], $e); // @codeCoverageIgnore
} catch (\GuzzleHttp\Exception\GuzzleException $e) {
- throw new Feed\Exception($url, $e);
+ throw new Feed\Exception("", ['url' => $url], $e);
}
}
@@ -133,9 +133,9 @@ class Feed {
$this->resource->getEncoding()
)->execute();
} catch (PicoFeedException $e) {
- throw new Feed\Exception($this->resource->getUrl(), $e);
+ throw new Feed\Exception("", ['url' => $this->resource->getUrl()], $e);
} catch (\GuzzleHttp\Exception\GuzzleException $e) { // @codeCoverageIgnore
- throw new Feed\Exception($this->resource->getUrl(), $e); // @codeCoverageIgnore
+ throw new Feed\Exception("", ['url' => $this->resource->getUrl()], $e); // @codeCoverageIgnore
}
// Grab the favicon for the feed, or null if no valid icon is found
diff --git a/lib/Feed/Exception.php b/lib/Feed/Exception.php
index 2bf181e6..1a8e68fc 100644
--- a/lib/Feed/Exception.php
+++ b/lib/Feed/Exception.php
@@ -15,30 +15,33 @@ class Exception extends \JKingWeb\Arsse\AbstractException {
protected const CURL_ERROR_MAP = [1 => "invalidUrl",3 => "invalidUrl",5 => "transmissionError","connectionFailed","connectionFailed","transmissionError","forbidden","unauthorized","transmissionError","transmissionError","transmissionError","transmissionError","connectionFailed","connectionFailed","transmissionError","transmissionError","transmissionError","transmissionError","transmissionError","invalidUrl","transmissionError","transmissionError","transmissionError","transmissionError",28 => "timeout","transmissionError","transmissionError","transmissionError","transmissionError","transmissionError",35 => "invalidCertificate","transmissionError","transmissionError","transmissionError","transmissionError",45 => "transmissionError","unauthorized","maxRedirect",52 => "transmissionError","invalidCertificate","invalidCertificate","transmissionError","transmissionError",58 => "invalidCertificate","invalidCertificate","invalidCertificate","transmissionError","invalidUrl","transmissionError","invalidCertificate","transmissionError","invalidCertificate","forbidden","invalidUrl","forbidden","transmissionError",73 => "transmissionError","transmissionError",77 => "invalidCertificate","invalidUrl",90 => "invalidCertificate","invalidCertificate","transmissionError",94 => "unauthorized","transmissionError","connectionFailed"];
protected const HTTP_ERROR_MAP = [401 => "unauthorized",403 => "forbidden",404 => "invalidUrl",408 => "timeout",410 => "invalidUrl",414 => "invalidUrl",451 => "invalidUrl"];
- public function __construct($url, \Throwable $e) {
- if ($e instanceof BadResponseException) {
- $msgID = self::HTTP_ERROR_MAP[$e->getCode()] ?? "transmissionError";
- } elseif ($e instanceof TooManyRedirectsException) {
- $msgID = "maxRedirect";
- } elseif ($e instanceof GuzzleException) {
- $msg = $e->getMessage();
- if (preg_match("/^Error creating resource:/", $msg)) {
- // PHP stream error; the class of error is ambiguous
- $msgID = "transmissionError";
- } elseif (preg_match("/^cURL error (\d+):/", $msg, $match)) {
- $msgID = self::CURL_ERROR_MAP[(int) $match[1]] ?? "internalError";
+ public function __construct(string $msgID = "", $vars, \Throwable $e) {
+ if ($msgID === "") {
+ assert($e !== null, new \Exception("Expecting Picofeed or Guzzle exception when no message specified."));
+ if ($e instanceof BadResponseException) {
+ $msgID = self::HTTP_ERROR_MAP[$e->getCode()] ?? "transmissionError";
+ } elseif ($e instanceof TooManyRedirectsException) {
+ $msgID = "maxRedirect";
+ } elseif ($e instanceof GuzzleException) {
+ $msg = $e->getMessage();
+ if (preg_match("/^Error creating resource:/", $msg)) {
+ // PHP stream error; the class of error is ambiguous
+ $msgID = "transmissionError";
+ } elseif (preg_match("/^cURL error (\d+):/", $msg, $match)) {
+ $msgID = self::CURL_ERROR_MAP[(int) $match[1]] ?? "internalError";
+ } else {
+ $msgID = "internalError";
+ }
+ } elseif ($e instanceof PicoFeedException) {
+ $className = get_class($e);
+ // Convert the exception thrown by PicoFeed to the one to be thrown here.
+ $msgID = preg_replace('/^PicoFeed\\\(?:Client|Parser|Reader)\\\([A-Za-z]+)Exception$/', '$1', $className);
+ // If the message ID doesn't change then it's unknown.
+ $msgID = ($msgID !== $className) ? lcfirst($msgID) : "internalError";
} else {
$msgID = "internalError";
}
- } elseif ($e instanceof PicoFeedException) {
- $className = get_class($e);
- // Convert the exception thrown by PicoFeed to the one to be thrown here.
- $msgID = preg_replace('/^PicoFeed\\\(?:Client|Parser|Reader)\\\([A-Za-z]+)Exception$/', '$1', $className);
- // If the message ID doesn't change then it's unknown.
- $msgID = ($msgID !== $className) ? lcfirst($msgID) : "internalError";
- } else {
- $msgID = "internalError";
}
- parent::__construct($msgID, ['url' => $url], $e);
+ parent::__construct($msgID, $vars, $e);
}
}
diff --git a/tests/cases/CLI/TestCLI.php b/tests/cases/CLI/TestCLI.php
index 671fbb91..3d0d6f14 100644
--- a/tests/cases/CLI/TestCLI.php
+++ b/tests/cases/CLI/TestCLI.php
@@ -82,7 +82,7 @@ class TestCLI extends \JKingWeb\Arsse\Test\AbstractTest {
public function testRefreshAFeed(string $cmd, int $exitStatus, string $output): void {
Arsse::$db = \Phake::mock(Database::class);
\Phake::when(Arsse::$db)->feedUpdate(1, true)->thenReturn(true);
- \Phake::when(Arsse::$db)->feedUpdate(2, true)->thenThrow(new \JKingWeb\Arsse\Feed\Exception("http://example.com/", $this->mockGuzzleException(ClientException::class, "", 404)));
+ \Phake::when(Arsse::$db)->feedUpdate(2, true)->thenThrow(new \JKingWeb\Arsse\Feed\Exception("", ['url' => "http://example.com/"], $this->mockGuzzleException(ClientException::class, "", 404)));
$this->assertConsole($this->cli, $cmd, $exitStatus, $output);
\Phake::verify($this->cli)->loadConf;
\Phake::verify(Arsse::$db)->feedUpdate;
diff --git a/tests/cases/Database/SeriesSubscription.php b/tests/cases/Database/SeriesSubscription.php
index 3cdeeead..c009b600 100644
--- a/tests/cases/Database/SeriesSubscription.php
+++ b/tests/cases/Database/SeriesSubscription.php
@@ -251,7 +251,7 @@ trait SeriesSubscription {
public function testAddASubscriptionToAnInvalidFeed(): void {
$url = "http://example.org/feed1";
$feedID = $this->nextID("arsse_feeds");
- \Phake::when(Arsse::$db)->feedUpdate->thenThrow(new FeedException($url, $this->mockGuzzleException(ClientException::class, "", 404)));
+ \Phake::when(Arsse::$db)->feedUpdate->thenThrow(new FeedException("", ['url' => $url], $this->mockGuzzleException(ClientException::class, "", 404)));
$this->assertException("invalidUrl", "Feed");
try {
Arsse::$db->subscriptionAdd($this->user, $url, "", "", false);
diff --git a/tests/cases/Feed/TestException.php b/tests/cases/Feed/TestException.php
index 95adde1d..b28d0d1d 100644
--- a/tests/cases/Feed/TestException.php
+++ b/tests/cases/Feed/TestException.php
@@ -20,7 +20,7 @@ class TestException extends \JKingWeb\Arsse\Test\AbstractTest {
public function testHandleCurlErrors(int $code, string $message): void {
$e = $this->mockGuzzleException(TransferException::class, "cURL error $code: Some message", 0);
$this->assertException($message, "Feed");
- throw new FeedException("https://example.com/", $e);
+ throw new FeedException("", ['url' => "https://example.com/"], $e);
}
public function provideCurlErrors() {
@@ -119,7 +119,7 @@ class TestException extends \JKingWeb\Arsse\Test\AbstractTest {
public function testHandleHttpErrors(int $code, string $message): void {
$e = $this->mockGuzzleException(BadResponseException::class, "Irrelevant message", $code);
$this->assertException($message, "Feed");
- throw new FeedException("https://example.com/", $e);
+ throw new FeedException("", ['url' => "https://example.com/"], $e);
}
public function provideHTTPErrors() {
@@ -145,7 +145,7 @@ class TestException extends \JKingWeb\Arsse\Test\AbstractTest {
/** @dataProvider providePicoFeedException */
public function testHandlePicofeedException(PicoFeedException $e, string $message) {
$this->assertException($message, "Feed");
- throw new FeedException("https://example.com/", $e);
+ throw new FeedException("", ['url' => "https://example.com/"], $e);
}
public function providePicoFeedException() {
@@ -160,18 +160,18 @@ class TestException extends \JKingWeb\Arsse\Test\AbstractTest {
public function testHandleExcessRedirections() {
$e = $this->mockGuzzleException(TooManyRedirectsException::class, "Irrelevant message", 404);
$this->assertException("maxRedirect", "Feed");
- throw new FeedException("https://example.com/", $e);
+ throw new FeedException("", ['url' => "https://example.com/"], $e);
}
public function testHandleGenericStreamErrors() {
$e = $this->mockGuzzleException(TransferException::class, "Error creating resource: Irrelevant message", 403);
$this->assertException("transmissionError", "Feed");
- throw new FeedException("https://example.com/", $e);
+ throw new FeedException("", ['url' => "https://example.com/"], $e);
}
public function testHandleUnexpectedError() {
$e = new \Exception;
$this->assertException("internalError", "Feed");
- throw new FeedException("https://example.com/", $e);
+ throw new FeedException("", ['url' => "https://example.com/"], $e);
}
}
diff --git a/tests/cases/REST/NextcloudNews/TestV1_2.php b/tests/cases/REST/NextcloudNews/TestV1_2.php
index eccc0cc1..7b4ce328 100644
--- a/tests/cases/REST/NextcloudNews/TestV1_2.php
+++ b/tests/cases/REST/NextcloudNews/TestV1_2.php
@@ -534,7 +534,7 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest {
}
public function provideNewSubscriptions(): array {
- $feedException = new \JKingWeb\Arsse\Feed\Exception("", new \PicoFeed\Reader\SubscriptionNotFoundException);
+ $feedException = new \JKingWeb\Arsse\Feed\Exception("", [], new \PicoFeed\Reader\SubscriptionNotFoundException);
return [
[['url' => "http://example.com/news.atom", 'folderId' => 3], 2112, 0, $this->feeds['db'][0], new ExceptionInput("idMissing"), new Response(['feeds' => [$this->feeds['rest'][0]]])],
[['url' => "http://example.org/news.atom", 'folderId' => 8], 42, 4758915, $this->feeds['db'][1], true, new Response(['feeds' => [$this->feeds['rest'][1]], 'newestItemId' => 4758915])],
diff --git a/tests/cases/REST/TinyTinyRSS/TestAPI.php b/tests/cases/REST/TinyTinyRSS/TestAPI.php
index ff6d8954..6191d84b 100644
--- a/tests/cases/REST/TinyTinyRSS/TestAPI.php
+++ b/tests/cases/REST/TinyTinyRSS/TestAPI.php
@@ -787,13 +787,13 @@ LONG_STRING;
];
$out = [
['code' => 1, 'feed_id' => 2],
- ['code' => 5, 'message' => (new \JKingWeb\Arsse\Feed\Exception("http://example.com/1", $this->mockGuzzleException(ClientException::class, "", 401)))->getMessage()],
+ ['code' => 5, 'message' => (new \JKingWeb\Arsse\Feed\Exception("", ['url' => "http://example.com/1"], $this->mockGuzzleException(ClientException::class, "", 401)))->getMessage()],
['code' => 1, 'feed_id' => 0],
['code' => 0, 'feed_id' => 3],
['code' => 0, 'feed_id' => 1],
- ['code' => 3, 'message' => (new \JKingWeb\Arsse\Feed\Exception("http://localhost:8000/Feed/Discovery/Invalid", new \PicoFeed\Reader\SubscriptionNotFoundException()))->getMessage()],
- ['code' => 2, 'message' => (new \JKingWeb\Arsse\Feed\Exception("http://example.com/6", $this->mockGuzzleException(ClientException::class, "", 404)))->getMessage()],
- ['code' => 6, 'message' => (new \JKingWeb\Arsse\Feed\Exception("http://example.com/7", new \PicoFeed\Parser\MalformedXmlException()))->getMessage()],
+ ['code' => 3, 'message' => (new \JKingWeb\Arsse\Feed\Exception("", ['url' => "http://localhost:8000/Feed/Discovery/Invalid"], new \PicoFeed\Reader\SubscriptionNotFoundException()))->getMessage()],
+ ['code' => 2, 'message' => (new \JKingWeb\Arsse\Feed\Exception("", ['url' => "http://example.com/6"], $this->mockGuzzleException(ClientException::class, "", 404)))->getMessage()],
+ ['code' => 6, 'message' => (new \JKingWeb\Arsse\Feed\Exception("", ['url' => "http://example.com/7"], new \PicoFeed\Parser\MalformedXmlException()))->getMessage()],
['code' => 1, 'feed_id' => 4],
['code' => 0, 'feed_id' => 4],
];
@@ -804,13 +804,13 @@ LONG_STRING;
['id' => 4, 'url' => "http://example.com/9"],
];
\Phake::when(Arsse::$db)->subscriptionAdd(...$db[0])->thenReturn(2);
- \Phake::when(Arsse::$db)->subscriptionAdd(...$db[1])->thenThrow(new \JKingWeb\Arsse\Feed\Exception("http://example.com/1", $this->mockGuzzleException(ClientException::class, "", 401)));
+ \Phake::when(Arsse::$db)->subscriptionAdd(...$db[1])->thenThrow(new \JKingWeb\Arsse\Feed\Exception("", ['url' => "http://example.com/1"], $this->mockGuzzleException(ClientException::class, "", 401)));
\Phake::when(Arsse::$db)->subscriptionAdd(...$db[2])->thenReturn(2);
\Phake::when(Arsse::$db)->subscriptionAdd(...$db[3])->thenThrow(new ExceptionInput("constraintViolation"));
\Phake::when(Arsse::$db)->subscriptionAdd(...$db[4])->thenThrow(new ExceptionInput("constraintViolation"));
\Phake::when(Arsse::$db)->subscriptionAdd(...$db[5])->thenThrow(new ExceptionInput("constraintViolation"));
- \Phake::when(Arsse::$db)->subscriptionAdd(...$db[6])->thenThrow(new \JKingWeb\Arsse\Feed\Exception("http://example.com/6", $this->mockGuzzleException(ClientException::class, "", 404)));
- \Phake::when(Arsse::$db)->subscriptionAdd(...$db[7])->thenThrow(new \JKingWeb\Arsse\Feed\Exception("http://example.com/7", new \PicoFeed\Parser\MalformedXmlException()));
+ \Phake::when(Arsse::$db)->subscriptionAdd(...$db[6])->thenThrow(new \JKingWeb\Arsse\Feed\Exception("", ['url' => "http://example.com/6"], $this->mockGuzzleException(ClientException::class, "", 404)));
+ \Phake::when(Arsse::$db)->subscriptionAdd(...$db[7])->thenThrow(new \JKingWeb\Arsse\Feed\Exception("", ['url' => "http://example.com/7"], new \PicoFeed\Parser\MalformedXmlException()));
\Phake::when(Arsse::$db)->subscriptionAdd(...$db[8])->thenReturn(4);
\Phake::when(Arsse::$db)->subscriptionAdd(...$db[9])->thenThrow(new ExceptionInput("constraintViolation"));
\Phake::when(Arsse::$db)->folderPropertiesGet(Arsse::$user->id, 42)->thenReturn($this->v(['id' => 42]));
diff --git a/tests/lib/FeedException.php b/tests/lib/FeedException.php
new file mode 100644
index 00000000..414dbe43
--- /dev/null
+++ b/tests/lib/FeedException.php
@@ -0,0 +1,15 @@
+
Date: Fri, 22 Jan 2021 18:24:33 -0500
Subject: [PATCH 124/366] Implement feed listing by category
Also modify user list to reflect changes in Miniflux 2.0.27.
---
lib/REST/Miniflux/V1.php | 85 +++++++++++-------
tests/cases/REST/Miniflux/TestV1.php | 125 ++++++++++-----------------
tests/lib/FeedException.php | 15 ----
3 files changed, 102 insertions(+), 123 deletions(-)
delete mode 100644 tests/lib/FeedException.php
diff --git a/lib/REST/Miniflux/V1.php b/lib/REST/Miniflux/V1.php
index fdcac8a6..c9a4fdde 100644
--- a/lib/REST/Miniflux/V1.php
+++ b/lib/REST/Miniflux/V1.php
@@ -54,17 +54,17 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
'fetch_via_proxy' => "boolean",
];
protected const USER_META_MAP = [
- // Miniflux ID // Arsse ID Default value Extra
- 'is_admin' => ["admin", false, false],
- 'theme' => ["theme", "light_serif", false],
- 'language' => ["lang", "en_US", false],
- 'timezone' => ["tz", "UTC", false],
- 'entry_sorting_direction' => ["sort_asc", false, false],
- 'entries_per_page' => ["page_size", 100, false],
- 'keyboard_shortcuts' => ["shortcuts", true, false],
- 'show_reading_time' => ["reading_time", true, false],
- 'entry_swipe' => ["swipe", true, false],
- 'custom_css' => ["stylesheet", "", true],
+ // Miniflux ID // Arsse ID Default value
+ 'is_admin' => ["admin", false],
+ 'theme' => ["theme", "light_serif"],
+ 'language' => ["lang", "en_US"],
+ 'timezone' => ["tz", "UTC"],
+ 'entry_sorting_direction' => ["sort_asc", false],
+ 'entries_per_page' => ["page_size", 100],
+ 'keyboard_shortcuts' => ["shortcuts", true],
+ 'show_reading_time' => ["reading_time", true],
+ 'entry_swipe' => ["swipe", true],
+ 'stylesheet' => ["stylesheet", ""],
];
protected const CALLS = [ // handler method Admin Path Body Query Required fields
'/categories' => [
@@ -76,13 +76,13 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
'DELETE' => ["deleteCategory", false, true, false, false, []],
],
'/categories/1/entries' => [
- 'GET' => ["getCategoryEntries", false, false, false, true, []],
+ 'GET' => ["getCategoryEntries", false, true, false, false, []],
],
'/categories/1/entries/1' => [
- 'GET' => ["getCategoryEntry", false, false, false, true, []],
+ 'GET' => ["getCategoryEntry", false, true, false, false, []],
],
'/categories/1/feeds' => [
- 'GET' => ["getCategoryFeeds", false, false, false, true, []],
+ 'GET' => ["getCategoryFeeds", false, true, false, false, []],
],
'/categories/1/mark-all-as-read' => [
'PUT' => ["markCategory", false, true, false, false, []],
@@ -354,16 +354,11 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
'id' => $info['num'],
'username' => $u,
'last_login_at' => $now,
+ 'google_id' => "",
+ 'openid_connect_id' => "",
];
- foreach (self::USER_META_MAP as $ext => [$int, $default, $extra]) {
- if (!$extra) {
- $entry[$ext] = $info[$int] ?? $default;
- } else {
- if (!isset($entry['extra'])) {
- $entry['extra'] = [];
- }
- $entry['extra'][$ext] = $info[$int] ?? $default;
- }
+ foreach (self::USER_META_MAP as $ext => [$int, $default]) {
+ $entry[$ext] = $info[$int] ?? $default;
}
$entry['entry_sorting_direction'] = ($entry['entry_sorting_direction']) ? "asc" : "desc";
$out[] = $entry;
@@ -530,15 +525,21 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
return new EmptyResponse(204);
}
- protected function getCategories(): ResponseInterface {
- $out = [];
+ protected function baseCategory(): array {
+ // the root folder is always a category and is always ID 1
+ // the specific formulation is verbose, so a function makes sense
$meta = Arsse::$user->propertiesGet(Arsse::$user->id, false);
+ return ['id' => 1, 'title' => $meta['root_folder_name'] ?? Arsse::$lang->msg("API.Miniflux.DefaultCategoryName"), 'user_id' => $meta['num']];
+ }
+
+ protected function getCategories(): ResponseInterface {
// add the root folder as a category
- $out[] = ['id' => 1, 'title' => $meta['root_folder_name'] ?? Arsse::$lang->msg("API.Miniflux.DefaultCategoryName"), 'user_id' => $meta['num']];
+ $out = [$this->baseCategory()];
+ $num = $out[0]['user_id'];
// add other top folders as categories
foreach (Arsse::$db->folderList(Arsse::$user->id, null, false) as $f) {
// always add 1 to the ID since the root folder will always be 1 instead of 0.
- $out[] = ['id' => $f['id'] + 1, 'title' => $f['name'], 'user_id' => $meta['num']];
+ $out[] = ['id' => $f['id'] + 1, 'title' => $f['name'], 'user_id' => $num];
}
return new Response($out);
}
@@ -622,13 +623,13 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
}
protected function mapFolders(): array {
- $meta = Arsse::$user->propertiesGet(Arsse::$user->id, false);
- $folders = [0 => ['id' => 1, 'title' => $meta['root_folder_name'] ?? Arsse::$lang->msg("API.Miniflux.DefaultCategoryName"), 'user_id' => $meta['num']]];
+ $folders = [0 => $this->baseCategory()];
+ $num = $folders[0]['user_id'];
foreach (Arsse::$db->folderList(Arsse::$user->id, null, false) as $r) {
$folders[(int) $r['id']] = [
'id' => ((int) $r['id']) + 1,
'title' => $r['name'],
- 'user_id' => $meta['num'],
+ 'user_id' => $num,
];
}
return $folders;
@@ -676,6 +677,30 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
return new Response($out);
}
+ protected function getCategoryFeeds(array $path): ResponseInterface {
+ // transform the category number into a folder number by subtracting one
+ $folder = ((int) $path[1]) - 1;
+ // unless the folder is root, list recursive
+ $recursive = $folder > 0;
+ $tr = Arsse::$db->begin();
+ // get the list of subscriptions, or bail\
+ try {
+ $subs = Arsse::$db->subscriptionList(Arsse::$user->id, $folder, $recursive)->getAll();
+ } catch (ExceptionInput $e) {
+ // the folder does not exist
+ return new EmptyResponse(404);
+ }
+ // compile the list of folders; the feed list includes folder names
+ // NOTE: We compile the full list of folders in case someone has manually selected a non-top folder
+ $folders = $this->mapFolders();
+ // next compile the list of feeds
+ $out = [];
+ foreach ($subs as $r) {
+ $out[] = $this->transformFeed($r, $folders);
+ }
+ return new Response($out);
+ }
+
protected function createFeed(array $data): ResponseInterface {
$props = [
'keep_rule' => $data['keeplist_rules'],
diff --git a/tests/cases/REST/Miniflux/TestV1.php b/tests/cases/REST/Miniflux/TestV1.php
index 84965ad0..0bc1d50b 100644
--- a/tests/cases/REST/Miniflux/TestV1.php
+++ b/tests/cases/REST/Miniflux/TestV1.php
@@ -15,6 +15,7 @@ use JKingWeb\Arsse\Db\ExceptionInput;
use JKingWeb\Arsse\Misc\Date;
use JKingWeb\Arsse\REST\Miniflux\V1;
use JKingWeb\Arsse\REST\Miniflux\ErrorResponse;
+use JKingWeb\Arsse\Test\FeedException;
use JKingWeb\Arsse\User\ExceptionConflict;
use JKingWeb\Arsse\User\ExceptionInput as UserExceptionInput;
use Psr\Http\Message\ResponseInterface;
@@ -34,6 +35,8 @@ class TestV1 extends \JKingWeb\Arsse\Test\AbstractTest {
'id' => 1,
'username' => "john.doe@example.com",
'last_login_at' => self::NOW,
+ 'google_id' => "",
+ 'openid_connect_id' => "",
'is_admin' => true,
'theme' => "custom",
'language' => "fr_CA",
@@ -43,14 +46,14 @@ class TestV1 extends \JKingWeb\Arsse\Test\AbstractTest {
'keyboard_shortcuts' => false,
'show_reading_time' => false,
'entry_swipe' => false,
- 'extra' => [
- 'custom_css' => "p {}",
- ],
+ 'stylesheet' => "p {}",
],
[
'id' => 2,
'username' => "jane.doe@example.com",
'last_login_at' => self::NOW,
+ 'google_id' => "",
+ 'openid_connect_id' => "",
'is_admin' => false,
'theme' => "light_serif",
'language' => "en_US",
@@ -60,11 +63,17 @@ class TestV1 extends \JKingWeb\Arsse\Test\AbstractTest {
'keyboard_shortcuts' => true,
'show_reading_time' => true,
'entry_swipe' => true,
- 'extra' => [
- 'custom_css' => "",
- ],
+ 'stylesheet' => "",
],
];
+ protected $feeds = [
+ ['id' => 1, 'feed' => 12, 'url' => "http://example.com/ook", 'title' => "Ook", 'source' => "http://example.com/", 'icon_id' => 47, 'icon_url' => "http://example.com/icon", 'folder' => 2112, 'top_folder' => 5, 'pinned' => 0, 'err_count' => 1, 'err_msg' => "Oopsie", 'order_type' => 0, 'keep_rule' => "this|that", 'block_rule' => "both", 'added' => "2020-12-21 21:12:00", 'updated' => "2021-01-05 13:51:32", 'edited' => "2021-01-01 00:00:00", 'modified' => "2020-11-30 04:08:52", 'next_fetch' => "2021-01-20 00:00:00", 'etag' => "OOKEEK", 'scrape' => 0, 'unread' => 42],
+ ['id' => 55, 'feed' => 12, 'url' => "http://j%20k:super%20secret@example.com/eek", 'title' => "Eek", 'source' => "http://example.com/", 'icon_id' => null, 'icon_url' => null, 'folder' => null, 'top_folder' => null, 'pinned' => 0, 'err_count' => 0, 'err_msg' => null, 'order_type' => 0, 'keep_rule' => null, 'block_rule' => null, 'added' => "2020-12-21 21:12:00", 'updated' => "2021-01-05 13:51:32", 'edited' => null, 'modified' => "2020-11-30 04:08:52", 'next_fetch' => null, 'etag' => null, 'scrape' => 1, 'unread' => 0],
+ ];
+ protected $feedsOut = [
+ ['id' => 1, 'user_id' => 42, 'feed_url' => "http://example.com/ook", 'site_url' => "http://example.com/", 'title' => "Ook", 'checked_at' => "2021-01-05T13:51:32.000000Z", 'next_check_at' => "2021-01-20T00:00:00.000000Z", 'etag_header' => "OOKEEK", 'last_modified_header' => "Fri, 01 Jan 2021 00:00:00 GMT", 'parsing_error_message' => "Oopsie", 'parsing_error_count' => 1, 'scraper_rules' => "", 'rewrite_rules' => "", 'crawler' => false, 'blocklist_rules' => "both", 'keeplist_rules' => "this|that", 'user_agent' => "", 'username' => "", 'password' => "", 'disabled' => false, 'ignore_http_cache' => false, 'fetch_via_proxy' => false, 'category' => ['id' => 6, 'title' => "Cat Ook", 'user_id' => 42], 'icon' => ['feed_id' => 1,'icon_id' => 47]],
+ ['id' => 55, 'user_id' => 42, 'feed_url' => "http://example.com/eek", 'site_url' => "http://example.com/", 'title' => "Eek", 'checked_at' => "2021-01-05T13:51:32.000000Z", 'next_check_at' => "0001-01-01T00:00:00.000000Z", 'etag_header' => "", 'last_modified_header' => "", 'parsing_error_message' => "", 'parsing_error_count' => 0, 'scraper_rules' => "", 'rewrite_rules' => "", 'crawler' => true, 'blocklist_rules' => "", 'keeplist_rules' => "", 'user_agent' => "", 'username' => "j k", 'password' => "super secret", 'disabled' => false, 'ignore_http_cache' => false, 'fetch_via_proxy' => false, 'category' => ['id' => 1,'title' => "All", 'user_id' => 42], 'icon' => null],
+ ];
protected function req(string $method, string $target, $data = "", array $headers = [], ?string $user = "john.doe@example.com", bool $body = true): ResponseInterface {
$prefix = "/v1";
@@ -535,82 +544,42 @@ class TestV1 extends \JKingWeb\Arsse\Test\AbstractTest {
);
}
- public function testListReeds(): void {
- \Phake::when(Arsse::$db)->folderList->thenReturn(new Result([
+ public function testListFeeds(): void {
+ \Phake::when(Arsse::$db)->folderList->thenReturn(new Result($this->v([
['id' => 5, 'name' => "Cat Ook"],
- ]));
- \Phake::when(Arsse::$db)->subscriptionList->thenReturn(new Result([
- ['id' => 1, 'feed' => 12, 'url' => "http://example.com/ook", 'title' => "Ook", 'source' => "http://example.com/", 'icon_id' => 47, 'icon_url' => "http://example.com/icon", 'folder' => 2112, 'top_folder' => 5, 'pinned' => 0, 'err_count' => 1, 'err_msg' => "Oopsie", 'order_type' => 0, 'keep_rule' => "this|that", 'block_rule' => "both", 'added' => "2020-12-21 21:12:00", 'updated' => "2021-01-05 13:51:32", 'edited' => "2021-01-01 00:00:00", 'modified' => "2020-11-30 04:08:52", 'next_fetch' => "2021-01-20 00:00:00", 'etag' => "OOKEEK", 'scrape' => 0, 'unread' => 42],
- ['id' => 55, 'feed' => 12, 'url' => "http://j%20k:super%20secret@example.com/eek", 'title' => "Eek", 'source' => "http://example.com/", 'icon_id' => null, 'icon_url' => null, 'folder' => null, 'top_folder' => null, 'pinned' => 0, 'err_count' => 0, 'err_msg' => null, 'order_type' => 0, 'keep_rule' => null, 'block_rule' => null, 'added' => "2020-12-21 21:12:00", 'updated' => "2021-01-05 13:51:32", 'edited' => null, 'modified' => "2020-11-30 04:08:52", 'next_fetch' => null, 'etag' => null, 'scrape' => 1, 'unread' => 0],
- ]));
- $exp = new Response([
- [
- 'id' => 1,
- 'user_id' => 42,
- 'feed_url' => "http://example.com/ook",
- 'site_url' => "http://example.com/",
- 'title' => "Ook",
- 'checked_at' => "2021-01-05T13:51:32.000000Z",
- 'next_check_at' => "2021-01-20T00:00:00.000000Z",
- 'etag_header' => "OOKEEK",
- 'last_modified_header' => "Fri, 01 Jan 2021 00:00:00 GMT",
- 'parsing_error_message' => "Oopsie",
- 'parsing_error_count' => 1,
- 'scraper_rules' => "",
- 'rewrite_rules' => "",
- 'crawler' => false,
- 'blocklist_rules' => "both",
- 'keeplist_rules' => "this|that",
- 'user_agent' => "",
- 'username' => "",
- 'password' => "",
- 'disabled' => false,
- 'ignore_http_cache' => false,
- 'fetch_via_proxy' => false,
- 'category' => [
- 'id' => 6,
- 'title' => "Cat Ook",
- 'user_id' => 42
- ],
- 'icon' => [
- 'feed_id' => 1,
- 'icon_id' => 47
- ],
- ],
- [
- 'id' => 55,
- 'user_id' => 42,
- 'feed_url' => "http://example.com/eek",
- 'site_url' => "http://example.com/",
- 'title' => "Eek",
- 'checked_at' => "2021-01-05T13:51:32.000000Z",
- 'next_check_at' => "0001-01-01T00:00:00.000000Z",
- 'etag_header' => "",
- 'last_modified_header' => "",
- 'parsing_error_message' => "",
- 'parsing_error_count' => 0,
- 'scraper_rules' => "",
- 'rewrite_rules' => "",
- 'crawler' => true,
- 'blocklist_rules' => "",
- 'keeplist_rules' => "",
- 'user_agent' => "",
- 'username' => "j k",
- 'password' => "super secret",
- 'disabled' => false,
- 'ignore_http_cache' => false,
- 'fetch_via_proxy' => false,
- 'category' => [
- 'id' => 1,
- 'title' => "All",
- 'user_id' => 42
- ],
- 'icon' => null,
- ],
- ]);
+ ])));
+ \Phake::when(Arsse::$db)->subscriptionList->thenReturn(new Result($this->v($this->feeds)));
+ $exp = new Response($this->feedsOut);
$this->assertMessage($exp, $this->req("GET", "/feeds"));
}
+ public function testListFeedsOfACategory(): void {
+ \Phake::when(Arsse::$db)->folderList->thenReturn(new Result($this->v([
+ ['id' => 5, 'name' => "Cat Ook"],
+ ])));
+ \Phake::when(Arsse::$db)->subscriptionList->thenReturn(new Result($this->v($this->feeds)));
+ $exp = new Response($this->feedsOut);
+ $this->assertMessage($exp, $this->req("GET", "/categories/2112/feeds"));
+ \Phake::verify(Arsse::$db)->subscriptionList(Arsse::$user->id, 2111, true);
+ }
+
+ public function testListFeedsOfTheRootCategory(): void {
+ \Phake::when(Arsse::$db)->folderList->thenReturn(new Result($this->v([
+ ['id' => 5, 'name' => "Cat Ook"],
+ ])));
+ \Phake::when(Arsse::$db)->subscriptionList->thenReturn(new Result($this->v($this->feeds)));
+ $exp = new Response($this->feedsOut);
+ $this->assertMessage($exp, $this->req("GET", "/categories/1/feeds"));
+ \Phake::verify(Arsse::$db)->subscriptionList(Arsse::$user->id, 0, false);
+ }
+
+ public function testListFeedsOfAMissingCategory(): void {
+ \Phake::when(Arsse::$db)->subscriptionList->thenThrow(new ExceptionInput("idMissing"));
+ $exp = new EmptyResponse(404);
+ $this->assertMessage($exp, $this->req("GET", "/categories/2112/feeds"));
+ \Phake::verify(Arsse::$db)->subscriptionList(Arsse::$user->id, 2111, true);
+ }
+
/** @dataProvider provideFeedCreations */
public function testCreateAFeed(array $in, $out1, $out2, $out3, ResponseInterface $exp): void {
if ($out1 instanceof \Exception) {
diff --git a/tests/lib/FeedException.php b/tests/lib/FeedException.php
deleted file mode 100644
index 414dbe43..00000000
--- a/tests/lib/FeedException.php
+++ /dev/null
@@ -1,15 +0,0 @@
-
Date: Sat, 23 Jan 2021 12:00:11 -0500
Subject: [PATCH 125/366] Test feed fetching errors for Miniflux
---
lib/Feed/Exception.php | 2 +-
lib/REST/Miniflux/V1.php | 3 +++
locale/en.php | 1 +
tests/cases/REST/Miniflux/TestV1.php | 34 ++++++++++++++++++++--------
4 files changed, 29 insertions(+), 11 deletions(-)
diff --git a/lib/Feed/Exception.php b/lib/Feed/Exception.php
index 1a8e68fc..113d405e 100644
--- a/lib/Feed/Exception.php
+++ b/lib/Feed/Exception.php
@@ -15,7 +15,7 @@ class Exception extends \JKingWeb\Arsse\AbstractException {
protected const CURL_ERROR_MAP = [1 => "invalidUrl",3 => "invalidUrl",5 => "transmissionError","connectionFailed","connectionFailed","transmissionError","forbidden","unauthorized","transmissionError","transmissionError","transmissionError","transmissionError","connectionFailed","connectionFailed","transmissionError","transmissionError","transmissionError","transmissionError","transmissionError","invalidUrl","transmissionError","transmissionError","transmissionError","transmissionError",28 => "timeout","transmissionError","transmissionError","transmissionError","transmissionError","transmissionError",35 => "invalidCertificate","transmissionError","transmissionError","transmissionError","transmissionError",45 => "transmissionError","unauthorized","maxRedirect",52 => "transmissionError","invalidCertificate","invalidCertificate","transmissionError","transmissionError",58 => "invalidCertificate","invalidCertificate","invalidCertificate","transmissionError","invalidUrl","transmissionError","invalidCertificate","transmissionError","invalidCertificate","forbidden","invalidUrl","forbidden","transmissionError",73 => "transmissionError","transmissionError",77 => "invalidCertificate","invalidUrl",90 => "invalidCertificate","invalidCertificate","transmissionError",94 => "unauthorized","transmissionError","connectionFailed"];
protected const HTTP_ERROR_MAP = [401 => "unauthorized",403 => "forbidden",404 => "invalidUrl",408 => "timeout",410 => "invalidUrl",414 => "invalidUrl",451 => "invalidUrl"];
- public function __construct(string $msgID = "", $vars, \Throwable $e) {
+ public function __construct(string $msgID = "", $vars = null, \Throwable $e = null) {
if ($msgID === "") {
assert($e !== null, new \Exception("Expecting Picofeed or Guzzle exception when no message specified."));
if ($e instanceof BadResponseException) {
diff --git a/lib/REST/Miniflux/V1.php b/lib/REST/Miniflux/V1.php
index c9a4fdde..303dac16 100644
--- a/lib/REST/Miniflux/V1.php
+++ b/lib/REST/Miniflux/V1.php
@@ -401,6 +401,7 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
10502 => "Fetch404",
10506 => "Fetch403",
10507 => "Fetch401",
+ 10521 => "Fetch404",
][$e->getCode()] ?? "FetchOther";
return new ErrorResponse($msg, 502);
}
@@ -719,6 +720,8 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
10502 => "Fetch404",
10506 => "Fetch403",
10507 => "Fetch401",
+ 10521 => "Fetch404",
+ 10522 => "FetchFormat",
][$e->getCode()] ?? "FetchOther";
return new ErrorResponse($msg, 502);
} catch (ExceptionInput $e) {
diff --git a/locale/en.php b/locale/en.php
index 1f917c4c..812a50c9 100644
--- a/locale/en.php
+++ b/locale/en.php
@@ -19,6 +19,7 @@ return [
'API.Miniflux.Error.Fetch401' => 'You are not authorized to access this resource (invalid username/password)',
'API.Miniflux.Error.Fetch403' => 'Unable to fetch this resource (Status Code = 403)',
'API.Miniflux.Error.FetchOther' => 'Unable to fetch this resource',
+ 'API.Miniflux.Error.FetchFormat' => 'Unsupported feed format',
'API.Miniflux.Error.DuplicateCategory' => 'This category already exists.',
'API.Miniflux.Error.InvalidCategory' => 'Invalid category title "{title}"',
'API.Miniflux.Error.MissingCategory' => 'This category does not exist or does not belong to this user.',
diff --git a/tests/cases/REST/Miniflux/TestV1.php b/tests/cases/REST/Miniflux/TestV1.php
index 0bc1d50b..a18a3025 100644
--- a/tests/cases/REST/Miniflux/TestV1.php
+++ b/tests/cases/REST/Miniflux/TestV1.php
@@ -15,7 +15,7 @@ use JKingWeb\Arsse\Db\ExceptionInput;
use JKingWeb\Arsse\Misc\Date;
use JKingWeb\Arsse\REST\Miniflux\V1;
use JKingWeb\Arsse\REST\Miniflux\ErrorResponse;
-use JKingWeb\Arsse\Test\FeedException;
+use JKingWeb\Arsse\Feed\Exception as FeedException;
use JKingWeb\Arsse\User\ExceptionConflict;
use JKingWeb\Arsse\User\ExceptionInput as UserExceptionInput;
use Psr\Http\Message\ResponseInterface;
@@ -602,12 +602,12 @@ class TestV1 extends \JKingWeb\Arsse\Test\AbstractTest {
$in2 = $out2 !== null;
$in3 = $out3 !== null;
if ($in1) {
- \Phake::verify(Arsse::$db)->feedAdd($in['feed_url'], $in['username'] ?? "", $in['password'] ?? "", true, $in['crawler'] ?? false);
+ \Phake::verify(Arsse::$db)->feedAdd($in['feed_url'], $in['username'] ?? "", $in['password'] ?? "", false, $in['crawler'] ?? false);
} else {
\Phake::verify(Arsse::$db, \Phake::times(0))->feedAdd;
}
if ($in2) {
- \Phake::verify(Arsse::$db)->subscriptionAdd("john.doe@example.com", $in['feed_url'], $in['username'] ?? "", $in['password'] ?? "", true, $in['crawler'] ?? false);
+ \Phake::verify(Arsse::$db)->subscriptionAdd("john.doe@example.com", $in['feed_url'], $in['username'] ?? "", $in['password'] ?? "", false, $in['crawler'] ?? false);
} else {
\Phake::verify(Arsse::$db, \Phake::times(0))->subscriptionAdd;
}
@@ -627,13 +627,27 @@ class TestV1 extends \JKingWeb\Arsse\Test\AbstractTest {
public function provideFeedCreations(): iterable {
self::clearData();
return [
- [['category_id' => 1], null, null, null, new ErrorResponse(["MissingInputValue", 'field' => "feed_url"], 422)],
- [['feed_url' => "http://example.com/"], null, null, null, new ErrorResponse(["MissingInputValue", 'field' => "category_id"], 422)],
- [['feed_url' => "http://example.com/", 'category_id' => "1"], null, null, null, new ErrorResponse(["InvalidInputType", 'field' => "category_id", 'expected' => "integer", 'actual' => "string"], 422)],
- [['feed_url' => "Not a URL", 'category_id' => 1], null, null, null, new ErrorResponse(["InvalidInputValue", 'field' => "feed_url"], 422)],
- [['feed_url' => "http://example.com/", 'category_id' => 0], null, null, null, new ErrorResponse(["InvalidInputValue", 'field' => "category_id"], 422)],
- [['feed_url' => "http://example.com/", 'category_id' => 1, 'keeplist_rules' => "["], null, null, null, new ErrorResponse(["InvalidInputValue", 'field' => "keeplist_rules"], 422)],
- [['feed_url' => "http://example.com/", 'category_id' => 1, 'blocklist_rules' => "["], null, null, null, new ErrorResponse(["InvalidInputValue", 'field' => "blocklist_rules"], 422)],
+ [['category_id' => 1], null, null, null, new ErrorResponse(["MissingInputValue", 'field' => "feed_url"], 422)],
+ [['feed_url' => "http://example.com/"], null, null, null, new ErrorResponse(["MissingInputValue", 'field' => "category_id"], 422)],
+ [['feed_url' => "http://example.com/", 'category_id' => "1"], null, null, null, new ErrorResponse(["InvalidInputType", 'field' => "category_id", 'expected' => "integer", 'actual' => "string"], 422)],
+ [['feed_url' => "Not a URL", 'category_id' => 1], null, null, null, new ErrorResponse(["InvalidInputValue", 'field' => "feed_url"], 422)],
+ [['feed_url' => "http://example.com/", 'category_id' => 0], null, null, null, new ErrorResponse(["InvalidInputValue", 'field' => "category_id"], 422)],
+ [['feed_url' => "http://example.com/", 'category_id' => 1, 'keeplist_rules' => "["], null, null, null, new ErrorResponse(["InvalidInputValue", 'field' => "keeplist_rules"], 422)],
+ [['feed_url' => "http://example.com/", 'category_id' => 1, 'blocklist_rules' => "["], null, null, null, new ErrorResponse(["InvalidInputValue", 'field' => "blocklist_rules"], 422)],
+ [['feed_url' => "http://example.com/", 'category_id' => 1], new FeedException("internalError"), null, null, new ErrorResponse("FetchOther", 502)],
+ [['feed_url' => "http://example.com/", 'category_id' => 1], new FeedException("invalidCertificate"), null, null, new ErrorResponse("FetchOther", 502)],
+ [['feed_url' => "http://example.com/", 'category_id' => 1], new FeedException("invalidUrl"), null, null, new ErrorResponse("Fetch404", 502)],
+ [['feed_url' => "http://example.com/", 'category_id' => 1], new FeedException("maxRedirect"), null, null, new ErrorResponse("FetchOther", 502)],
+ [['feed_url' => "http://example.com/", 'category_id' => 1], new FeedException("maxSize"), null, null, new ErrorResponse("FetchOther", 502)],
+ [['feed_url' => "http://example.com/", 'category_id' => 1], new FeedException("timeout"), null, null, new ErrorResponse("FetchOther", 502)],
+ [['feed_url' => "http://example.com/", 'category_id' => 1], new FeedException("forbidden"), null, null, new ErrorResponse("Fetch403", 502)],
+ [['feed_url' => "http://example.com/", 'category_id' => 1], new FeedException("unauthorized"), null, null, new ErrorResponse("Fetch401", 502)],
+ [['feed_url' => "http://example.com/", 'category_id' => 1], new FeedException("transmissionError"), null, null, new ErrorResponse("FetchOther", 502)],
+ [['feed_url' => "http://example.com/", 'category_id' => 1], new FeedException("connectionFailed"), null, null, new ErrorResponse("FetchOther", 502)],
+ [['feed_url' => "http://example.com/", 'category_id' => 1], new FeedException("malformedXml"), null, null, new ErrorResponse("FetchOther", 502)],
+ [['feed_url' => "http://example.com/", 'category_id' => 1], new FeedException("xmlEntity"), null, null, new ErrorResponse("FetchOther", 502)],
+ [['feed_url' => "http://example.com/", 'category_id' => 1], new FeedException("subscriptionNotFound"), null, null, new ErrorResponse("Fetch404", 502)],
+ [['feed_url' => "http://example.com/", 'category_id' => 1], new FeedException("unsupportedFeedFormat"), null, null, new ErrorResponse("FetchFormat", 502)],
];
}
}
From 7893b5f59d6dedf23df931d38a6f6212c6841e3a Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Sat, 23 Jan 2021 18:01:23 -0500
Subject: [PATCH 126/366] More feed adding tests
---
lib/REST/Miniflux/V1.php | 14 ++++----
tests/cases/REST/Miniflux/TestV1.php | 49 +++++++++++++++-------------
2 files changed, 32 insertions(+), 31 deletions(-)
diff --git a/lib/REST/Miniflux/V1.php b/lib/REST/Miniflux/V1.php
index 303dac16..6bbbeea8 100644
--- a/lib/REST/Miniflux/V1.php
+++ b/lib/REST/Miniflux/V1.php
@@ -232,7 +232,7 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
}
try {
return $this->$func(...$args);
- // @codeCoverageIgnoreStart
+ // @codeCoverageIgnoreStart
} catch (Exception $e) {
// if there was a REST exception return 400
return new EmptyResponse(400);
@@ -703,18 +703,16 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
}
protected function createFeed(array $data): ResponseInterface {
- $props = [
- 'keep_rule' => $data['keeplist_rules'],
- 'block_rule' => $data['blocklist_rules'],
- 'folder' => $data['category_id'] - 1,
- 'scrape' => (bool) $data['crawler'],
- ];
try {
Arsse::$db->feedAdd($data['feed_url'], (string) $data['username'], (string) $data['password'], false, (bool) $data['crawler']);
$tr = Arsse::$db->begin();
$id = Arsse::$db->subscriptionAdd(Arsse::$user->id, $data['feed_url'], (string) $data['username'], (string) $data['password'], false, (bool) $data['crawler']);
- Arsse::$db->subscriptionPropertiesSet(Arsse::$user->id, $id, $props);
+ Arsse::$db->subscriptionPropertiesSet(Arsse::$user->id, $id, ['folder' => $data['category_id'] - 1, 'scrape' => (bool) $data['crawler']]);
$tr->commit();
+ if (strlen($data['keeplist_rules'] ?? "") || strlen($data['blocklist_rules'] ?? "")) {
+ // we do rules separately so as not to tie up the database
+ Arsse::$db->subscriptionPropertiesSet(Arsse::$user->id, $id, ['keep_rule' => $data['keeplist_rules'], 'block_rule' => $data['blocklist_rules']]);
+ }
} catch (FeedException $e) {
$msg = [
10502 => "Fetch404",
diff --git a/tests/cases/REST/Miniflux/TestV1.php b/tests/cases/REST/Miniflux/TestV1.php
index a18a3025..3bcfbfc9 100644
--- a/tests/cases/REST/Miniflux/TestV1.php
+++ b/tests/cases/REST/Miniflux/TestV1.php
@@ -613,11 +613,13 @@ class TestV1 extends \JKingWeb\Arsse\Test\AbstractTest {
}
if ($in3) {
$props = [
- 'keep_rule' => $in['keeplist_rules'],
- 'block_rule' => $in['blocklist_rules'],
'folder' => $in['category_id'] - 1,
'scrape' => $in['crawler'] ?? false,
];
+ $rules = (strlen($in['keeplist_rules'] ?? "") || strlen($in['blocklist_rules'] ?? "")) ? [
+ 'keep_rule' => $in['keeplist_rules'],
+ 'block_rule' => $in['blocklist_rules'],
+ ] : [];
\Phake::verify(Arsse::$db)->subscriptionPropertiesSet("john.doe@example.com", $out2, $props);
} else {
\Phake::verify(Arsse::$db, \Phake::times(0))->subscriptionPropertiesSet;
@@ -627,27 +629,28 @@ class TestV1 extends \JKingWeb\Arsse\Test\AbstractTest {
public function provideFeedCreations(): iterable {
self::clearData();
return [
- [['category_id' => 1], null, null, null, new ErrorResponse(["MissingInputValue", 'field' => "feed_url"], 422)],
- [['feed_url' => "http://example.com/"], null, null, null, new ErrorResponse(["MissingInputValue", 'field' => "category_id"], 422)],
- [['feed_url' => "http://example.com/", 'category_id' => "1"], null, null, null, new ErrorResponse(["InvalidInputType", 'field' => "category_id", 'expected' => "integer", 'actual' => "string"], 422)],
- [['feed_url' => "Not a URL", 'category_id' => 1], null, null, null, new ErrorResponse(["InvalidInputValue", 'field' => "feed_url"], 422)],
- [['feed_url' => "http://example.com/", 'category_id' => 0], null, null, null, new ErrorResponse(["InvalidInputValue", 'field' => "category_id"], 422)],
- [['feed_url' => "http://example.com/", 'category_id' => 1, 'keeplist_rules' => "["], null, null, null, new ErrorResponse(["InvalidInputValue", 'field' => "keeplist_rules"], 422)],
- [['feed_url' => "http://example.com/", 'category_id' => 1, 'blocklist_rules' => "["], null, null, null, new ErrorResponse(["InvalidInputValue", 'field' => "blocklist_rules"], 422)],
- [['feed_url' => "http://example.com/", 'category_id' => 1], new FeedException("internalError"), null, null, new ErrorResponse("FetchOther", 502)],
- [['feed_url' => "http://example.com/", 'category_id' => 1], new FeedException("invalidCertificate"), null, null, new ErrorResponse("FetchOther", 502)],
- [['feed_url' => "http://example.com/", 'category_id' => 1], new FeedException("invalidUrl"), null, null, new ErrorResponse("Fetch404", 502)],
- [['feed_url' => "http://example.com/", 'category_id' => 1], new FeedException("maxRedirect"), null, null, new ErrorResponse("FetchOther", 502)],
- [['feed_url' => "http://example.com/", 'category_id' => 1], new FeedException("maxSize"), null, null, new ErrorResponse("FetchOther", 502)],
- [['feed_url' => "http://example.com/", 'category_id' => 1], new FeedException("timeout"), null, null, new ErrorResponse("FetchOther", 502)],
- [['feed_url' => "http://example.com/", 'category_id' => 1], new FeedException("forbidden"), null, null, new ErrorResponse("Fetch403", 502)],
- [['feed_url' => "http://example.com/", 'category_id' => 1], new FeedException("unauthorized"), null, null, new ErrorResponse("Fetch401", 502)],
- [['feed_url' => "http://example.com/", 'category_id' => 1], new FeedException("transmissionError"), null, null, new ErrorResponse("FetchOther", 502)],
- [['feed_url' => "http://example.com/", 'category_id' => 1], new FeedException("connectionFailed"), null, null, new ErrorResponse("FetchOther", 502)],
- [['feed_url' => "http://example.com/", 'category_id' => 1], new FeedException("malformedXml"), null, null, new ErrorResponse("FetchOther", 502)],
- [['feed_url' => "http://example.com/", 'category_id' => 1], new FeedException("xmlEntity"), null, null, new ErrorResponse("FetchOther", 502)],
- [['feed_url' => "http://example.com/", 'category_id' => 1], new FeedException("subscriptionNotFound"), null, null, new ErrorResponse("Fetch404", 502)],
- [['feed_url' => "http://example.com/", 'category_id' => 1], new FeedException("unsupportedFeedFormat"), null, null, new ErrorResponse("FetchFormat", 502)],
+ [['category_id' => 1], null, null, null, new ErrorResponse(["MissingInputValue", 'field' => "feed_url"], 422)],
+ [['feed_url' => "http://example.com/"], null, null, null, new ErrorResponse(["MissingInputValue", 'field' => "category_id"], 422)],
+ [['feed_url' => "http://example.com/", 'category_id' => "1"], null, null, null, new ErrorResponse(["InvalidInputType", 'field' => "category_id", 'expected' => "integer", 'actual' => "string"], 422)],
+ [['feed_url' => "Not a URL", 'category_id' => 1], null, null, null, new ErrorResponse(["InvalidInputValue", 'field' => "feed_url"], 422)],
+ [['feed_url' => "http://example.com/", 'category_id' => 0], null, null, null, new ErrorResponse(["InvalidInputValue", 'field' => "category_id"], 422)],
+ [['feed_url' => "http://example.com/", 'category_id' => 1, 'keeplist_rules' => "["], null, null, null, new ErrorResponse(["InvalidInputValue", 'field' => "keeplist_rules"], 422)],
+ [['feed_url' => "http://example.com/", 'category_id' => 1, 'blocklist_rules' => "["], null, null, null, new ErrorResponse(["InvalidInputValue", 'field' => "blocklist_rules"], 422)],
+ [['feed_url' => "http://example.com/", 'category_id' => 1], new FeedException("internalError"), null, null, new ErrorResponse("FetchOther", 502)],
+ [['feed_url' => "http://example.com/", 'category_id' => 1], new FeedException("invalidCertificate"), null, null, new ErrorResponse("FetchOther", 502)],
+ [['feed_url' => "http://example.com/", 'category_id' => 1], new FeedException("invalidUrl"), null, null, new ErrorResponse("Fetch404", 502)],
+ [['feed_url' => "http://example.com/", 'category_id' => 1], new FeedException("maxRedirect"), null, null, new ErrorResponse("FetchOther", 502)],
+ [['feed_url' => "http://example.com/", 'category_id' => 1], new FeedException("maxSize"), null, null, new ErrorResponse("FetchOther", 502)],
+ [['feed_url' => "http://example.com/", 'category_id' => 1], new FeedException("timeout"), null, null, new ErrorResponse("FetchOther", 502)],
+ [['feed_url' => "http://example.com/", 'category_id' => 1], new FeedException("forbidden"), null, null, new ErrorResponse("Fetch403", 502)],
+ [['feed_url' => "http://example.com/", 'category_id' => 1], new FeedException("unauthorized"), null, null, new ErrorResponse("Fetch401", 502)],
+ [['feed_url' => "http://example.com/", 'category_id' => 1], new FeedException("transmissionError"), null, null, new ErrorResponse("FetchOther", 502)],
+ [['feed_url' => "http://example.com/", 'category_id' => 1], new FeedException("connectionFailed"), null, null, new ErrorResponse("FetchOther", 502)],
+ [['feed_url' => "http://example.com/", 'category_id' => 1], new FeedException("malformedXml"), null, null, new ErrorResponse("FetchOther", 502)],
+ [['feed_url' => "http://example.com/", 'category_id' => 1], new FeedException("xmlEntity"), null, null, new ErrorResponse("FetchOther", 502)],
+ [['feed_url' => "http://example.com/", 'category_id' => 1], new FeedException("subscriptionNotFound"), null, null, new ErrorResponse("Fetch404", 502)],
+ [['feed_url' => "http://example.com/", 'category_id' => 1], new FeedException("unsupportedFeedFormat"), null, null, new ErrorResponse("FetchFormat", 502)],
+ [['feed_url' => "http://example.com/", 'category_id' => 1], 2112, new ExceptionInput("constraintViolation"), null, new ErrorResponse("DuplicateFeed", 409)],
];
}
}
From a34edcb0d1d4d3524c20b43d1a7e9c8d1deb0430 Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Sun, 24 Jan 2021 11:25:38 -0500
Subject: [PATCH 127/366] Last tests for feed creation
---
tests/cases/REST/Miniflux/TestV1.php | 73 +++++++++++++++++-----------
1 file changed, 45 insertions(+), 28 deletions(-)
diff --git a/tests/cases/REST/Miniflux/TestV1.php b/tests/cases/REST/Miniflux/TestV1.php
index 3bcfbfc9..98184c20 100644
--- a/tests/cases/REST/Miniflux/TestV1.php
+++ b/tests/cases/REST/Miniflux/TestV1.php
@@ -581,7 +581,7 @@ class TestV1 extends \JKingWeb\Arsse\Test\AbstractTest {
}
/** @dataProvider provideFeedCreations */
- public function testCreateAFeed(array $in, $out1, $out2, $out3, ResponseInterface $exp): void {
+ public function testCreateAFeed(array $in, $out1, $out2, $out3, $out4, ResponseInterface $exp): void {
if ($out1 instanceof \Exception) {
\Phake::when(Arsse::$db)->feedAdd->thenThrow($out1);
} else {
@@ -594,21 +594,26 @@ class TestV1 extends \JKingWeb\Arsse\Test\AbstractTest {
}
if ($out3 instanceof \Exception) {
\Phake::when(Arsse::$db)->subscriptionPropertiesSet->thenThrow($out3);
+ } elseif ($out4 instanceof \Exception) {
+ \Phake::when(Arsse::$db)->subscriptionPropertiesSet->thenReturn($out3)->thenThrow($out4);
} else {
- \Phake::when(Arsse::$db)->subscriptionPropertiesSet->thenReturn($out3);
+ \Phake::when(Arsse::$db)->subscriptionPropertiesSet->thenReturn($out3)->thenReturn($out4);
}
$this->assertMessage($exp, $this->req("POST", "/feeds", $in));
$in1 = $out1 !== null;
$in2 = $out2 !== null;
$in3 = $out3 !== null;
+ $in4 = $out4 !== null;
if ($in1) {
\Phake::verify(Arsse::$db)->feedAdd($in['feed_url'], $in['username'] ?? "", $in['password'] ?? "", false, $in['crawler'] ?? false);
} else {
\Phake::verify(Arsse::$db, \Phake::times(0))->feedAdd;
}
if ($in2) {
+ \Phake::verify(Arsse::$db)->begin();
\Phake::verify(Arsse::$db)->subscriptionAdd("john.doe@example.com", $in['feed_url'], $in['username'] ?? "", $in['password'] ?? "", false, $in['crawler'] ?? false);
} else {
+ \Phake::verify(Arsse::$db, \Phake::times(0))->begin;
\Phake::verify(Arsse::$db, \Phake::times(0))->subscriptionAdd;
}
if ($in3) {
@@ -616,41 +621,53 @@ class TestV1 extends \JKingWeb\Arsse\Test\AbstractTest {
'folder' => $in['category_id'] - 1,
'scrape' => $in['crawler'] ?? false,
];
- $rules = (strlen($in['keeplist_rules'] ?? "") || strlen($in['blocklist_rules'] ?? "")) ? [
- 'keep_rule' => $in['keeplist_rules'],
- 'block_rule' => $in['blocklist_rules'],
- ] : [];
\Phake::verify(Arsse::$db)->subscriptionPropertiesSet("john.doe@example.com", $out2, $props);
+ if (!$out3 instanceof \Exception) {
+ \Phake::verify($this->transaction)->commit();
+ }
} else {
\Phake::verify(Arsse::$db, \Phake::times(0))->subscriptionPropertiesSet;
}
+ if ($in4) {
+ $rules = [
+ 'keep_rule' => $in['keeplist_rules'] ?? null,
+ 'block_rule' => $in['blocklist_rules'] ?? null,
+ ];
+ \Phake::verify(Arsse::$db)->subscriptionPropertiesSet("john.doe@example.com", $out2, $rules);
+ } else {
+ \Phake::verify(Arsse::$db, \Phake::atMost(1))->subscriptionPropertiesSet;
+ }
}
public function provideFeedCreations(): iterable {
self::clearData();
return [
- [['category_id' => 1], null, null, null, new ErrorResponse(["MissingInputValue", 'field' => "feed_url"], 422)],
- [['feed_url' => "http://example.com/"], null, null, null, new ErrorResponse(["MissingInputValue", 'field' => "category_id"], 422)],
- [['feed_url' => "http://example.com/", 'category_id' => "1"], null, null, null, new ErrorResponse(["InvalidInputType", 'field' => "category_id", 'expected' => "integer", 'actual' => "string"], 422)],
- [['feed_url' => "Not a URL", 'category_id' => 1], null, null, null, new ErrorResponse(["InvalidInputValue", 'field' => "feed_url"], 422)],
- [['feed_url' => "http://example.com/", 'category_id' => 0], null, null, null, new ErrorResponse(["InvalidInputValue", 'field' => "category_id"], 422)],
- [['feed_url' => "http://example.com/", 'category_id' => 1, 'keeplist_rules' => "["], null, null, null, new ErrorResponse(["InvalidInputValue", 'field' => "keeplist_rules"], 422)],
- [['feed_url' => "http://example.com/", 'category_id' => 1, 'blocklist_rules' => "["], null, null, null, new ErrorResponse(["InvalidInputValue", 'field' => "blocklist_rules"], 422)],
- [['feed_url' => "http://example.com/", 'category_id' => 1], new FeedException("internalError"), null, null, new ErrorResponse("FetchOther", 502)],
- [['feed_url' => "http://example.com/", 'category_id' => 1], new FeedException("invalidCertificate"), null, null, new ErrorResponse("FetchOther", 502)],
- [['feed_url' => "http://example.com/", 'category_id' => 1], new FeedException("invalidUrl"), null, null, new ErrorResponse("Fetch404", 502)],
- [['feed_url' => "http://example.com/", 'category_id' => 1], new FeedException("maxRedirect"), null, null, new ErrorResponse("FetchOther", 502)],
- [['feed_url' => "http://example.com/", 'category_id' => 1], new FeedException("maxSize"), null, null, new ErrorResponse("FetchOther", 502)],
- [['feed_url' => "http://example.com/", 'category_id' => 1], new FeedException("timeout"), null, null, new ErrorResponse("FetchOther", 502)],
- [['feed_url' => "http://example.com/", 'category_id' => 1], new FeedException("forbidden"), null, null, new ErrorResponse("Fetch403", 502)],
- [['feed_url' => "http://example.com/", 'category_id' => 1], new FeedException("unauthorized"), null, null, new ErrorResponse("Fetch401", 502)],
- [['feed_url' => "http://example.com/", 'category_id' => 1], new FeedException("transmissionError"), null, null, new ErrorResponse("FetchOther", 502)],
- [['feed_url' => "http://example.com/", 'category_id' => 1], new FeedException("connectionFailed"), null, null, new ErrorResponse("FetchOther", 502)],
- [['feed_url' => "http://example.com/", 'category_id' => 1], new FeedException("malformedXml"), null, null, new ErrorResponse("FetchOther", 502)],
- [['feed_url' => "http://example.com/", 'category_id' => 1], new FeedException("xmlEntity"), null, null, new ErrorResponse("FetchOther", 502)],
- [['feed_url' => "http://example.com/", 'category_id' => 1], new FeedException("subscriptionNotFound"), null, null, new ErrorResponse("Fetch404", 502)],
- [['feed_url' => "http://example.com/", 'category_id' => 1], new FeedException("unsupportedFeedFormat"), null, null, new ErrorResponse("FetchFormat", 502)],
- [['feed_url' => "http://example.com/", 'category_id' => 1], 2112, new ExceptionInput("constraintViolation"), null, new ErrorResponse("DuplicateFeed", 409)],
+ [['category_id' => 1], null, null, null, null, new ErrorResponse(["MissingInputValue", 'field' => "feed_url"], 422)],
+ [['feed_url' => "http://example.com/"], null, null, null, null, new ErrorResponse(["MissingInputValue", 'field' => "category_id"], 422)],
+ [['feed_url' => "http://example.com/", 'category_id' => "1"], null, null, null, null, new ErrorResponse(["InvalidInputType", 'field' => "category_id", 'expected' => "integer", 'actual' => "string"], 422)],
+ [['feed_url' => "Not a URL", 'category_id' => 1], null, null, null, null, new ErrorResponse(["InvalidInputValue", 'field' => "feed_url"], 422)],
+ [['feed_url' => "http://example.com/", 'category_id' => 0], null, null, null, null, new ErrorResponse(["InvalidInputValue", 'field' => "category_id"], 422)],
+ [['feed_url' => "http://example.com/", 'category_id' => 1, 'keeplist_rules' => "["], null, null, null, null, new ErrorResponse(["InvalidInputValue", 'field' => "keeplist_rules"], 422)],
+ [['feed_url' => "http://example.com/", 'category_id' => 1, 'blocklist_rules' => "["], null, null, null, null, new ErrorResponse(["InvalidInputValue", 'field' => "blocklist_rules"], 422)],
+ [['feed_url' => "http://example.com/", 'category_id' => 1], new FeedException("internalError"), null, null, null, new ErrorResponse("FetchOther", 502)],
+ [['feed_url' => "http://example.com/", 'category_id' => 1], new FeedException("invalidCertificate"), null, null, null, new ErrorResponse("FetchOther", 502)],
+ [['feed_url' => "http://example.com/", 'category_id' => 1], new FeedException("invalidUrl"), null, null, null, new ErrorResponse("Fetch404", 502)],
+ [['feed_url' => "http://example.com/", 'category_id' => 1], new FeedException("maxRedirect"), null, null, null, new ErrorResponse("FetchOther", 502)],
+ [['feed_url' => "http://example.com/", 'category_id' => 1], new FeedException("maxSize"), null, null, null, new ErrorResponse("FetchOther", 502)],
+ [['feed_url' => "http://example.com/", 'category_id' => 1], new FeedException("timeout"), null, null, null, new ErrorResponse("FetchOther", 502)],
+ [['feed_url' => "http://example.com/", 'category_id' => 1], new FeedException("forbidden"), null, null, null, new ErrorResponse("Fetch403", 502)],
+ [['feed_url' => "http://example.com/", 'category_id' => 1], new FeedException("unauthorized"), null, null, null, new ErrorResponse("Fetch401", 502)],
+ [['feed_url' => "http://example.com/", 'category_id' => 1], new FeedException("transmissionError"), null, null, null, new ErrorResponse("FetchOther", 502)],
+ [['feed_url' => "http://example.com/", 'category_id' => 1], new FeedException("connectionFailed"), null, null, null, new ErrorResponse("FetchOther", 502)],
+ [['feed_url' => "http://example.com/", 'category_id' => 1], new FeedException("malformedXml"), null, null, null, new ErrorResponse("FetchOther", 502)],
+ [['feed_url' => "http://example.com/", 'category_id' => 1], new FeedException("xmlEntity"), null, null, null, new ErrorResponse("FetchOther", 502)],
+ [['feed_url' => "http://example.com/", 'category_id' => 1], new FeedException("subscriptionNotFound"), null, null, null, new ErrorResponse("Fetch404", 502)],
+ [['feed_url' => "http://example.com/", 'category_id' => 1], new FeedException("unsupportedFeedFormat"), null, null, null, new ErrorResponse("FetchFormat", 502)],
+ [['feed_url' => "http://example.com/", 'category_id' => 1], 2112, new ExceptionInput("constraintViolation"), null, null, new ErrorResponse("DuplicateFeed", 409)],
+ [['feed_url' => "http://example.com/", 'category_id' => 1], 2112, 44, new ExceptionInput("idMissing"), null, new ErrorResponse("MissingCategory", 422)],
+ [['feed_url' => "http://example.com/", 'category_id' => 1], 2112, 44, true, null, new Response(['feed_id' => 44], 201)],
+ [['feed_url' => "http://example.com/", 'category_id' => 1, 'keeplist_rules' => "^A"], 2112, 44, true, true, new Response(['feed_id' => 44], 201)],
+ [['feed_url' => "http://example.com/", 'category_id' => 1, 'blocklist_rules' => "A"], 2112, 44, true, true, new Response(['feed_id' => 44], 201)],
];
}
}
From cca4b205e4347678ee9af6621a00f9ccd667cca6 Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Sun, 24 Jan 2021 11:33:00 -0500
Subject: [PATCH 128/366] Correct error output of getCategoryFeeds
---
lib/REST/Miniflux/V1.php | 2 +-
tests/cases/REST/Miniflux/TestV1.php | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/lib/REST/Miniflux/V1.php b/lib/REST/Miniflux/V1.php
index 6bbbeea8..2ad0f66f 100644
--- a/lib/REST/Miniflux/V1.php
+++ b/lib/REST/Miniflux/V1.php
@@ -689,7 +689,7 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
$subs = Arsse::$db->subscriptionList(Arsse::$user->id, $folder, $recursive)->getAll();
} catch (ExceptionInput $e) {
// the folder does not exist
- return new EmptyResponse(404);
+ return new ErrorResponse("404", 404);
}
// compile the list of folders; the feed list includes folder names
// NOTE: We compile the full list of folders in case someone has manually selected a non-top folder
diff --git a/tests/cases/REST/Miniflux/TestV1.php b/tests/cases/REST/Miniflux/TestV1.php
index 98184c20..dae4e415 100644
--- a/tests/cases/REST/Miniflux/TestV1.php
+++ b/tests/cases/REST/Miniflux/TestV1.php
@@ -575,7 +575,7 @@ class TestV1 extends \JKingWeb\Arsse\Test\AbstractTest {
public function testListFeedsOfAMissingCategory(): void {
\Phake::when(Arsse::$db)->subscriptionList->thenThrow(new ExceptionInput("idMissing"));
- $exp = new EmptyResponse(404);
+ $exp = new ErrorResponse("404", 404);
$this->assertMessage($exp, $this->req("GET", "/categories/2112/feeds"));
\Phake::verify(Arsse::$db)->subscriptionList(Arsse::$user->id, 2111, true);
}
From a646ad77b7b51cb0e72cb4f7b47205e49c207842 Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Sun, 24 Jan 2021 11:45:08 -0500
Subject: [PATCH 129/366] Use a read transaction when computing filter rules
---
lib/Database.php | 6 +++++-
1 file changed, 5 insertions(+), 1 deletion(-)
diff --git a/lib/Database.php b/lib/Database.php
index de829dba..bdb36b64 100644
--- a/lib/Database.php
+++ b/lib/Database.php
@@ -963,7 +963,7 @@ class Database {
}
$out = (bool) $this->db->prepare("UPDATE arsse_subscriptions set $setClause, modified = CURRENT_TIMESTAMP where owner = ? and id = ?", $setTypes, "str", "int")->run($setValues, $user, $id)->changes();
$tr->commit();
- // if filter rules were changed, apply them
+ // if filter rules were changed, apply them; this is done outside the transaction because it may take some time
if (array_key_exists("keep_rule", $data) || array_key_exists("block_rule", $data)) {
$this->subscriptionRulesApply($user, $id);
}
@@ -1030,6 +1030,8 @@ class Database {
* @param integer $id The identifier of the subscription whose rules are to be evaluated
*/
protected function subscriptionRulesApply(string $user, int $id): void {
+ // start a transaction for read isolation
+ $tr = $this->begin();
$sub = $this->db->prepare("SELECT feed, coalesce(keep_rule, '') as keep, coalesce(block_rule, '') as block from arsse_subscriptions where owner = ? and id = ?", "str", "int")->run($user, $id)->getRow();
try {
$keep = Rule::prep($sub['keep']);
@@ -1053,6 +1055,8 @@ class Database {
$hide[] = $r['id'];
}
}
+ // roll back the read transation
+ $tr->rollback();
// apply any marks
if ($hide) {
$this->articleMark($user, ['hidden' => true], (new Context)->articles($hide), false);
From 5a8a044a92422f61e3c55b1f7c28831e79326bdf Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Sun, 24 Jan 2021 13:54:54 -0500
Subject: [PATCH 130/366] Implement single-feed querying
---
lib/REST/Miniflux/V1.php | 12 ++++++++++++
tests/cases/REST/Miniflux/TestV1.php | 19 ++++++++++++++++---
2 files changed, 28 insertions(+), 3 deletions(-)
diff --git a/lib/REST/Miniflux/V1.php b/lib/REST/Miniflux/V1.php
index 2ad0f66f..2438494e 100644
--- a/lib/REST/Miniflux/V1.php
+++ b/lib/REST/Miniflux/V1.php
@@ -702,6 +702,18 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
return new Response($out);
}
+ protected function getFeed(array $path): ResponseInterface {
+ $tr = Arsse::$db->begin();
+ try {
+ $sub = Arsse::$db->subscriptionPropertiesGet(Arsse::$user->id, (int) $path[1]);
+ } catch (ExceptionInput $e) {
+ return new ErrorResponse("404", 404);
+ }
+ // compile the list of folders; the feed list includes folder names
+ $folders = $this->mapFolders();
+ return new Response($this->transformFeed($sub, $folders));
+ }
+
protected function createFeed(array $data): ResponseInterface {
try {
Arsse::$db->feedAdd($data['feed_url'], (string) $data['username'], (string) $data['password'], false, (bool) $data['crawler']);
diff --git a/tests/cases/REST/Miniflux/TestV1.php b/tests/cases/REST/Miniflux/TestV1.php
index dae4e415..9b5877d7 100644
--- a/tests/cases/REST/Miniflux/TestV1.php
+++ b/tests/cases/REST/Miniflux/TestV1.php
@@ -564,9 +564,7 @@ class TestV1 extends \JKingWeb\Arsse\Test\AbstractTest {
}
public function testListFeedsOfTheRootCategory(): void {
- \Phake::when(Arsse::$db)->folderList->thenReturn(new Result($this->v([
- ['id' => 5, 'name' => "Cat Ook"],
- ])));
+ \Phake::when(Arsse::$db)->folderList->thenReturn(new Result($this->v([['id' => 5, 'name' => "Cat Ook"],])));
\Phake::when(Arsse::$db)->subscriptionList->thenReturn(new Result($this->v($this->feeds)));
$exp = new Response($this->feedsOut);
$this->assertMessage($exp, $this->req("GET", "/categories/1/feeds"));
@@ -580,6 +578,21 @@ class TestV1 extends \JKingWeb\Arsse\Test\AbstractTest {
\Phake::verify(Arsse::$db)->subscriptionList(Arsse::$user->id, 2111, true);
}
+ public function testGetAFeed(): void {
+ \Phake::when(Arsse::$db)->subscriptionPropertiesGet->thenReturn($this->v($this->feeds[0]))->thenReturn($this->v($this->feeds[1]));
+ \Phake::when(Arsse::$db)->folderList->thenReturn(new Result($this->v([['id' => 5, 'name' => "Cat Ook"],])));
+ $this->assertMessage(new Response($this->feedsOut[0]), $this->req("GET", "/feeds/1"));
+ \Phake::verify(Arsse::$db)->subscriptionPropertiesGet(Arsse::$user->id, 1);
+ $this->assertMessage(new Response($this->feedsOut[1]), $this->req("GET", "/feeds/55"));
+ \Phake::verify(Arsse::$db)->subscriptionPropertiesGet(Arsse::$user->id, 55);
+ }
+
+ public function testGetAMissingFeed(): void {
+ \Phake::when(Arsse::$db)->subscriptionPropertiesGet->thenThrow(new ExceptionInput("subjectMissing"));
+ $this->assertMessage(new ErrorResponse("404", 404), $this->req("GET", "/feeds/1"));
+ \Phake::verify(Arsse::$db)->subscriptionPropertiesGet(Arsse::$user->id, 1);
+ }
+
/** @dataProvider provideFeedCreations */
public function testCreateAFeed(array $in, $out1, $out2, $out3, $out4, ResponseInterface $exp): void {
if ($out1 instanceof \Exception) {
From 8eebb75b1809cc9bc5f444ddda264318a5065523 Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Sun, 24 Jan 2021 20:28:00 -0500
Subject: [PATCH 131/366] Implement feed editing
---
.../030_Supported_Protocols/005_Miniflux.md | 2 +-
lib/REST/Miniflux/V1.php | 54 +++++++++++++++++++
locale/en.php | 3 +-
tests/cases/REST/Miniflux/TestV1.php | 29 ++++++++++
4 files changed, 86 insertions(+), 2 deletions(-)
diff --git a/docs/en/030_Supported_Protocols/005_Miniflux.md b/docs/en/030_Supported_Protocols/005_Miniflux.md
index c3a19211..c098aa1e 100644
--- a/docs/en/030_Supported_Protocols/005_Miniflux.md
+++ b/docs/en/030_Supported_Protocols/005_Miniflux.md
@@ -32,7 +32,7 @@ Miniflux version 2.0.27 is emulated, though not all features are implemented
- Various error codes and messages differ due to significant implementation differences
- `PUT` requests which return a body respond with `200 OK` rather than `201 Created`
- The "All" category is treated specially (see below for details)
-- Category names consisting only of whitespace are rejected along with the empty string
+- Feed and category titles consisting only of whitespace are rejected along with the empty string
- Filtering rules may not function identically (see below for details)
- The `checked_at` field of feeds indicates when the feed was last updated rather than when it was last checked
- Creating a feed with the `scrape` property set to `true` might not return scraped content for the initial synchronization
diff --git a/lib/REST/Miniflux/V1.php b/lib/REST/Miniflux/V1.php
index 2438494e..e987a25e 100644
--- a/lib/REST/Miniflux/V1.php
+++ b/lib/REST/Miniflux/V1.php
@@ -66,6 +66,34 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
'entry_swipe' => ["swipe", true],
'stylesheet' => ["stylesheet", ""],
];
+ /** A map between Miniflux's input properties and our input properties when modifiying feeds
+ *
+ * Miniflux also allows changing the following properties:
+ *
+ * - feed_url
+ * - username
+ * - password
+ * - user_agent
+ * - scraper_rules
+ * - rewrite_rules
+ * - disabled
+ * - ignore_http_cache
+ * - fetch_via_proxy
+ *
+ * These either do not apply because we have no cache or proxy,
+ * or cannot be changed because feeds are deduplicated and changing
+ * how they are fetched is not practical with our implementation.
+ * The properties are still checked for type and syntactic validity
+ * where practical, on the assumption Miniflux would also reject
+ * invalid values.
+ */
+ protected const FEED_META_MAP = [
+ 'title' => "title",
+ 'category_id' => "folder",
+ 'crawler' => "scrape",
+ 'keeplist_rules' => "keep_rule",
+ 'blocklist_rules' => "block_rule",
+ ];
protected const CALLS = [ // handler method Admin Path Body Query Required fields
'/categories' => [
'GET' => ["getCategories", false, false, false, false, []],
@@ -745,6 +773,32 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
return new Response(['feed_id' => $id], 201);
}
+ protected function updateFeed(array $path, array $data): ResponseInterface {
+ $in = [];
+ foreach (self::FEED_META_MAP as $from => $to) {
+ if (isset($data[$from])) {
+ $in[$to] = $data[$from];
+ }
+ }
+ if (isset($in['folder'])) {
+ $in['folder'] -= 1;
+ }
+ try {
+ Arsse::$db->subscriptionPropertiesSet(Arsse::$user->id, (int) $path[1], $in);
+ return $this->getFeed($path);
+ } catch (ExceptionInput $e) {
+ switch ($e->getCode()) {
+ case 10231:
+ case 10232:
+ return new ErrorResponse("InvalidTitle", 422);
+ case 10235:
+ return new ErrorResponse("MissingCategory", 422);
+ case 10239:
+ return new ErrorResponse("404", 404);
+ }
+ }
+ }
+
public static function tokenGenerate(string $user, string $label): string {
// Miniflux produces tokens in base64url alphabet
$t = str_replace(["+", "/"], ["-", "_"], base64_encode(random_bytes(self::TOKEN_LENGTH)));
diff --git a/locale/en.php b/locale/en.php
index 812a50c9..b44d7490 100644
--- a/locale/en.php
+++ b/locale/en.php
@@ -21,11 +21,12 @@ return [
'API.Miniflux.Error.FetchOther' => 'Unable to fetch this resource',
'API.Miniflux.Error.FetchFormat' => 'Unsupported feed format',
'API.Miniflux.Error.DuplicateCategory' => 'This category already exists.',
- 'API.Miniflux.Error.InvalidCategory' => 'Invalid category title "{title}"',
+ 'API.Miniflux.Error.InvalidCategory' => 'Invalid category title',
'API.Miniflux.Error.MissingCategory' => 'This category does not exist or does not belong to this user.',
'API.Miniflux.Error.InvalidElevation' => 'Only administrators can change permissions of standard users',
'API.Miniflux.Error.DuplicateUser' => 'The user name "{user}" already exists',
'API.Miniflux.Error.DuplicateFeed' => 'This feed already exists.',
+ 'API.Miniflux.Error.InvalidTitle' => 'Invalid feed title',
'API.TTRSS.Category.Uncategorized' => 'Uncategorized',
'API.TTRSS.Category.Special' => 'Special',
diff --git a/tests/cases/REST/Miniflux/TestV1.php b/tests/cases/REST/Miniflux/TestV1.php
index 9b5877d7..dbb0eff7 100644
--- a/tests/cases/REST/Miniflux/TestV1.php
+++ b/tests/cases/REST/Miniflux/TestV1.php
@@ -683,4 +683,33 @@ class TestV1 extends \JKingWeb\Arsse\Test\AbstractTest {
[['feed_url' => "http://example.com/", 'category_id' => 1, 'blocklist_rules' => "A"], 2112, 44, true, true, new Response(['feed_id' => 44], 201)],
];
}
+
+ /** @dataProvider provideFeedModifications */
+ public function testModifyAFeed(array $in, array $data, $out, ResponseInterface $exp): void {
+ $this->h = \Phake::partialMock(V1::class);
+ \Phake::when($this->h)->getFeed->thenReturn(new Response($this->feedsOut[0]));
+ if ($out instanceof \Exception) {
+ \Phake::when(Arsse::$db)->subscriptionPropertiesSet->thenThrow($out);
+ } else {
+ \Phake::when(Arsse::$db)->subscriptionPropertiesSet->thenReturn($out);
+ }
+ $this->assertMessage($exp, $this->req("PUT", "/feeds/2112"));
+ }
+
+ public function provideFeedModifications(): iterable {
+ self::clearData();
+ $success = new Response($this->feedsOut[0]);
+ return [
+ [[], [], true, $success],
+ [[], [], new ExceptionInput("subjectMissing"), new ErrorResponse("404", 404)],
+ [['title' => ""], ['title' => ""], new ExceptionInput("missing"), new ErrorResponse("InvalidTitle", 422)],
+ [['title' => " "], ['title' => " "], new ExceptionInput("whitespace"), new ErrorResponse("InvalidTitle", 422)],
+ [['title' => " "], ['title' => " "], new ExceptionInput("whitespace"), new ErrorResponse("InvalidTitle", 422)],
+ [['category_id' => 47], ['folder' => 46], new ExceptionInput("idMissing"), new ErrorResponse("MissingCategory", 422)],
+ [['crawler' => false], ['scrape' => false], true, $success],
+ [['keeplist_rules' => ""], ['keep_rule' => ""], true, $success],
+ [['blocklist_rules' => "ook"], ['block_rule' => "ook"], true, $success],
+ [['title' => "Ook!", 'crawler' => true], ['title' => "Ook!", 'scrape' => true], true, $success]
+ ];
+ }
}
From 9197a8d08b74c57766916cf880d71f89818c64c5 Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Sun, 24 Jan 2021 21:12:32 -0500
Subject: [PATCH 132/366] Implement feed deletion
---
lib/REST/Miniflux/V1.php | 11 ++++++++++-
tests/cases/REST/Miniflux/TestV1.php | 12 ++++++++++++
2 files changed, 22 insertions(+), 1 deletion(-)
diff --git a/lib/REST/Miniflux/V1.php b/lib/REST/Miniflux/V1.php
index e987a25e..705a77fa 100644
--- a/lib/REST/Miniflux/V1.php
+++ b/lib/REST/Miniflux/V1.php
@@ -785,7 +785,6 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
}
try {
Arsse::$db->subscriptionPropertiesSet(Arsse::$user->id, (int) $path[1], $in);
- return $this->getFeed($path);
} catch (ExceptionInput $e) {
switch ($e->getCode()) {
case 10231:
@@ -797,6 +796,16 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
return new ErrorResponse("404", 404);
}
}
+ return $this->getFeed($path);
+ }
+
+ protected function deleteFeed(array $path): ResponseInterface {
+ try {
+ Arsse::$db->subscriptionRemove(Arsse::$user->id, (int) $path[1]);
+ return new EmptyResponse(204);
+ } catch (ExceptionInput $e) {
+ return new ErrorResponse("404", 404);
+ }
}
public static function tokenGenerate(string $user, string $label): string {
diff --git a/tests/cases/REST/Miniflux/TestV1.php b/tests/cases/REST/Miniflux/TestV1.php
index dbb0eff7..c866aa1e 100644
--- a/tests/cases/REST/Miniflux/TestV1.php
+++ b/tests/cases/REST/Miniflux/TestV1.php
@@ -712,4 +712,16 @@ class TestV1 extends \JKingWeb\Arsse\Test\AbstractTest {
[['title' => "Ook!", 'crawler' => true], ['title' => "Ook!", 'scrape' => true], true, $success]
];
}
+
+ public function testDeleteAFeed(): void {
+ \Phake::when(Arsse::$db)->subscriptionRemove->thenReturn(true);
+ $this->assertMessage(new EmptyResponse(204), $this->req("DELETE", "/feeds/2112"));
+ \Phake::verify(Arsse::$db)->subscriptionRemove(Arsse::$user->id, 2112);
+ }
+
+ public function testDeleteAMissingFeed(): void {
+ \Phake::when(Arsse::$db)->subscriptionRemove->thenThrow(new ExceptionInput("subjectMissing"));
+ $this->assertMessage(new ErrorResponse("404", 404), $this->req("DELETE", "/feeds/2112"));
+ \Phake::verify(Arsse::$db)->subscriptionRemove(Arsse::$user->id, 2112);
+ }
}
From bdf9c0e9d2e0f58df52d6fb084e4dee2bbcb3b8e Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Sun, 24 Jan 2021 21:53:45 -0500
Subject: [PATCH 133/366] Prototype feed icon querying
---
lib/REST/Miniflux/V1.php | 16 ++++++++++++++++
1 file changed, 16 insertions(+)
diff --git a/lib/REST/Miniflux/V1.php b/lib/REST/Miniflux/V1.php
index 705a77fa..c577ce0c 100644
--- a/lib/REST/Miniflux/V1.php
+++ b/lib/REST/Miniflux/V1.php
@@ -808,6 +808,22 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
}
}
+ protected function getFeedIcon(array $path): ResponseInterface {
+ try {
+ $icon = Arsse::$db->subscriptionIcon(Arsse::$user->id, (int) $path[1]);
+ } catch (ExceptionInput $e) {
+ return new ErrorResponse("404", 404);
+ }
+ if (!$icon['id']) {
+ return new ErrorResponse("404", 404);
+ }
+ return new Response([
+ 'id' => $icon['id'],
+ 'data' => ($icon['type'] ?? "application/octet-stream").";base64,".base64_encode($icon['data']),
+ 'mime_type' => $icon['type'],
+ ]);
+ }
+
public static function tokenGenerate(string $user, string $label): string {
// Miniflux produces tokens in base64url alphabet
$t = str_replace(["+", "/"], ["-", "_"], base64_encode(random_bytes(self::TOKEN_LENGTH)));
From 8e749bb73c3fe0665dfb41f5b85a425d611a61eb Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Mon, 25 Jan 2021 09:02:52 -0500
Subject: [PATCH 134/366] Report 404 on icons for absence of data
This is significant as upgraded databases have icon IDs, but no data
---
lib/REST/Miniflux/V1.php | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/lib/REST/Miniflux/V1.php b/lib/REST/Miniflux/V1.php
index c577ce0c..6832b902 100644
--- a/lib/REST/Miniflux/V1.php
+++ b/lib/REST/Miniflux/V1.php
@@ -814,7 +814,7 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
} catch (ExceptionInput $e) {
return new ErrorResponse("404", 404);
}
- if (!$icon['id']) {
+ if (!$icon['data']) {
return new ErrorResponse("404", 404);
}
return new Response([
From 1eea3b3a4c6cb9af8b6bda0c1fced62ccfd28452 Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Tue, 26 Jan 2021 10:32:27 -0500
Subject: [PATCH 135/366] Fix feed update test
---
tests/cases/REST/Miniflux/TestV1.php | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/tests/cases/REST/Miniflux/TestV1.php b/tests/cases/REST/Miniflux/TestV1.php
index c866aa1e..7154dd98 100644
--- a/tests/cases/REST/Miniflux/TestV1.php
+++ b/tests/cases/REST/Miniflux/TestV1.php
@@ -693,7 +693,8 @@ class TestV1 extends \JKingWeb\Arsse\Test\AbstractTest {
} else {
\Phake::when(Arsse::$db)->subscriptionPropertiesSet->thenReturn($out);
}
- $this->assertMessage($exp, $this->req("PUT", "/feeds/2112"));
+ $this->assertMessage($exp, $this->req("PUT", "/feeds/2112", $in));
+ \Phake::verify(Arsse::$db)->subscriptionPropertiesSet(Arsse::$user->id, 2112, $data);
}
public function provideFeedModifications(): iterable {
From cc2672fb0ab8d4a545388de59c12919adeb10a24 Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Tue, 26 Jan 2021 12:03:26 -0500
Subject: [PATCH 136/366] Improve icon fetching interface
---
lib/Database.php | 6 +++++-
tests/cases/Database/SeriesSubscription.php | 7 +++----
2 files changed, 8 insertions(+), 5 deletions(-)
diff --git a/lib/Database.php b/lib/Database.php
index bdb36b64..f8053ae9 100644
--- a/lib/Database.php
+++ b/lib/Database.php
@@ -991,12 +991,14 @@ class Database {
* - "url": The URL of the icon
* - "type": The Content-Type of the icon e.g. "image/png"
* - "data": The icon itself, as a binary sring; if $withData is false this will be null
+ *
+ * If the subscription has no icon null is returned instead of an array
*
* @param string|null $user The user who owns the subscription being queried; using null here is supported for TT-RSS and SHOULD NOT be used elsewhere as it leaks information
* @param int $subscription The numeric identifier of the subscription
* @param bool $includeData Whether to include the binary data of the icon itself in the result
*/
- public function subscriptionIcon(?string $user, int $id, bool $includeData = true): array {
+ public function subscriptionIcon(?string $user, int $id, bool $includeData = true): ?array {
$data = $includeData ? "i.data" : "null as data";
$q = new Query("SELECT i.id, i.url, i.type, $data from arsse_subscriptions as s join arsse_feeds as f on s.feed = f.id left join arsse_icons as i on f.icon = i.id");
$q->setWhere("s.id = ?", "int", $id);
@@ -1006,6 +1008,8 @@ class Database {
$out = $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues())->getRow();
if (!$out) {
throw new Db\ExceptionInput("subjectMissing", ["action" => __FUNCTION__, "field" => "subscription", 'id' => $id]);
+ } elseif (!$out['id']) {
+ return null;
}
return $out;
}
diff --git a/tests/cases/Database/SeriesSubscription.php b/tests/cases/Database/SeriesSubscription.php
index c009b600..e4a2b099 100644
--- a/tests/cases/Database/SeriesSubscription.php
+++ b/tests/cases/Database/SeriesSubscription.php
@@ -478,7 +478,7 @@ trait SeriesSubscription {
$exp = "http://example.com/favicon.ico";
$this->assertSame($exp, Arsse::$db->subscriptionIcon(null, 1)['url']);
$this->assertSame($exp, Arsse::$db->subscriptionIcon(null, 2)['url']);
- $this->assertSame(null, Arsse::$db->subscriptionIcon(null, 3)['url']);
+ $this->assertSame(null, Arsse::$db->subscriptionIcon(null, 3));
}
public function testRetrieveTheFaviconOfAMissingSubscription(): void {
@@ -490,16 +490,15 @@ trait SeriesSubscription {
$exp = "http://example.com/favicon.ico";
$user = "john.doe@example.com";
$this->assertSame($exp, Arsse::$db->subscriptionIcon($user, 1)['url']);
- $this->assertSame(null, Arsse::$db->subscriptionIcon($user, 3)['url']);
+ $this->assertSame(null, Arsse::$db->subscriptionIcon($user, 3));
$user = "jane.doe@example.com";
$this->assertSame($exp, Arsse::$db->subscriptionIcon($user, 2)['url']);
}
public function testRetrieveTheFaviconOfASubscriptionOfTheWrongUser(): void {
- $exp = "http://example.com/favicon.ico";
$user = "john.doe@example.com";
$this->assertException("subjectMissing", "Db", "ExceptionInput");
- $this->assertSame(null, Arsse::$db->subscriptionIcon($user, 2)['url']);
+ Arsse::$db->subscriptionIcon($user, 2);
}
public function testListTheTagsOfASubscription(): void {
From 76f1cc8e9156ff0f5325a2aba019e81a16d623f9 Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Tue, 26 Jan 2021 13:44:44 -0500
Subject: [PATCH 137/366] Adjust users of subscriptionIcon
---
lib/REST/Miniflux/V1.php | 4 ++--
lib/REST/TinyTinyRSS/Icon.php | 2 +-
2 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/lib/REST/Miniflux/V1.php b/lib/REST/Miniflux/V1.php
index 6832b902..1ca0e79c 100644
--- a/lib/REST/Miniflux/V1.php
+++ b/lib/REST/Miniflux/V1.php
@@ -814,12 +814,12 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
} catch (ExceptionInput $e) {
return new ErrorResponse("404", 404);
}
- if (!$icon['data']) {
+ if (!$icon || !$icon['data']) {
return new ErrorResponse("404", 404);
}
return new Response([
'id' => $icon['id'],
- 'data' => ($icon['type'] ?? "application/octet-stream").";base64,".base64_encode($icon['data']),
+ 'data' => ($icon['type'] ?: "application/octet-stream").";base64,".base64_encode($icon['data']),
'mime_type' => $icon['type'],
]);
}
diff --git a/lib/REST/TinyTinyRSS/Icon.php b/lib/REST/TinyTinyRSS/Icon.php
index b49ae4e4..9e7c7ec0 100644
--- a/lib/REST/TinyTinyRSS/Icon.php
+++ b/lib/REST/TinyTinyRSS/Icon.php
@@ -31,7 +31,7 @@ class Icon extends \JKingWeb\Arsse\REST\AbstractHandler {
return new Response(404);
}
try {
- $url = Arsse::$db->subscriptionIcon(Arsse::$user->id ?? null, (int) $match[1], false)['url'];
+ $url = Arsse::$db->subscriptionIcon(Arsse::$user->id ?? null, (int) $match[1], false)['url'] ?? null;
if (!$url) {
return new Response(404);
}
From cd5f13f4b9fcda677472b7c606b92ed584cca071 Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Wed, 27 Jan 2021 11:53:07 -0500
Subject: [PATCH 138/366] Tests for icon querying
---
lib/REST/Miniflux/V1.php | 2 +-
tests/cases/REST/Miniflux/TestV1.php | 23 +++++++++++++++++++++++
2 files changed, 24 insertions(+), 1 deletion(-)
diff --git a/lib/REST/Miniflux/V1.php b/lib/REST/Miniflux/V1.php
index 1ca0e79c..95198650 100644
--- a/lib/REST/Miniflux/V1.php
+++ b/lib/REST/Miniflux/V1.php
@@ -820,7 +820,7 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
return new Response([
'id' => $icon['id'],
'data' => ($icon['type'] ?: "application/octet-stream").";base64,".base64_encode($icon['data']),
- 'mime_type' => $icon['type'],
+ 'mime_type' => ($icon['type'] ?: "application/octet-stream"),
]);
}
diff --git a/tests/cases/REST/Miniflux/TestV1.php b/tests/cases/REST/Miniflux/TestV1.php
index 7154dd98..8e6d13d4 100644
--- a/tests/cases/REST/Miniflux/TestV1.php
+++ b/tests/cases/REST/Miniflux/TestV1.php
@@ -725,4 +725,27 @@ class TestV1 extends \JKingWeb\Arsse\Test\AbstractTest {
$this->assertMessage(new ErrorResponse("404", 404), $this->req("DELETE", "/feeds/2112"));
\Phake::verify(Arsse::$db)->subscriptionRemove(Arsse::$user->id, 2112);
}
+
+ /** @dataProvider provideIcons */
+ public function testGetTheIconOfASubscription($out, ResponseInterface $exp): void {
+ if ($out instanceof \Exception) {
+ \Phake::when(Arsse::$db)->subscriptionIcon->thenThrow($out);
+ } else {
+ \Phake::when(Arsse::$db)->subscriptionIcon->thenReturn($this->v($out));
+ }
+ $this->assertMessage($exp, $this->req("GET", "/feeds/2112/icon"));
+ \Phake::verify(Arsse::$db)->subscriptionIcon(Arsse::$user->id, 2112);
+ }
+
+ public function provideIcons(): iterable {
+ self::clearData();
+ return [
+ [['id' => 44, 'type' => "image/svg+xml", 'data' => " "], new Response(['id' => 44, 'data' => "image/svg+xml;base64,PHN2Zy8+", 'mime_type' => "image/svg+xml"])],
+ [['id' => 47, 'type' => "", 'data' => " "], new Response(['id' => 47, 'data' => "application/octet-stream;base64,PHN2Zy8+", 'mime_type' => "application/octet-stream"])],
+ [['id' => 47, 'type' => null, 'data' => " "], new Response(['id' => 47, 'data' => "application/octet-stream;base64,PHN2Zy8+", 'mime_type' => "application/octet-stream"])],
+ [['id' => 47, 'type' => null, 'data' => null], new ErrorResponse("404", 404)],
+ [null, new ErrorResponse("404", 404)],
+ [new ExceptionInput("subjectMissing"), new ErrorResponse("404", 404)],
+ ];
+ }
}
From ad094f5217d9f346ade0f1ece2871dfcb8c69a1a Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Wed, 27 Jan 2021 13:41:10 -0500
Subject: [PATCH 139/366] Don't return icons without types at all
---
lib/REST/Miniflux/V1.php | 6 +++---
tests/cases/REST/Miniflux/TestV1.php | 4 ++--
2 files changed, 5 insertions(+), 5 deletions(-)
diff --git a/lib/REST/Miniflux/V1.php b/lib/REST/Miniflux/V1.php
index 95198650..efd04695 100644
--- a/lib/REST/Miniflux/V1.php
+++ b/lib/REST/Miniflux/V1.php
@@ -814,13 +814,13 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
} catch (ExceptionInput $e) {
return new ErrorResponse("404", 404);
}
- if (!$icon || !$icon['data']) {
+ if (!$icon || !$icon['type'] || !$icon['data']) {
return new ErrorResponse("404", 404);
}
return new Response([
'id' => $icon['id'],
- 'data' => ($icon['type'] ?: "application/octet-stream").";base64,".base64_encode($icon['data']),
- 'mime_type' => ($icon['type'] ?: "application/octet-stream"),
+ 'data' => $icon['type'].";base64,".base64_encode($icon['data']),
+ 'mime_type' => $icon['type'],
]);
}
diff --git a/tests/cases/REST/Miniflux/TestV1.php b/tests/cases/REST/Miniflux/TestV1.php
index 8e6d13d4..d04f45a3 100644
--- a/tests/cases/REST/Miniflux/TestV1.php
+++ b/tests/cases/REST/Miniflux/TestV1.php
@@ -741,8 +741,8 @@ class TestV1 extends \JKingWeb\Arsse\Test\AbstractTest {
self::clearData();
return [
[['id' => 44, 'type' => "image/svg+xml", 'data' => " "], new Response(['id' => 44, 'data' => "image/svg+xml;base64,PHN2Zy8+", 'mime_type' => "image/svg+xml"])],
- [['id' => 47, 'type' => "", 'data' => " "], new Response(['id' => 47, 'data' => "application/octet-stream;base64,PHN2Zy8+", 'mime_type' => "application/octet-stream"])],
- [['id' => 47, 'type' => null, 'data' => " "], new Response(['id' => 47, 'data' => "application/octet-stream;base64,PHN2Zy8+", 'mime_type' => "application/octet-stream"])],
+ [['id' => 47, 'type' => "", 'data' => " "], new ErrorResponse("404", 404)],
+ [['id' => 47, 'type' => null, 'data' => " "], new ErrorResponse("404", 404)],
[['id' => 47, 'type' => null, 'data' => null], new ErrorResponse("404", 404)],
[null, new ErrorResponse("404", 404)],
[new ExceptionInput("subjectMissing"), new ErrorResponse("404", 404)],
From 3b2190ca105afb200f8b7a86c87101f718e7f0c7 Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Thu, 28 Jan 2021 14:55:18 -0500
Subject: [PATCH 140/366] Include folder names directly in subscription list
---
lib/Database.php | 8 ++-
lib/REST/Miniflux/V1.php | 75 ++++++++++-----------
tests/cases/Database/SeriesSubscription.php | 32 +++++----
tests/cases/REST/Miniflux/TestV1.php | 12 +---
4 files changed, 59 insertions(+), 68 deletions(-)
diff --git a/lib/Database.php b/lib/Database.php
index f8053ae9..4c7237ad 100644
--- a/lib/Database.php
+++ b/lib/Database.php
@@ -796,19 +796,21 @@ class Database {
"SELECT
s.id as id,
s.feed as feed,
- f.url,source,folder,pinned,err_count,err_msg,order_type,added,keep_rule,block_rule,f.etag,s.scrape,
+ f.url,source,pinned,err_count,err_msg,order_type,added,keep_rule,block_rule,f.etag,s.scrape,
f.updated as updated,
f.modified as edited,
s.modified as modified,
f.next_fetch,
i.id as icon_id,
i.url as icon_url,
- t.top as top_folder,
+ folder, t.top as top_folder, d.name as folder_name, dt.name as top_folder_name,
coalesce(s.title, f.title) as title,
coalesce((articles - hidden - marked), articles) as unread
FROM arsse_subscriptions as s
- left join topmost as t on t.f_id = s.folder
join arsse_feeds as f on f.id = s.feed
+ left join topmost as t on t.f_id = s.folder
+ left join arsse_folders as d on s.folder = d.id
+ left join arsse_folders as dt on t.top = dt.id
left join arsse_icons as i on i.id = f.icon
left join (
select
diff --git a/lib/REST/Miniflux/V1.php b/lib/REST/Miniflux/V1.php
index efd04695..acecc027 100644
--- a/lib/REST/Miniflux/V1.php
+++ b/lib/REST/Miniflux/V1.php
@@ -554,21 +554,30 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
return new EmptyResponse(204);
}
- protected function baseCategory(): array {
- // the root folder is always a category and is always ID 1
- // the specific formulation is verbose, so a function makes sense
+ /** Returns a useful subset of user metadata
+ *
+ * The following keys are included:
+ *
+ * - "num": The user's numeric ID,
+ * - "root": The effective name of the root folder
+ */
+ protected function userMeta(string $user): array {
$meta = Arsse::$user->propertiesGet(Arsse::$user->id, false);
- return ['id' => 1, 'title' => $meta['root_folder_name'] ?? Arsse::$lang->msg("API.Miniflux.DefaultCategoryName"), 'user_id' => $meta['num']];
+ return [
+ 'num' => $meta['num'],
+ 'root' => $meta['root_folder_name'] ?? Arsse::$lang->msg("API.Miniflux.DefaultCategoryName")
+ ];
}
protected function getCategories(): ResponseInterface {
+ $out = [];
// add the root folder as a category
- $out = [$this->baseCategory()];
- $num = $out[0]['user_id'];
+ $meta = $this->userMeta(Arsse::$user->id);
+ $out[] = ['id' => 1, 'title' => $meta['root'], 'user_id' => $meta['num']];
// add other top folders as categories
foreach (Arsse::$db->folderList(Arsse::$user->id, null, false) as $f) {
// always add 1 to the ID since the root folder will always be 1 instead of 0.
- $out[] = ['id' => $f['id'] + 1, 'title' => $f['name'], 'user_id' => $num];
+ $out[] = ['id' => $f['id'] + 1, 'title' => $f['name'], 'user_id' => $meta['num']];
}
return new Response($out);
}
@@ -651,24 +660,11 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
return new EmptyResponse(204);
}
- protected function mapFolders(): array {
- $folders = [0 => $this->baseCategory()];
- $num = $folders[0]['user_id'];
- foreach (Arsse::$db->folderList(Arsse::$user->id, null, false) as $r) {
- $folders[(int) $r['id']] = [
- 'id' => ((int) $r['id']) + 1,
- 'title' => $r['name'],
- 'user_id' => $num,
- ];
- }
- return $folders;
- }
-
- protected function transformFeed(array $sub, array $folders): array {
+ protected function transformFeed(array $sub, int $uid, string $rootName): array {
$url = new Uri($sub['url']);
return [
'id' => (int) $sub['id'],
- 'user_id' => $folders[0]['user_id'],
+ 'user_id' => $uid,
'feed_url' => (string) $url->withUserInfo(""),
'site_url' => (string) $sub['source'],
'title' => (string) $sub['title'],
@@ -689,19 +685,21 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
'disabled' => false,
'ignore_http_cache' => false,
'fetch_via_proxy' => false,
- 'category' => $folders[(int) $sub['top_folder']],
+ 'category' => [
+ 'id' => (int) $sub['top_folder'] + 1,
+ 'title' => $sub['top_folder_name'] ?? $rootName,
+ 'user_id' => $uid,
+ ],
'icon' => $sub['icon_id'] ? ['feed_id' => (int) $sub['id'], 'icon_id' => (int) $sub['icon_id']] : null,
];
}
protected function getFeeds(): ResponseInterface {
- $tr = Arsse::$db->begin();
- // compile the list of folders; the feed list includes folder names
- $folders = $this->mapFolders();
- // next compile the list of feeds
$out = [];
+ $tr = Arsse::$db->begin();
+ $meta = $this->userMeta(Arsse::$user->id);
foreach (Arsse::$db->subscriptionList(Arsse::$user->id) as $r) {
- $out[] = $this->transformFeed($r, $folders);
+ $out[] = $this->transformFeed($r, $meta['num'], $meta['root']);
}
return new Response($out);
}
@@ -711,35 +709,30 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
$folder = ((int) $path[1]) - 1;
// unless the folder is root, list recursive
$recursive = $folder > 0;
+ $out = [];
$tr = Arsse::$db->begin();
- // get the list of subscriptions, or bail\
+ // get the list of subscriptions, or bail
try {
- $subs = Arsse::$db->subscriptionList(Arsse::$user->id, $folder, $recursive)->getAll();
+ $meta = $this->userMeta(Arsse::$user->id);
+ foreach (Arsse::$db->subscriptionList(Arsse::$user->id, $folder, $recursive) as $r) {
+ $out[] = $this->transformFeed($r, $meta['num'], $meta['root']);
+ }
} catch (ExceptionInput $e) {
// the folder does not exist
return new ErrorResponse("404", 404);
}
- // compile the list of folders; the feed list includes folder names
- // NOTE: We compile the full list of folders in case someone has manually selected a non-top folder
- $folders = $this->mapFolders();
- // next compile the list of feeds
- $out = [];
- foreach ($subs as $r) {
- $out[] = $this->transformFeed($r, $folders);
- }
return new Response($out);
}
protected function getFeed(array $path): ResponseInterface {
$tr = Arsse::$db->begin();
+ $meta = $this->userMeta(Arsse::$user->id);
try {
$sub = Arsse::$db->subscriptionPropertiesGet(Arsse::$user->id, (int) $path[1]);
+ return new Response($this->transformFeed($sub, $meta['num'], $meta['root']));
} catch (ExceptionInput $e) {
return new ErrorResponse("404", 404);
}
- // compile the list of folders; the feed list includes folder names
- $folders = $this->mapFolders();
- return new Response($this->transformFeed($sub, $folders));
}
protected function createFeed(array $data): ResponseInterface {
diff --git a/tests/cases/Database/SeriesSubscription.php b/tests/cases/Database/SeriesSubscription.php
index e4a2b099..f235a916 100644
--- a/tests/cases/Database/SeriesSubscription.php
+++ b/tests/cases/Database/SeriesSubscription.php
@@ -314,22 +314,26 @@ trait SeriesSubscription {
public function testListSubscriptions(): void {
$exp = [
[
- 'url' => "http://example.com/feed2",
- 'title' => "eek",
- 'folder' => null,
- 'top_folder' => null,
- 'unread' => 4,
- 'pinned' => 1,
- 'order_type' => 2,
+ 'url' => "http://example.com/feed2",
+ 'title' => "eek",
+ 'folder' => null,
+ 'top_folder' => null,
+ 'folder_name' => null,
+ 'top_folder_name' => null,
+ 'unread' => 4,
+ 'pinned' => 1,
+ 'order_type' => 2,
],
[
- 'url' => "http://example.com/feed3",
- 'title' => "Ook",
- 'folder' => 2,
- 'top_folder' => 1,
- 'unread' => 2,
- 'pinned' => 0,
- 'order_type' => 1,
+ 'url' => "http://example.com/feed3",
+ 'title' => "Ook",
+ 'folder' => 2,
+ 'top_folder' => 1,
+ 'folder_name' => "Software",
+ 'top_folder_name' => "Technology",
+ 'unread' => 2,
+ 'pinned' => 0,
+ 'order_type' => 1,
],
];
$this->assertResult($exp, Arsse::$db->subscriptionList($this->user));
diff --git a/tests/cases/REST/Miniflux/TestV1.php b/tests/cases/REST/Miniflux/TestV1.php
index d04f45a3..818bffc1 100644
--- a/tests/cases/REST/Miniflux/TestV1.php
+++ b/tests/cases/REST/Miniflux/TestV1.php
@@ -67,8 +67,8 @@ class TestV1 extends \JKingWeb\Arsse\Test\AbstractTest {
],
];
protected $feeds = [
- ['id' => 1, 'feed' => 12, 'url' => "http://example.com/ook", 'title' => "Ook", 'source' => "http://example.com/", 'icon_id' => 47, 'icon_url' => "http://example.com/icon", 'folder' => 2112, 'top_folder' => 5, 'pinned' => 0, 'err_count' => 1, 'err_msg' => "Oopsie", 'order_type' => 0, 'keep_rule' => "this|that", 'block_rule' => "both", 'added' => "2020-12-21 21:12:00", 'updated' => "2021-01-05 13:51:32", 'edited' => "2021-01-01 00:00:00", 'modified' => "2020-11-30 04:08:52", 'next_fetch' => "2021-01-20 00:00:00", 'etag' => "OOKEEK", 'scrape' => 0, 'unread' => 42],
- ['id' => 55, 'feed' => 12, 'url' => "http://j%20k:super%20secret@example.com/eek", 'title' => "Eek", 'source' => "http://example.com/", 'icon_id' => null, 'icon_url' => null, 'folder' => null, 'top_folder' => null, 'pinned' => 0, 'err_count' => 0, 'err_msg' => null, 'order_type' => 0, 'keep_rule' => null, 'block_rule' => null, 'added' => "2020-12-21 21:12:00", 'updated' => "2021-01-05 13:51:32", 'edited' => null, 'modified' => "2020-11-30 04:08:52", 'next_fetch' => null, 'etag' => null, 'scrape' => 1, 'unread' => 0],
+ ['id' => 1, 'feed' => 12, 'url' => "http://example.com/ook", 'title' => "Ook", 'source' => "http://example.com/", 'icon_id' => 47, 'icon_url' => "http://example.com/icon", 'folder' => 2112, 'top_folder' => 5, 'folder_name' => "Cat Eek", 'top_folder_name' => "Cat Ook", 'pinned' => 0, 'err_count' => 1, 'err_msg' => "Oopsie", 'order_type' => 0, 'keep_rule' => "this|that", 'block_rule' => "both", 'added' => "2020-12-21 21:12:00", 'updated' => "2021-01-05 13:51:32", 'edited' => "2021-01-01 00:00:00", 'modified' => "2020-11-30 04:08:52", 'next_fetch' => "2021-01-20 00:00:00", 'etag' => "OOKEEK", 'scrape' => 0, 'unread' => 42],
+ ['id' => 55, 'feed' => 12, 'url' => "http://j%20k:super%20secret@example.com/eek", 'title' => "Eek", 'source' => "http://example.com/", 'icon_id' => null, 'icon_url' => null, 'folder' => null, 'top_folder' => null, 'folder_name' => null, 'top_folder_name' => null, 'pinned' => 0, 'err_count' => 0, 'err_msg' => null, 'order_type' => 0, 'keep_rule' => null, 'block_rule' => null, 'added' => "2020-12-21 21:12:00", 'updated' => "2021-01-05 13:51:32", 'edited' => null, 'modified' => "2020-11-30 04:08:52", 'next_fetch' => null, 'etag' => null, 'scrape' => 1, 'unread' => 0],
];
protected $feedsOut = [
['id' => 1, 'user_id' => 42, 'feed_url' => "http://example.com/ook", 'site_url' => "http://example.com/", 'title' => "Ook", 'checked_at' => "2021-01-05T13:51:32.000000Z", 'next_check_at' => "2021-01-20T00:00:00.000000Z", 'etag_header' => "OOKEEK", 'last_modified_header' => "Fri, 01 Jan 2021 00:00:00 GMT", 'parsing_error_message' => "Oopsie", 'parsing_error_count' => 1, 'scraper_rules' => "", 'rewrite_rules' => "", 'crawler' => false, 'blocklist_rules' => "both", 'keeplist_rules' => "this|that", 'user_agent' => "", 'username' => "", 'password' => "", 'disabled' => false, 'ignore_http_cache' => false, 'fetch_via_proxy' => false, 'category' => ['id' => 6, 'title' => "Cat Ook", 'user_id' => 42], 'icon' => ['feed_id' => 1,'icon_id' => 47]],
@@ -545,18 +545,12 @@ class TestV1 extends \JKingWeb\Arsse\Test\AbstractTest {
}
public function testListFeeds(): void {
- \Phake::when(Arsse::$db)->folderList->thenReturn(new Result($this->v([
- ['id' => 5, 'name' => "Cat Ook"],
- ])));
\Phake::when(Arsse::$db)->subscriptionList->thenReturn(new Result($this->v($this->feeds)));
$exp = new Response($this->feedsOut);
$this->assertMessage($exp, $this->req("GET", "/feeds"));
}
public function testListFeedsOfACategory(): void {
- \Phake::when(Arsse::$db)->folderList->thenReturn(new Result($this->v([
- ['id' => 5, 'name' => "Cat Ook"],
- ])));
\Phake::when(Arsse::$db)->subscriptionList->thenReturn(new Result($this->v($this->feeds)));
$exp = new Response($this->feedsOut);
$this->assertMessage($exp, $this->req("GET", "/categories/2112/feeds"));
@@ -564,7 +558,6 @@ class TestV1 extends \JKingWeb\Arsse\Test\AbstractTest {
}
public function testListFeedsOfTheRootCategory(): void {
- \Phake::when(Arsse::$db)->folderList->thenReturn(new Result($this->v([['id' => 5, 'name' => "Cat Ook"],])));
\Phake::when(Arsse::$db)->subscriptionList->thenReturn(new Result($this->v($this->feeds)));
$exp = new Response($this->feedsOut);
$this->assertMessage($exp, $this->req("GET", "/categories/1/feeds"));
@@ -580,7 +573,6 @@ class TestV1 extends \JKingWeb\Arsse\Test\AbstractTest {
public function testGetAFeed(): void {
\Phake::when(Arsse::$db)->subscriptionPropertiesGet->thenReturn($this->v($this->feeds[0]))->thenReturn($this->v($this->feeds[1]));
- \Phake::when(Arsse::$db)->folderList->thenReturn(new Result($this->v([['id' => 5, 'name' => "Cat Ook"],])));
$this->assertMessage(new Response($this->feedsOut[0]), $this->req("GET", "/feeds/1"));
\Phake::verify(Arsse::$db)->subscriptionPropertiesGet(Arsse::$user->id, 1);
$this->assertMessage(new Response($this->feedsOut[1]), $this->req("GET", "/feeds/55"));
From 1e924bed83d9900a254248274404ec9d8447fba1 Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Sat, 30 Jan 2021 13:38:02 -0500
Subject: [PATCH 141/366] Partial query string normalization
---
lib/REST/Miniflux/V1.php | 40 ++++++++++++++++++++++++++++++++++++++++
1 file changed, 40 insertions(+)
diff --git a/lib/REST/Miniflux/V1.php b/lib/REST/Miniflux/V1.php
index acecc027..17864bbc 100644
--- a/lib/REST/Miniflux/V1.php
+++ b/lib/REST/Miniflux/V1.php
@@ -32,6 +32,20 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
protected const ACCEPTED_TYPES_OPML = ["application/xml", "text/xml", "text/x-opml"];
protected const ACCEPTED_TYPES_JSON = ["application/json"];
protected const TOKEN_LENGTH = 32;
+ protected const VALID_QUERY = [
+ 'status' => V::T_STRING + V::M_ARRAY,
+ 'offset' => V::T_INT,
+ 'limit' => V::T_INT,
+ 'order' => V::T_STRING,
+ 'direction' => V::T_STRING,
+ 'before' => V::T_DATE, // Unix timestamp
+ 'after' => V::T_DATE, // Unix timestamp
+ 'before_entry_id' => V::T_INT,
+ 'after_entry_id' => V::T_INT,
+ 'starred' => V::T_BOOL,
+ 'search' => V::T_STRING,
+ 'category_id' => V::T_INT,
+ ];
protected const VALID_JSON = [
// user properties which map directly to Arsse user metadata are listed separately;
// not all these properties are used by our implementation, but they are treated
@@ -345,6 +359,32 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
return $body;
}
+ protected function normalizeQuery(string $query): array {
+ // fill an array with all valid keys
+ $out = [];
+ foreach (self::VALID_QUERY as $k => $t) {
+ $out[$k] = ($t >= V::M_ARRAY) ? [] : null;
+ }
+ // split the query string and normalize the values to their correct types
+ foreach (explode("&", $query) as $parts) {
+ $parts = explode("=", $parts, 2);
+ $k = rawurldecode($parts[0]);
+ $v = (isset($parts[1])) ? rawurldecode($parts[1]) : null;
+ if (!isset(self::VALID_QUERY[$k]) || !isset($v)) {
+ // ignore unknown keys and missing values
+ continue;
+ }
+ $t = self::VALID_QUERY[$k] & ~V::M_ARRAY;
+ $a = self::VALID_QUERY[$k] >= V::M_ARRAY;
+ if ($a) {
+ $out[$k][] = V::normalize($v, $t + V::M_DROP, "unix");
+ } elseif (!isset($out[$k])) {
+ $out[$k] = V::normalize($v, $t + V::M_DROP, "unix");
+ }
+ }
+ return $out;
+ }
+
protected function handleHTTPOptions(string $url): ResponseInterface {
// normalize the URL path: change any IDs to 1 for easier comparison
$url = $this->normalizePathIDs($url);
From bb890834444e815a5710a27b0eba01c3fcec6f4f Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Sat, 30 Jan 2021 21:37:19 -0500
Subject: [PATCH 142/366] Perform strict validation of query parameters
This is in fact stricter than Miniflux, which ignores duplicate values
and does not validate anything other than the string enumerations
---
lib/Conf.php | 10 +---------
lib/Misc/ValueInfo.php | 12 ++++++++++++
lib/REST/Miniflux/V1.php | 41 ++++++++++++++++++++++++++++------------
locale/en.php | 1 +
4 files changed, 43 insertions(+), 21 deletions(-)
diff --git a/lib/Conf.php b/lib/Conf.php
index c6fd33c1..dfe35e29 100644
--- a/lib/Conf.php
+++ b/lib/Conf.php
@@ -113,14 +113,6 @@ class Conf {
/** @var \DateInterval|null (OBSOLETE) Number of seconds for SQLite to wait before returning a timeout error when trying to acquire a write lock on the database (zero does not wait) */
public $dbSQLite3Timeout = null; // previously 60.0
-
- protected const TYPE_NAMES = [
- Value::T_BOOL => "boolean",
- Value::T_STRING => "string",
- Value::T_FLOAT => "float",
- VALUE::T_INT => "integer",
- Value::T_INTERVAL => "interval",
- ];
protected const EXPECTED_TYPES = [
'dbTimeoutExec' => "double",
'dbTimeoutLock' => "double",
@@ -318,7 +310,7 @@ class Conf {
return $value;
} catch (ExceptionType $e) {
$type = $this->types[$key]['const'] & ~(Value::M_STRICT | Value::M_DROP | Value::M_NULL | Value::M_ARRAY);
- throw new Conf\Exception("typeMismatch", ['param' => $key, 'type' => self::TYPE_NAMES[$type], 'file' => $file, 'nullable' => $nullable]);
+ throw new Conf\Exception("typeMismatch", ['param' => $key, 'type' => Value::TYPE_NAMES[$type], 'file' => $file, 'nullable' => $nullable]);
}
}
diff --git a/lib/Misc/ValueInfo.php b/lib/Misc/ValueInfo.php
index e9af5272..9977e786 100644
--- a/lib/Misc/ValueInfo.php
+++ b/lib/Misc/ValueInfo.php
@@ -35,6 +35,17 @@ class ValueInfo {
public const M_DROP = 1 << 29; // drop the value (return null) if the type doesn't match
public const M_STRICT = 1 << 30; // throw an exception if the type doesn't match
public const M_ARRAY = 1 << 31; // the value should be a flat array of values of the specified type; indexed and associative are both acceptable
+ public const TYPE_NAMES = [
+ self::T_MIXED => "mixed",
+ self::T_NULL => "null",
+ self::T_BOOL => "boolean",
+ self::T_INT => "integer",
+ self::T_FLOAT => "float",
+ self::T_DATE => "date",
+ self::T_STRING => "string",
+ self::T_ARRAY => "array",
+ self::T_INTERVAL => "interval",
+ ];
// symbolic date and time formats
protected const DATE_FORMATS = [ // in out
'iso8601' => ["!Y-m-d\TH:i:s", "Y-m-d\TH:i:s\Z" ], // NOTE: ISO 8601 dates require special input processing because of varying formats for timezone offsets
@@ -48,6 +59,7 @@ class ValueInfo {
'float' => ["U.u", "U.u" ],
];
+
public static function normalize($value, int $type, string $dateInFormat = null, $dateOutFormat = null) {
$allowNull = ($type & self::M_NULL);
$strict = ($type & (self::M_STRICT | self::M_DROP));
diff --git a/lib/REST/Miniflux/V1.php b/lib/REST/Miniflux/V1.php
index 17864bbc..c6761213 100644
--- a/lib/REST/Miniflux/V1.php
+++ b/lib/REST/Miniflux/V1.php
@@ -8,6 +8,7 @@ namespace JKingWeb\Arsse\REST\Miniflux;
use JKingWeb\Arsse\Arsse;
use JKingWeb\Arsse\Feed;
+use JKingWeb\Arsse\ExceptionType;
use JKingWeb\Arsse\Feed\Exception as FeedException;
use JKingWeb\Arsse\AbstractException;
use JKingWeb\Arsse\Context\Context;
@@ -118,7 +119,7 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
'DELETE' => ["deleteCategory", false, true, false, false, []],
],
'/categories/1/entries' => [
- 'GET' => ["getCategoryEntries", false, true, false, false, []],
+ 'GET' => ["getCategoryEntries", false, true, false, true, []],
],
'/categories/1/entries/1' => [
'GET' => ["getCategoryEntry", false, true, false, false, []],
@@ -155,7 +156,7 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
'DELETE' => ["deleteFeed", false, true, false, false, []],
],
'/feeds/1/entries' => [
- 'GET' => ["getFeedEntries", false, true, false, false, []],
+ 'GET' => ["getFeedEntries", false, true, false, true, []],
],
'/feeds/1/entries/1' => [
'GET' => ["getFeedEntry", false, true, false, false, []],
@@ -226,7 +227,7 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
return new ErrorResponse("401", 401);
}
// get the request path only; this is assumed to already be normalized
- $target = parse_url($req->getRequestTarget())['path'] ?? "";
+ $target = parse_url($req->getRequestTarget(), \PHP_URL_PATH) ?? "";
$method = $req->getMethod();
// handle HTTP OPTIONS requests
if ($method === "OPTIONS") {
@@ -270,7 +271,7 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
$args[] = $data;
}
if ($reqQuery) {
- $args[] = $req->getQueryParams();
+ $args[] = $this->normalizeQuery(parse_url($req->getRequestTarget(), \PHP_URL_QUERY) ?? "");
}
try {
return $this->$func(...$args);
@@ -330,9 +331,9 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
} elseif (gettype($body[$k]) !== $t) {
return new ErrorResponse(["InvalidInputType", 'field' => $k, 'expected' => $t, 'actual' => gettype($body[$k])], 422);
} elseif (
- (in_array($k, ["keeplist_rules", "blocklist_rules"]) && !Rule::validate($body[$k])) ||
- (in_array($k, ["url", "feed_url"]) && !URL::absolute($body[$k])) ||
- ($k === "category_id" && $body[$k] < 1)
+ (in_array($k, ["keeplist_rules", "blocklist_rules"]) && !Rule::validate($body[$k]))
+ || (in_array($k, ["url", "feed_url"]) && !URL::absolute($body[$k]))
+ || ($k === "category_id" && $body[$k] < 1)
) {
return new ErrorResponse(["InvalidInputValue", 'field' => $k], 422);
}
@@ -359,7 +360,7 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
return $body;
}
- protected function normalizeQuery(string $query): array {
+ protected function normalizeQuery(string $query) {
// fill an array with all valid keys
$out = [];
foreach (self::VALID_QUERY as $k => $t) {
@@ -376,10 +377,26 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
}
$t = self::VALID_QUERY[$k] & ~V::M_ARRAY;
$a = self::VALID_QUERY[$k] >= V::M_ARRAY;
- if ($a) {
- $out[$k][] = V::normalize($v, $t + V::M_DROP, "unix");
- } elseif (!isset($out[$k])) {
- $out[$k] = V::normalize($v, $t + V::M_DROP, "unix");
+ try {
+ if ($a) {
+ $out[$k][] = V::normalize($v, $t + V::M_STRICT, "unix");
+ } elseif (!isset($out[$k])) {
+ $out[$k] = V::normalize($v, $t + V::M_STRICT, "unix");
+ } else {
+ return new ErrorResponse(["DuplicateInputValue", 'field' => $k], 400);
+ }
+ } catch (ExceptionType $e) {
+ return new ErrorResponse(["InvalidInputValue", 'field' => $k], 400);
+ }
+ if (
+ // TODO: does the "starred" param accept 0/1, or just true/false?
+ (in_array($k, ["category_id", "before_entry_id", "after_entry_id"]) && $v < 1)
+ || (in_array($k, ["limit", "offset"]) && $v < 0)
+ || ($k === "direction" && !in_array($v, ["asc", "desc"]))
+ || ($k === "order" && !in_array($v, ["id", "status", "published_at", "category_title", "category_id"]))
+ || ($k === "status" && !in_array($v, ["read", "unread", "removed"]))
+ ) {
+ return new ErrorResponse(["InvalidInputValue", 'field' => $k], 400);
}
}
return $out;
diff --git a/locale/en.php b/locale/en.php
index b44d7490..03b0579f 100644
--- a/locale/en.php
+++ b/locale/en.php
@@ -12,6 +12,7 @@ return [
'API.Miniflux.Error.403' => 'Access Forbidden',
'API.Miniflux.Error.404' => 'Resource Not Found',
'API.Miniflux.Error.MissingInputValue' => 'Required key "{field}" was not present in input',
+ 'API.Miniflux.Error.DuplicateInputValue' => 'Key "{field}" accepts only one value',
'API.Miniflux.Error.InvalidBodyJSON' => 'Invalid JSON payload: {0}',
'API.Miniflux.Error.InvalidInputType' => 'Input key "{field}" of type {actual} was expected as {expected}',
'API.Miniflux.Error.InvalidInputValue' => 'Supplied value is not valid for input key "{field}"',
From ddbcb598e8885234aa4c7f63c8ff739059216b0b Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Sun, 31 Jan 2021 10:44:27 -0500
Subject: [PATCH 143/366] Match more closely Miniflux query string behaviour
- The starred key is a simople boolean whose value is immaterial
- Blank values are honoured for keys other than starred and status
---
lib/REST/Miniflux/V1.php | 39 ++++++++++++++++++++++++++++-----------
1 file changed, 28 insertions(+), 11 deletions(-)
diff --git a/lib/REST/Miniflux/V1.php b/lib/REST/Miniflux/V1.php
index c6761213..23d9e021 100644
--- a/lib/REST/Miniflux/V1.php
+++ b/lib/REST/Miniflux/V1.php
@@ -43,7 +43,7 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
'after' => V::T_DATE, // Unix timestamp
'before_entry_id' => V::T_INT,
'after_entry_id' => V::T_INT,
- 'starred' => V::T_BOOL,
+ 'starred' => V::T_MIXED, // the presence of the starred key is the only thing considered by Miniflux
'search' => V::T_STRING,
'category_id' => V::T_INT,
];
@@ -271,7 +271,11 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
$args[] = $data;
}
if ($reqQuery) {
- $args[] = $this->normalizeQuery(parse_url($req->getRequestTarget(), \PHP_URL_QUERY) ?? "");
+ $query = $this->normalizeQuery(parse_url($req->getRequestTarget(), \PHP_URL_QUERY) ?? "");
+ if ($query instanceof ResponseInterface) {
+ return $query;
+ }
+ $args[] = $query;
}
try {
return $this->$func(...$args);
@@ -363,33 +367,46 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
protected function normalizeQuery(string $query) {
// fill an array with all valid keys
$out = [];
+ $seen = [];
foreach (self::VALID_QUERY as $k => $t) {
$out[$k] = ($t >= V::M_ARRAY) ? [] : null;
+ $seen[$k] = false;
}
// split the query string and normalize the values to their correct types
foreach (explode("&", $query) as $parts) {
$parts = explode("=", $parts, 2);
$k = rawurldecode($parts[0]);
- $v = (isset($parts[1])) ? rawurldecode($parts[1]) : null;
- if (!isset(self::VALID_QUERY[$k]) || !isset($v)) {
- // ignore unknown keys and missing values
+ $v = (isset($parts[1])) ? rawurldecode($parts[1]) : "";
+ if (!isset(self::VALID_QUERY[$k])) {
+ // ignore unknown keys
continue;
}
$t = self::VALID_QUERY[$k] & ~V::M_ARRAY;
$a = self::VALID_QUERY[$k] >= V::M_ARRAY;
try {
- if ($a) {
- $out[$k][] = V::normalize($v, $t + V::M_STRICT, "unix");
- } elseif (!isset($out[$k])) {
- $out[$k] = V::normalize($v, $t + V::M_STRICT, "unix");
- } else {
+ if ($seen[$k] && !$a) {
+ // if the key has already been seen and it's not an array field, bail
+ // NOTE: Miniflux itself simply ignores duplicates entirely
return new ErrorResponse(["DuplicateInputValue", 'field' => $k], 400);
}
+ $seen[$k] = true;
+ if ($k === "starred") {
+ // the starred key is a special case in that Miniflux only considers the presence of the key
+ $out[$k] = true;
+ continue;
+ } elseif ($v === "") {
+ // if the value is empty we can discard the value, but subsequent values for the same non-array key are still considered duplicates
+ continue;
+ } elseif ($a) {
+ $out[$k][] = V::normalize($v, $t + V::M_STRICT, "unix");
+ } else {
+ $out[$k] = V::normalize($v, $t + V::M_STRICT, "unix");
+ }
} catch (ExceptionType $e) {
return new ErrorResponse(["InvalidInputValue", 'field' => $k], 400);
}
+ // perform additional validation
if (
- // TODO: does the "starred" param accept 0/1, or just true/false?
(in_array($k, ["category_id", "before_entry_id", "after_entry_id"]) && $v < 1)
|| (in_array($k, ["limit", "offset"]) && $v < 0)
|| ($k === "direction" && !in_array($v, ["asc", "desc"]))
From 197cbba77dd1caff18368d6129d70b8741f47d4d Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Mon, 1 Feb 2021 15:48:44 -0500
Subject: [PATCH 144/366] Document article column definitions
---
lib/Database.php | 46 +++++++++++++++++++++++-----------------------
1 file changed, 23 insertions(+), 23 deletions(-)
diff --git a/lib/Database.php b/lib/Database.php
index 4c7237ad..62740d4e 100644
--- a/lib/Database.php
+++ b/lib/Database.php
@@ -1451,29 +1451,29 @@ class Database {
protected function articleColumns(): array {
$greatest = $this->db->sqlToken("greatest");
return [
- 'id' => "arsse_articles.id",
- 'edition' => "latest_editions.edition",
- 'latest_edition' => "max(latest_editions.edition)",
- 'url' => "arsse_articles.url",
- 'title' => "arsse_articles.title",
- 'author' => "arsse_articles.author",
- 'content' => "coalesce(case when arsse_subscriptions.scrape = 1 then arsse_articles.content_scraped end, arsse_articles.content)",
- 'guid' => "arsse_articles.guid",
- 'fingerprint' => "arsse_articles.url_title_hash || ':' || arsse_articles.url_content_hash || ':' || arsse_articles.title_content_hash",
- 'folder' => "coalesce(arsse_subscriptions.folder,0)",
- 'subscription' => "arsse_subscriptions.id",
- 'feed' => "arsse_subscriptions.feed",
- 'hidden' => "coalesce(arsse_marks.hidden,0)",
- 'starred' => "coalesce(arsse_marks.starred,0)",
- 'unread' => "abs(coalesce(arsse_marks.read,0) - 1)",
- 'note' => "coalesce(arsse_marks.note,'')",
- 'published_date' => "arsse_articles.published",
- 'edited_date' => "arsse_articles.edited",
- 'modified_date' => "arsse_articles.modified",
- 'marked_date' => "$greatest(arsse_articles.modified, coalesce(arsse_marks.modified, '0001-01-01 00:00:00'), coalesce(label_stats.modified, '0001-01-01 00:00:00'))",
- 'subscription_title' => "coalesce(arsse_subscriptions.title, arsse_feeds.title)",
- 'media_url' => "arsse_enclosures.url",
- 'media_type' => "arsse_enclosures.type",
+ 'id' => "arsse_articles.id", // The article's unchanging numeric ID
+ 'edition' => "latest_editions.edition", // The article's numeric ID which increases each time it is modified in the feed
+ 'latest_edition' => "max(latest_editions.edition)", // The most recent of all editions
+ 'url' => "arsse_articles.url", // The URL of the article's full content
+ 'title' => "arsse_articles.title", // The title
+ 'author' => "arsse_articles.author", // The name of the author
+ 'content' => "coalesce(case when arsse_subscriptions.scrape = 1 then arsse_articles.content_scraped end, arsse_articles.content)", // The article content
+ 'guid' => "arsse_articles.guid", // The GUID of the article, as presented in the feed (NOTE: Picofeed actually provides a hash of the ID)
+ 'fingerprint' => "arsse_articles.url_title_hash || ':' || arsse_articles.url_content_hash || ':' || arsse_articles.title_content_hash", // A combination of three hashes
+ 'folder' => "coalesce(arsse_subscriptions.folder,0)", // The folder of the article's feed. This is mainly for use in WHERE clauses
+ 'subscription' => "arsse_subscriptions.id", // The article's parent subscription
+ 'feed' => "arsse_subscriptions.feed", // The article's parent feed
+ 'hidden' => "coalesce(arsse_marks.hidden,0)", // Whether the article is hidden
+ 'starred' => "coalesce(arsse_marks.starred,0)", // Whether the article is starred
+ 'unread' => "abs(coalesce(arsse_marks.read,0) - 1)", // Whether the article is unread
+ 'note' => "coalesce(arsse_marks.note,'')", // The article's note, if any
+ 'published_date' => "arsse_articles.published", // The date at which the article was first published i.e. its creation date
+ 'edited_date' => "arsse_articles.edited", // The date at which the article was last edited according to the feed
+ 'modified_date' => "arsse_articles.modified", // The date at which the article was last updated in our database
+ 'marked_date' => "$greatest(arsse_articles.modified, coalesce(arsse_marks.modified, '0001-01-01 00:00:00'), coalesce(label_stats.modified, '0001-01-01 00:00:00'))", // The date at which the article metadata was last modified by the user
+ 'subscription_title' => "coalesce(arsse_subscriptions.title, arsse_feeds.title)", // The parent subscription's title
+ 'media_url' => "arsse_enclosures.url", // The URL of the article's enclosure, if any (NOTE: Picofeed only exposes one enclosure)
+ 'media_type' => "arsse_enclosures.type", // The Content-Type of the article's enclosure, if any
];
}
From 007183450a8a9f868cf914dbca68a0eccfe72287 Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Mon, 1 Feb 2021 21:02:46 -0500
Subject: [PATCH 145/366] Context and column list for article queries
Sorting and transformation still need to be figured out
---
.../030_Supported_Protocols/005_Miniflux.md | 2 ++
lib/REST/Miniflux/V1.php | 36 +++++++++++++++++++
2 files changed, 38 insertions(+)
diff --git a/docs/en/030_Supported_Protocols/005_Miniflux.md b/docs/en/030_Supported_Protocols/005_Miniflux.md
index c098aa1e..da52cb49 100644
--- a/docs/en/030_Supported_Protocols/005_Miniflux.md
+++ b/docs/en/030_Supported_Protocols/005_Miniflux.md
@@ -36,6 +36,8 @@ Miniflux version 2.0.27 is emulated, though not all features are implemented
- Filtering rules may not function identically (see below for details)
- The `checked_at` field of feeds indicates when the feed was last updated rather than when it was last checked
- Creating a feed with the `scrape` property set to `true` might not return scraped content for the initial synchronization
+- Querying articles for both read/unread and removed statuses will not return all removed articles
+- Search strings will match partial words
# Behaviour of filtering (block and keep) rules
diff --git a/lib/REST/Miniflux/V1.php b/lib/REST/Miniflux/V1.php
index 23d9e021..57398f88 100644
--- a/lib/REST/Miniflux/V1.php
+++ b/lib/REST/Miniflux/V1.php
@@ -109,6 +109,7 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
'keeplist_rules' => "keep_rule",
'blocklist_rules' => "block_rule",
];
+ protected const ARTICLE_COLUMNS = ["id", "url", "title", "author", "fingerprint", "subscription", "published_date", "modified_date", "starred", "unread", "content", "media_url", "media_type"];
protected const CALLS = [ // handler method Admin Path Body Query Required fields
'/categories' => [
'GET' => ["getCategories", false, false, false, false, []],
@@ -891,6 +892,41 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
]);
}
+ protected function getEntries(array $query): ResponseInterface {
+ $c = (new Context)
+ ->limit($query['limit'])
+ ->offset($query['offset'])
+ ->starred($query['starred'])
+ ->modifiedSince($query['after']) // FIXME: This may not be the correct date field
+ ->notModifiedSince($query['before'])
+ ->oldestArticle($query['after_entry_id'] ? $query['after_entry_id'] + 1 : null) // FIXME: This might be edition
+ ->latestArticle($query['before_entry_id'] ? $query['before_entry_id'] - 1 : null)
+ ->searchTerms(strlen($query['search'] ?? "") ? preg_split("/\s+/", $query['search']) : null); // NOTE: Miniflux matches only whole words; we match simple substrings
+ if ($query['category_id']) {
+ if ($query['category_id'] === 1) {
+ $c->folderShallow(0);
+ } else {
+ $c->folder($query['category_id'] - 1);
+ }
+ }
+ // FIXME: specifying e.g. ?status=read&status=removed should yield all hidden articles and all read articles, but the best we can do is all read articles which are or are not hidden
+ sort($status = array_unique($query['status']));
+ if ($status === ["read", "removed"]) {
+ $c->unread(false);
+ } elseif ($status === ["read", "unread"]) {
+ $c->hidden(false);
+ } elseif ($status === ["read"]) {
+ $c->hidden(false)->unread(false);
+ } elseif ($status === ["removed", "unread"]) {
+ $c->unread(true);
+ } elseif ($status === ["removed"]) {
+ $c->hidden(true);
+ } elseif ($status === ["unread"]) {
+ $c->hidden(false)->unread(true);
+ }
+ $articles = Arsse::$db->articleList(Arsse::$user->id, $c, self::ARTICLE_COLUMNS);
+ }
+
public static function tokenGenerate(string $user, string $label): string {
// Miniflux produces tokens in base64url alphabet
$t = str_replace(["+", "/"], ["-", "_"], base64_encode(random_bytes(self::TOKEN_LENGTH)));
From 9d7ada7f5969f5bd36ccb2766b88a9e81c1fa313 Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Mon, 1 Feb 2021 22:11:15 -0500
Subject: [PATCH 146/366] Partial implementation of article sorting
---
lib/REST/Miniflux/V1.php | 20 +++++++++++++++++++-
1 file changed, 19 insertions(+), 1 deletion(-)
diff --git a/lib/REST/Miniflux/V1.php b/lib/REST/Miniflux/V1.php
index 57398f88..5510bd2f 100644
--- a/lib/REST/Miniflux/V1.php
+++ b/lib/REST/Miniflux/V1.php
@@ -924,7 +924,25 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
} elseif ($status === ["unread"]) {
$c->hidden(false)->unread(true);
}
- $articles = Arsse::$db->articleList(Arsse::$user->id, $c, self::ARTICLE_COLUMNS);
+ $desc = $query['direction'] === "desc" ? " desc" : "";
+ if ($query['order'] === "id") {
+ $order = ["id".$desc];
+ } elseif ($query['order'] === "status") {
+ if (!$desc) {
+ $order = ["hidden", "unread desc"];
+ } else {
+ $order = ["hidden desc", "unread"];
+ }
+ } elseif ($query['order'] === "published_at") {
+ $order = ["modified_date".$desc];
+ } elseif ($query['order'] === "category_title") {
+ $order = []; // TODO
+ } elseif ($query['order'] === "catgory_id") {
+ $order = []; //TODO
+ } else {
+ $order = [];
+ }
+ $articles = Arsse::$db->articleList(Arsse::$user->id, $c, self::ARTICLE_COLUMNS, $order);
}
public static function tokenGenerate(string $user, string $label): string {
From ed27e0aaaa5e9165ee305eab10bca536d9be6f92 Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Tue, 2 Feb 2021 10:00:08 -0500
Subject: [PATCH 147/366] Sort nulls consistently
PostgreSQL normally sorts nulls after everything else in ascending order
and vice versa; we reverse this, to match SQLIte and MySQL
---
lib/Db/Driver.php | 2 ++
lib/Db/MySQL/Driver.php | 2 ++
lib/Db/PostgreSQL/Driver.php | 4 ++++
lib/Db/SQLite3/Driver.php | 2 ++
tests/cases/Db/BaseDriver.php | 4 ++++
5 files changed, 14 insertions(+)
diff --git a/lib/Db/Driver.php b/lib/Db/Driver.php
index d533b926..cc522dc7 100644
--- a/lib/Db/Driver.php
+++ b/lib/Db/Driver.php
@@ -75,6 +75,8 @@ interface Driver {
* - "nocase": the name of a general-purpose case-insensitive collation sequence
* - "like": the case-insensitive LIKE operator
* - "integer": the integer type to use for explicit casts
+ * - "asc": ascending sort order
+ * - "desc": descending sort order
*/
public function sqlToken(string $token): string;
diff --git a/lib/Db/MySQL/Driver.php b/lib/Db/MySQL/Driver.php
index 8a82be44..1c0da1e1 100644
--- a/lib/Db/MySQL/Driver.php
+++ b/lib/Db/MySQL/Driver.php
@@ -83,6 +83,8 @@ class Driver extends \JKingWeb\Arsse\Db\AbstractDriver {
return '"utf8mb4_unicode_ci"';
case "integer":
return "signed integer";
+ case "asc":
+ return "";
default:
return $token;
}
diff --git a/lib/Db/PostgreSQL/Driver.php b/lib/Db/PostgreSQL/Driver.php
index fccc0710..c22f0963 100644
--- a/lib/Db/PostgreSQL/Driver.php
+++ b/lib/Db/PostgreSQL/Driver.php
@@ -119,6 +119,10 @@ class Driver extends \JKingWeb\Arsse\Db\AbstractDriver {
return '"und-x-icu"';
case "like":
return "ilike";
+ case "asc":
+ return "nulls first";
+ case "desc":
+ return "desc nulls last";
default:
return $token;
}
diff --git a/lib/Db/SQLite3/Driver.php b/lib/Db/SQLite3/Driver.php
index bef5ec65..3445b892 100644
--- a/lib/Db/SQLite3/Driver.php
+++ b/lib/Db/SQLite3/Driver.php
@@ -114,6 +114,8 @@ class Driver extends \JKingWeb\Arsse\Db\AbstractDriver {
switch (strtolower($token)) {
case "greatest":
return "max";
+ case "asc":
+ return "";
default:
return $token;
}
diff --git a/tests/cases/Db/BaseDriver.php b/tests/cases/Db/BaseDriver.php
index 665443d3..f47bcf06 100644
--- a/tests/cases/Db/BaseDriver.php
+++ b/tests/cases/Db/BaseDriver.php
@@ -385,6 +385,8 @@ abstract class BaseDriver extends \JKingWeb\Arsse\Test\AbstractTest {
$nocase = $this->drv->sqlToken("noCASE");
$like = $this->drv->sqlToken("liKe");
$integer = $this->drv->sqlToken("InTEGer");
+ $asc = $this->drv->sqlToken("asc");
+ $desc = $this->drv->sqlToken("desc");
$this->assertSame("NOT_A_TOKEN", $this->drv->sqlToken("NOT_A_TOKEN"));
@@ -392,5 +394,7 @@ abstract class BaseDriver extends \JKingWeb\Arsse\Test\AbstractTest {
$this->assertSame("Z", $this->drv->query("SELECT 'Z' collate $nocase")->getValue());
$this->assertSame("Z", $this->drv->query("SELECT 'Z' where 'Z' $like 'z'")->getValue());
$this->assertEquals(1, $this->drv->query("SELECT CAST((1=1) as $integer)")->getValue());
+ $this->assertEquals([null, 1], array_column($this->drv->query("SELECT 1 as t union select null as t order by t $asc")->getAll(), "t"));
+ $this->assertEquals([1, null], array_column($this->drv->query("SELECT 1 as t union select null as t order by t $desc")->getAll(), "t"));
}
}
From a43f8797c520bd5d3811540f6a1c1d04bc598aa8 Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Tue, 2 Feb 2021 11:51:19 -0500
Subject: [PATCH 148/366] Add ability to sort by folder ID or name
---
docs/en/030_Supported_Protocols/005_Miniflux.md | 2 ++
lib/Database.php | 10 ++++++++--
lib/REST/Miniflux/V1.php | 4 ++--
tests/cases/Database/SeriesArticle.php | 3 ++-
4 files changed, 14 insertions(+), 5 deletions(-)
diff --git a/docs/en/030_Supported_Protocols/005_Miniflux.md b/docs/en/030_Supported_Protocols/005_Miniflux.md
index da52cb49..38ede1c7 100644
--- a/docs/en/030_Supported_Protocols/005_Miniflux.md
+++ b/docs/en/030_Supported_Protocols/005_Miniflux.md
@@ -49,6 +49,8 @@ For convenience the patterns are tested after collapsing whitespace. Unlike Mini
Nextcloud News' root folder and Tiny Tiny RSS' "Uncategorized" catgory are mapped to Miniflux's initial "All" category. This Miniflux category can be renamed, but it cannot be deleted. Attempting to do so will delete the child feeds it contains, but not the category itself.
+Because the root folder does not existing in the database as a separate entity, it will always sort first when ordering by `category_id` or `category_title`.
+
# Interaction with nested categories
Tiny Tiny RSS is unique in allowing newsfeeds to be grouped into categories nested to arbitrary depth. When newsfeeds are placed into nested categories, they simply appear in the top-level category when accessed via the Miniflux protocol. This does not affect OPML exports, where full nesting is preserved.
diff --git a/lib/Database.php b/lib/Database.php
index 62740d4e..db6f087d 100644
--- a/lib/Database.php
+++ b/lib/Database.php
@@ -1461,6 +1461,9 @@ class Database {
'guid' => "arsse_articles.guid", // The GUID of the article, as presented in the feed (NOTE: Picofeed actually provides a hash of the ID)
'fingerprint' => "arsse_articles.url_title_hash || ':' || arsse_articles.url_content_hash || ':' || arsse_articles.title_content_hash", // A combination of three hashes
'folder' => "coalesce(arsse_subscriptions.folder,0)", // The folder of the article's feed. This is mainly for use in WHERE clauses
+ 'top_folder' => "coalesce(folder_data.top,0)", // The top-most folder of the article's feed. This is mainly for use in WHERE clauses
+ 'folder_name' => "folder_data.name", // The name of the folder of the article's feed. This is mainly for use in WHERE clauses
+ 'top_folder_name' => "folder_data.top_name", // The name of the top-most folder of the article's feed. This is mainly for use in WHERE clauses
'subscription' => "arsse_subscriptions.id", // The article's parent subscription
'feed' => "arsse_subscriptions.feed", // The article's parent feed
'hidden' => "coalesce(arsse_marks.hidden,0)", // Whether the article is hidden
@@ -1537,6 +1540,7 @@ class Database {
from arsse_articles
join arsse_subscriptions on arsse_subscriptions.feed = arsse_articles.feed and arsse_subscriptions.owner = ?
join arsse_feeds on arsse_subscriptions.feed = arsse_feeds.id
+ left join folder_data on arsse_subscriptions.folder = folder_data.id
left join arsse_marks on arsse_marks.subscription = arsse_subscriptions.id and arsse_marks.article = arsse_articles.id
left join arsse_enclosures on arsse_enclosures.article = arsse_articles.id
join (
@@ -1548,6 +1552,8 @@ class Database {
["str", "str"],
[$user, $user]
);
+ $q->setCTE("topmost(f_id,top)", "SELECT id,id from arsse_folders where owner = ? and parent is null union all select id,top from arsse_folders join topmost on parent=f_id", ["str"], [$user]);
+ $q->setCTE("folder_data(id,name,top,top_name)", "SELECT f1.id, f1.name, top, f2.name from arsse_folders as f1 join topmost on f1.id = f_id join arsse_folders as f2 on f2.id = top");
$q->setLimit($context->limit, $context->offset);
// handle the simple context options
$options = [
@@ -1788,9 +1794,9 @@ class Database {
$order = $col[1] ?? "";
$col = $col[0];
if ($order === "desc") {
- $order = " desc";
+ $order = " ".$this->db->sqlToken("desc");
} elseif ($order === "asc" || $order === "") {
- $order = "";
+ $order = " ".$this->db->sqlToken("asc");
} else {
// column direction spec is bogus
continue;
diff --git a/lib/REST/Miniflux/V1.php b/lib/REST/Miniflux/V1.php
index 5510bd2f..ea725106 100644
--- a/lib/REST/Miniflux/V1.php
+++ b/lib/REST/Miniflux/V1.php
@@ -936,9 +936,9 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
} elseif ($query['order'] === "published_at") {
$order = ["modified_date".$desc];
} elseif ($query['order'] === "category_title") {
- $order = []; // TODO
+ $order = ["top_folder_name".$desc];
} elseif ($query['order'] === "catgory_id") {
- $order = []; //TODO
+ $order = ["top_folder".$desc];
} else {
$order = [];
}
diff --git a/tests/cases/Database/SeriesArticle.php b/tests/cases/Database/SeriesArticle.php
index 6302f5df..eace73af 100644
--- a/tests/cases/Database/SeriesArticle.php
+++ b/tests/cases/Database/SeriesArticle.php
@@ -408,8 +408,9 @@ trait SeriesArticle {
],
];
$this->fields = [
- "id", "subscription", "feed", "modified_date", "marked_date", "unread", "starred", "edition", "edited_date",
+ "id", "subscription", "feed", "modified_date", "marked_date", "unread", "starred", "hidden", "edition", "edited_date",
"url", "title", "subscription_title", "author", "guid", "published_date", "fingerprint",
+ "folder", "top_folder", "folder_name", "top_folder_name",
"content", "media_url", "media_type",
"note",
];
From 0e7abfa8f9fc8c8330f10778224f857db7ddd012 Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Tue, 2 Feb 2021 16:05:16 -0500
Subject: [PATCH 149/366] Largely complete article querying
Tests to come
---
lib/REST/Miniflux/V1.php | 110 ++++++++++++++++++++++++++++++++++-----
1 file changed, 98 insertions(+), 12 deletions(-)
diff --git a/lib/REST/Miniflux/V1.php b/lib/REST/Miniflux/V1.php
index ea725106..ffd6c363 100644
--- a/lib/REST/Miniflux/V1.php
+++ b/lib/REST/Miniflux/V1.php
@@ -109,7 +109,12 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
'keeplist_rules' => "keep_rule",
'blocklist_rules' => "block_rule",
];
- protected const ARTICLE_COLUMNS = ["id", "url", "title", "author", "fingerprint", "subscription", "published_date", "modified_date", "starred", "unread", "content", "media_url", "media_type"];
+ protected const ARTICLE_COLUMNS = [
+ "id", "url", "title", "author", "fingerprint", "subscription",
+ "published_date", "modified_date",
+ "starred", "unread",
+ "content", "media_url", "media_type"
+ ];
protected const CALLS = [ // handler method Admin Path Body Query Required fields
'/categories' => [
'GET' => ["getCategories", false, false, false, false, []],
@@ -640,7 +645,8 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
$meta = Arsse::$user->propertiesGet(Arsse::$user->id, false);
return [
'num' => $meta['num'],
- 'root' => $meta['root_folder_name'] ?? Arsse::$lang->msg("API.Miniflux.DefaultCategoryName")
+ 'root' => $meta['root_folder_name'] ?? Arsse::$lang->msg("API.Miniflux.DefaultCategoryName"),
+ 'tz' => new \DateTimeZone($meta['tz'] ?? "UTC"),
];
}
@@ -892,8 +898,8 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
]);
}
- protected function getEntries(array $query): ResponseInterface {
- $c = (new Context)
+ protected function computeContext(array $query, Context $c = null): Context {
+ $c = ($c ?? new Context)
->limit($query['limit'])
->offset($query['offset'])
->starred($query['starred'])
@@ -924,25 +930,105 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
} elseif ($status === ["unread"]) {
$c->hidden(false)->unread(true);
}
+ return $c;
+ }
+
+ protected function computeOrder(array $query): array {
$desc = $query['direction'] === "desc" ? " desc" : "";
if ($query['order'] === "id") {
- $order = ["id".$desc];
+ return ["id".$desc];
} elseif ($query['order'] === "status") {
if (!$desc) {
- $order = ["hidden", "unread desc"];
+ return ["hidden", "unread desc"];
} else {
- $order = ["hidden desc", "unread"];
+ return ["hidden desc", "unread"];
}
} elseif ($query['order'] === "published_at") {
- $order = ["modified_date".$desc];
+ return ["modified_date".$desc];
} elseif ($query['order'] === "category_title") {
- $order = ["top_folder_name".$desc];
+ return ["top_folder_name".$desc];
} elseif ($query['order'] === "catgory_id") {
- $order = ["top_folder".$desc];
+ return ["top_folder".$desc];
} else {
- $order = [];
+ return [];
}
- $articles = Arsse::$db->articleList(Arsse::$user->id, $c, self::ARTICLE_COLUMNS, $order);
+ }
+
+ protected function transformEntry(array $entry, int $uid, \DateTimeZone $tz): array {
+ if ($entry['hidden']) {
+ $status = "removed";
+ } elseif ($entry['unread']) {
+ $status = "unread";
+ } else {
+ $status = "read";
+ }
+ if ($entry['media_url']) {
+ $enclosures = [
+ [
+ 'id' => $entry['id'], // NOTE: We don't have IDs for enclosures, but we also only have one enclosure per entry, so we can just re-use the same ID
+ 'user_id' => $uid,
+ 'entry_id' => $entry['id'],
+ 'url' => $entry['media_url'],
+ 'mime_type' => $entry['media_type'] ?: "application/octet-stream",
+ 'size' => 0,
+ ]
+ ];
+ } else {
+ $enclosures = null;
+ }
+ return [
+ 'id' => (int) $entry['id'],
+ 'user_id' => $uid,
+ 'feed_id' => (int) $entry['subscription'],
+ 'status' => $status,
+ 'hash' => $entry['fingerprint'],
+ 'title' => $entry['title'],
+ 'url' => $entry['url'],
+ 'comments_url' => "",
+ 'published_at' => Date::transform(Date::normalize($entry['published_date'], "sql")->setTimezone($tz), "iso8601"),
+ 'created_at' => Date::transform(Date::normalize($entry['modified_date'], "sql")->setTimezone($tz), "iso8601m"),
+ 'content' => $entry['content'],
+ 'author' => (string) $entry['author'],
+ 'share_code' => "",
+ 'starred' => (bool) $entry['starred'],
+ 'reading_time' => 0,
+ 'enclosures' => $enclosures,
+ 'feed' => null,
+ ];
+ }
+
+ protected function getEntries(array $query): ResponseInterface {
+ $c = $this->computeContext($query);
+ $order = $this->computeOrder($query);
+ $tr = Arsse::$db->begin();
+ $meta = $this->userMeta(Arsse::$user->id);
+ // compile the list of entries
+ try {
+ $entries = Arsse::$db->articleList(Arsse::$user->id, $c, self::ARTICLE_COLUMNS, $order);
+ } catch (ExceptionInput $e) {
+ return new ErrorResponse("MissingCategory", 400);
+ }
+ $out = [];
+ foreach ($entries as $entry) {
+ $out[] = $this->transformEntry($entry, $meta['num'], $meta['tz']);
+ }
+ // next compile a map of feeds to add to the entries
+ $feeds = [];
+ foreach (Arsse::$db->subscriptionList(Arsse::$user->id) as $r) {
+ $feeds[(int) $r['id']] = $this->transformFeed($r, $meta['num'], $meta['root']);
+ }
+ // add the feed objects to each entry
+ // NOTE: If ever we implement multiple enclosure, this would be the right place to add them
+ for ($a = 0; $a < sizeof($out); $a++) {
+ $out[$a]['feed'] = $feeds[$out[$a]['feed_id']];
+ }
+ // finally compute the total number of entries match the query, if the query hs a limit or offset
+ if ($c->limit || $c->offset) {
+ $count = Arsse::$db->articleCount(Arsse::$user->id, $c);
+ } else {
+ $count = sizeof($out);
+ }
+ return new Response(['total' => $count, 'entries' => $out]);
}
public static function tokenGenerate(string $user, string $label): string {
From 23ca6bb77b7aeb87c871e112dade1d2539226535 Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Tue, 2 Feb 2021 16:14:04 -0500
Subject: [PATCH 150/366] Count articles without offset or limit
---
lib/REST/Miniflux/V1.php | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/lib/REST/Miniflux/V1.php b/lib/REST/Miniflux/V1.php
index ffd6c363..e7dad341 100644
--- a/lib/REST/Miniflux/V1.php
+++ b/lib/REST/Miniflux/V1.php
@@ -1024,7 +1024,7 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
}
// finally compute the total number of entries match the query, if the query hs a limit or offset
if ($c->limit || $c->offset) {
- $count = Arsse::$db->articleCount(Arsse::$user->id, $c);
+ $count = Arsse::$db->articleCount(Arsse::$user->id, (clone $c)->limit(0)->offset(0));
} else {
$count = sizeof($out);
}
@@ -1032,7 +1032,7 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
}
public static function tokenGenerate(string $user, string $label): string {
- // Miniflux produces tokens in base64url alphabet
+ // Miniflux produces tokenss in base64url alphabet
$t = str_replace(["+", "/"], ["-", "_"], base64_encode(random_bytes(self::TOKEN_LENGTH)));
return Arsse::$db->tokenCreate($user, "miniflux.login", $t, null, $label);
}
From af51377fe9b43d6c1956cea9d7de0fe9121edb12 Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Wed, 3 Feb 2021 13:06:36 -0500
Subject: [PATCH 151/366] First set of article query tests
---
.../030_Supported_Protocols/005_Miniflux.md | 3 +
lib/REST/Miniflux/V1.php | 26 ++-
tests/cases/REST/Miniflux/TestV1.php | 188 +++++++++++-------
3 files changed, 132 insertions(+), 85 deletions(-)
diff --git a/docs/en/030_Supported_Protocols/005_Miniflux.md b/docs/en/030_Supported_Protocols/005_Miniflux.md
index 38ede1c7..62a27bd3 100644
--- a/docs/en/030_Supported_Protocols/005_Miniflux.md
+++ b/docs/en/030_Supported_Protocols/005_Miniflux.md
@@ -26,6 +26,9 @@ Miniflux version 2.0.27 is emulated, though not all features are implemented
- The `disabled`, `ignore_http_cache`, and `fetch_via_proxy` flags
- Changing the URL, username, or password of a feed
- Titles and types are not available during feed discovery and are filled with generic data
+- Reading time is not calculated and will always be zero
+- Only the first enclosure of an article is retained
+- Comment URLs of articles are not exposed
# Differences
diff --git a/lib/REST/Miniflux/V1.php b/lib/REST/Miniflux/V1.php
index e7dad341..b1c16c09 100644
--- a/lib/REST/Miniflux/V1.php
+++ b/lib/REST/Miniflux/V1.php
@@ -110,9 +110,10 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
'blocklist_rules' => "block_rule",
];
protected const ARTICLE_COLUMNS = [
- "id", "url", "title", "author", "fingerprint", "subscription",
+ "id", "url", "title", "subscription",
+ "author", "fingerprint",
"published_date", "modified_date",
- "starred", "unread",
+ "starred", "unread", "hidden",
"content", "media_url", "media_type"
];
protected const CALLS = [ // handler method Admin Path Body Query Required fields
@@ -916,7 +917,8 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
}
}
// FIXME: specifying e.g. ?status=read&status=removed should yield all hidden articles and all read articles, but the best we can do is all read articles which are or are not hidden
- sort($status = array_unique($query['status']));
+ $status = array_unique($query['status']);
+ sort($status);
if ($status === ["read", "removed"]) {
$c->unread(false);
} elseif ($status === ["read", "unread"]) {
@@ -1013,14 +1015,16 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
$out[] = $this->transformEntry($entry, $meta['num'], $meta['tz']);
}
// next compile a map of feeds to add to the entries
- $feeds = [];
- foreach (Arsse::$db->subscriptionList(Arsse::$user->id) as $r) {
- $feeds[(int) $r['id']] = $this->transformFeed($r, $meta['num'], $meta['root']);
- }
- // add the feed objects to each entry
- // NOTE: If ever we implement multiple enclosure, this would be the right place to add them
- for ($a = 0; $a < sizeof($out); $a++) {
- $out[$a]['feed'] = $feeds[$out[$a]['feed_id']];
+ if ($out) {
+ $feeds = [];
+ foreach (Arsse::$db->subscriptionList(Arsse::$user->id) as $r) {
+ $feeds[(int) $r['id']] = $this->transformFeed($r, $meta['num'], $meta['root']);
+ }
+ // add the feed objects to each entry
+ // NOTE: If ever we implement multiple enclosure, this would be the right place to add them
+ for ($a = 0; $a < sizeof($out); $a++) {
+ $out[$a]['feed'] = $feeds[$out[$a]['feed_id']];
+ }
}
// finally compute the total number of entries match the query, if the query hs a limit or offset
if ($c->limit || $c->offset) {
diff --git a/tests/cases/REST/Miniflux/TestV1.php b/tests/cases/REST/Miniflux/TestV1.php
index 818bffc1..899ab9b6 100644
--- a/tests/cases/REST/Miniflux/TestV1.php
+++ b/tests/cases/REST/Miniflux/TestV1.php
@@ -26,54 +26,61 @@ use JKingWeb\Arsse\Test\Result;
/** @covers \JKingWeb\Arsse\REST\Miniflux\V1 */
class TestV1 extends \JKingWeb\Arsse\Test\AbstractTest {
protected const NOW = "2020-12-09T22:35:10.023419Z";
-
- protected $h;
- protected $transaction;
- protected $token = "Tk2o9YubmZIL2fm2w8Z4KlDEQJz532fNSOcTG0s2_xc=";
- protected $users = [
- [
- 'id' => 1,
- 'username' => "john.doe@example.com",
- 'last_login_at' => self::NOW,
- 'google_id' => "",
- 'openid_connect_id' => "",
- 'is_admin' => true,
- 'theme' => "custom",
- 'language' => "fr_CA",
- 'timezone' => "Asia/Gaza",
- 'entry_sorting_direction' => "asc",
- 'entries_per_page' => 200,
- 'keyboard_shortcuts' => false,
- 'show_reading_time' => false,
- 'entry_swipe' => false,
- 'stylesheet' => "p {}",
- ],
- [
- 'id' => 2,
- 'username' => "jane.doe@example.com",
- 'last_login_at' => self::NOW,
- 'google_id' => "",
- 'openid_connect_id' => "",
- 'is_admin' => false,
- 'theme' => "light_serif",
- 'language' => "en_US",
- 'timezone' => "UTC",
- 'entry_sorting_direction' => "desc",
- 'entries_per_page' => 100,
- 'keyboard_shortcuts' => true,
- 'show_reading_time' => true,
- 'entry_swipe' => true,
- 'stylesheet' => "",
- ],
+ protected const TOKEN = "Tk2o9YubmZIL2fm2w8Z4KlDEQJz532fNSOcTG0s2_xc=";
+ protected const USERS = [
+ ['id' => 1, 'username' => "john.doe@example.com", 'last_login_at' => self::NOW, 'google_id' => "", 'openid_connect_id' => "", 'is_admin' => true, 'theme' => "custom", 'language' => "fr_CA", 'timezone' => "Asia/Gaza", 'entry_sorting_direction' => "asc", 'entries_per_page' => 200, 'keyboard_shortcuts' => false, 'show_reading_time' => false, 'entry_swipe' => false, 'stylesheet' => "p {}"],
+ ['id' => 2, 'username' => "jane.doe@example.com", 'last_login_at' => self::NOW, 'google_id' => "", 'openid_connect_id' => "", 'is_admin' => false, 'theme' => "light_serif", 'language' => "en_US", 'timezone' => "UTC", 'entry_sorting_direction' => "desc", 'entries_per_page' => 100, 'keyboard_shortcuts' => true, 'show_reading_time' => true, 'entry_swipe' => true, 'stylesheet' => ""],
];
- protected $feeds = [
+ protected const FEEDS = [
['id' => 1, 'feed' => 12, 'url' => "http://example.com/ook", 'title' => "Ook", 'source' => "http://example.com/", 'icon_id' => 47, 'icon_url' => "http://example.com/icon", 'folder' => 2112, 'top_folder' => 5, 'folder_name' => "Cat Eek", 'top_folder_name' => "Cat Ook", 'pinned' => 0, 'err_count' => 1, 'err_msg' => "Oopsie", 'order_type' => 0, 'keep_rule' => "this|that", 'block_rule' => "both", 'added' => "2020-12-21 21:12:00", 'updated' => "2021-01-05 13:51:32", 'edited' => "2021-01-01 00:00:00", 'modified' => "2020-11-30 04:08:52", 'next_fetch' => "2021-01-20 00:00:00", 'etag' => "OOKEEK", 'scrape' => 0, 'unread' => 42],
['id' => 55, 'feed' => 12, 'url' => "http://j%20k:super%20secret@example.com/eek", 'title' => "Eek", 'source' => "http://example.com/", 'icon_id' => null, 'icon_url' => null, 'folder' => null, 'top_folder' => null, 'folder_name' => null, 'top_folder_name' => null, 'pinned' => 0, 'err_count' => 0, 'err_msg' => null, 'order_type' => 0, 'keep_rule' => null, 'block_rule' => null, 'added' => "2020-12-21 21:12:00", 'updated' => "2021-01-05 13:51:32", 'edited' => null, 'modified' => "2020-11-30 04:08:52", 'next_fetch' => null, 'etag' => null, 'scrape' => 1, 'unread' => 0],
];
- protected $feedsOut = [
+ protected const FEEDS_OUT = [
['id' => 1, 'user_id' => 42, 'feed_url' => "http://example.com/ook", 'site_url' => "http://example.com/", 'title' => "Ook", 'checked_at' => "2021-01-05T13:51:32.000000Z", 'next_check_at' => "2021-01-20T00:00:00.000000Z", 'etag_header' => "OOKEEK", 'last_modified_header' => "Fri, 01 Jan 2021 00:00:00 GMT", 'parsing_error_message' => "Oopsie", 'parsing_error_count' => 1, 'scraper_rules' => "", 'rewrite_rules' => "", 'crawler' => false, 'blocklist_rules' => "both", 'keeplist_rules' => "this|that", 'user_agent' => "", 'username' => "", 'password' => "", 'disabled' => false, 'ignore_http_cache' => false, 'fetch_via_proxy' => false, 'category' => ['id' => 6, 'title' => "Cat Ook", 'user_id' => 42], 'icon' => ['feed_id' => 1,'icon_id' => 47]],
['id' => 55, 'user_id' => 42, 'feed_url' => "http://example.com/eek", 'site_url' => "http://example.com/", 'title' => "Eek", 'checked_at' => "2021-01-05T13:51:32.000000Z", 'next_check_at' => "0001-01-01T00:00:00.000000Z", 'etag_header' => "", 'last_modified_header' => "", 'parsing_error_message' => "", 'parsing_error_count' => 0, 'scraper_rules' => "", 'rewrite_rules' => "", 'crawler' => true, 'blocklist_rules' => "", 'keeplist_rules' => "", 'user_agent' => "", 'username' => "j k", 'password' => "super secret", 'disabled' => false, 'ignore_http_cache' => false, 'fetch_via_proxy' => false, 'category' => ['id' => 1,'title' => "All", 'user_id' => 42], 'icon' => null],
];
+ protected const ENTRIES = [
+ [
+ 'id' => 42,
+ 'url' => "http://example.com/42",
+ 'title' => "Title 42",
+ 'subscription' => 2112,
+ 'author' => "Thomas Costain",
+ 'fingerprint' => "FINGERPRINT",
+ 'published_date' => "2021-01-22 02:21:12",
+ 'modified_date' => "2021-01-22 13:44:47",
+ 'starred' => 0,
+ 'unread' => 0,
+ 'hidden' => 0,
+ 'content' => "Content 42",
+ 'media_url' => null,
+ 'media_type' => null,
+ ],
+ ];
+ protected const ENTRIES_OUT = [
+ [
+ 'id' => 42,
+ 'user_id' => 42,
+ 'feed_id' => 55,
+ 'status' => "read",
+ 'hash' => "FINGERPRINT",
+ 'title' => "Title 42",
+ 'url' => "http://example.com/42",
+ 'comments_url' => "",
+ 'published_at' => "2021-01-22T02:21:12+00:00",
+ 'created_at' => "2021-01-22T13:44:47.000000+00:00",
+ 'content' => "Content 42",
+ 'author' => "Thomas Costain",
+ 'share_code' => "",
+ 'starred' => false,
+ 'reading_time' => 0,
+ 'enclosures' => null,
+ 'feed' => self::FEEDS_OUT[1],
+ ],
+ ];
+
+ protected $h;
+ protected $transaction;
protected function req(string $method, string $target, $data = "", array $headers = [], ?string $user = "john.doe@example.com", bool $body = true): ResponseInterface {
$prefix = "/v1";
@@ -122,23 +129,23 @@ class TestV1 extends \JKingWeb\Arsse\Test\AbstractTest {
}
Arsse::$user->id = null;
\Phake::when(Arsse::$db)->tokenLookup->thenThrow(new ExceptionInput("subjectMissing"));
- \Phake::when(Arsse::$db)->tokenLookup("miniflux.login", $this->token)->thenReturn(['user' => $user]);
+ \Phake::when(Arsse::$db)->tokenLookup("miniflux.login", self::TOKEN)->thenReturn(['user' => $user]);
$this->assertMessage($exp, $this->req("GET", "/", "", $headers, $auth ? "john.doe@example.com" : null));
$this->assertSame($success ? $user : null, Arsse::$user->id);
}
public function provideAuthResponses(): iterable {
return [
- [null, false, false],
- [null, true, true],
- [$this->token, false, true],
- [[$this->token, "BOGUS"], false, true],
- ["", true, true],
- [["", "BOGUS"], true, true],
- ["NOT A TOKEN", false, false],
- ["NOT A TOKEN", true, false],
- [["BOGUS", $this->token], false, false],
- [["", $this->token], false, false],
+ [null, false, false],
+ [null, true, true],
+ [self::TOKEN, false, true],
+ [[self::TOKEN, "BOGUS"], false, true],
+ ["", true, true],
+ [["", "BOGUS"], true, true],
+ ["NOT A TOKEN", false, false],
+ ["NOT A TOKEN", true, false],
+ [["BOGUS", self::TOKEN], false, false],
+ [["", self::TOKEN], false, false],
];
}
@@ -239,16 +246,16 @@ class TestV1 extends \JKingWeb\Arsse\Test\AbstractTest {
public function provideUserQueries(): iterable {
self::clearData();
return [
- [true, "/users", new Response($this->users)],
- [true, "/me", new Response($this->users[0])],
- [true, "/users/john.doe@example.com", new Response($this->users[0])],
- [true, "/users/1", new Response($this->users[0])],
- [true, "/users/jane.doe@example.com", new Response($this->users[1])],
- [true, "/users/2", new Response($this->users[1])],
+ [true, "/users", new Response(self::USERS)],
+ [true, "/me", new Response(self::USERS[0])],
+ [true, "/users/john.doe@example.com", new Response(self::USERS[0])],
+ [true, "/users/1", new Response(self::USERS[0])],
+ [true, "/users/jane.doe@example.com", new Response(self::USERS[1])],
+ [true, "/users/2", new Response(self::USERS[1])],
[true, "/users/jack.doe@example.com", new ErrorResponse("404", 404)],
[true, "/users/47", new ErrorResponse("404", 404)],
[false, "/users", new ErrorResponse("403", 403)],
- [false, "/me", new Response($this->users[1])],
+ [false, "/me", new Response(self::USERS[1])],
[false, "/users/john.doe@example.com", new ErrorResponse("403", 403)],
[false, "/users/1", new ErrorResponse("403", 403)],
[false, "/users/jane.doe@example.com", new ErrorResponse("403", 403)],
@@ -318,8 +325,8 @@ class TestV1 extends \JKingWeb\Arsse\Test\AbstractTest {
public function provideUserModifications(): iterable {
$out1 = ['num' => 2, 'admin' => false];
$out2 = ['num' => 1, 'admin' => false];
- $resp1 = array_merge($this->users[1], ['username' => "john.doe@example.com"]);
- $resp2 = array_merge($this->users[1], ['id' => 1, 'is_admin' => true]);
+ $resp1 = array_merge(self::USERS[1], ['username' => "john.doe@example.com"]);
+ $resp2 = array_merge(self::USERS[1], ['id' => 1, 'is_admin' => true]);
return [
[false, "/users/1", ['is_admin' => 0], null, null, null, null, null, null, new ErrorResponse(["InvalidInputType", 'field' => "is_admin", 'expected' => "boolean", 'actual' => "integer"], 422)],
[false, "/users/1", ['entry_sorting_direction' => "bad"], null, null, null, null, null, null, new ErrorResponse(["InvalidInputValue", 'field' => "entry_sorting_direction"], 422)],
@@ -376,7 +383,7 @@ class TestV1 extends \JKingWeb\Arsse\Test\AbstractTest {
}
public function provideUserAdditions(): iterable {
- $resp1 = array_merge($this->users[1], ['username' => "ook", 'password' => "eek"]);
+ $resp1 = array_merge(self::USERS[1], ['username' => "ook", 'password' => "eek"]);
return [
[[], null, null, null, null, new ErrorResponse(["MissingInputValue", 'field' => "username"], 422)],
[['username' => "ook"], null, null, null, null, new ErrorResponse(["MissingInputValue", 'field' => "password"], 422)],
@@ -545,21 +552,21 @@ class TestV1 extends \JKingWeb\Arsse\Test\AbstractTest {
}
public function testListFeeds(): void {
- \Phake::when(Arsse::$db)->subscriptionList->thenReturn(new Result($this->v($this->feeds)));
- $exp = new Response($this->feedsOut);
+ \Phake::when(Arsse::$db)->subscriptionList->thenReturn(new Result($this->v(self::FEEDS)));
+ $exp = new Response(self::FEEDS_OUT);
$this->assertMessage($exp, $this->req("GET", "/feeds"));
}
public function testListFeedsOfACategory(): void {
- \Phake::when(Arsse::$db)->subscriptionList->thenReturn(new Result($this->v($this->feeds)));
- $exp = new Response($this->feedsOut);
+ \Phake::when(Arsse::$db)->subscriptionList->thenReturn(new Result($this->v(self::FEEDS)));
+ $exp = new Response(self::FEEDS_OUT);
$this->assertMessage($exp, $this->req("GET", "/categories/2112/feeds"));
\Phake::verify(Arsse::$db)->subscriptionList(Arsse::$user->id, 2111, true);
}
public function testListFeedsOfTheRootCategory(): void {
- \Phake::when(Arsse::$db)->subscriptionList->thenReturn(new Result($this->v($this->feeds)));
- $exp = new Response($this->feedsOut);
+ \Phake::when(Arsse::$db)->subscriptionList->thenReturn(new Result($this->v(self::FEEDS)));
+ $exp = new Response(self::FEEDS_OUT);
$this->assertMessage($exp, $this->req("GET", "/categories/1/feeds"));
\Phake::verify(Arsse::$db)->subscriptionList(Arsse::$user->id, 0, false);
}
@@ -572,10 +579,10 @@ class TestV1 extends \JKingWeb\Arsse\Test\AbstractTest {
}
public function testGetAFeed(): void {
- \Phake::when(Arsse::$db)->subscriptionPropertiesGet->thenReturn($this->v($this->feeds[0]))->thenReturn($this->v($this->feeds[1]));
- $this->assertMessage(new Response($this->feedsOut[0]), $this->req("GET", "/feeds/1"));
+ \Phake::when(Arsse::$db)->subscriptionPropertiesGet->thenReturn($this->v(self::FEEDS[0]))->thenReturn($this->v(self::FEEDS[1]));
+ $this->assertMessage(new Response(self::FEEDS_OUT[0]), $this->req("GET", "/feeds/1"));
\Phake::verify(Arsse::$db)->subscriptionPropertiesGet(Arsse::$user->id, 1);
- $this->assertMessage(new Response($this->feedsOut[1]), $this->req("GET", "/feeds/55"));
+ $this->assertMessage(new Response(self::FEEDS_OUT[1]), $this->req("GET", "/feeds/55"));
\Phake::verify(Arsse::$db)->subscriptionPropertiesGet(Arsse::$user->id, 55);
}
@@ -679,7 +686,7 @@ class TestV1 extends \JKingWeb\Arsse\Test\AbstractTest {
/** @dataProvider provideFeedModifications */
public function testModifyAFeed(array $in, array $data, $out, ResponseInterface $exp): void {
$this->h = \Phake::partialMock(V1::class);
- \Phake::when($this->h)->getFeed->thenReturn(new Response($this->feedsOut[0]));
+ \Phake::when($this->h)->getFeed->thenReturn(new Response(self::FEEDS_OUT[0]));
if ($out instanceof \Exception) {
\Phake::when(Arsse::$db)->subscriptionPropertiesSet->thenThrow($out);
} else {
@@ -691,7 +698,7 @@ class TestV1 extends \JKingWeb\Arsse\Test\AbstractTest {
public function provideFeedModifications(): iterable {
self::clearData();
- $success = new Response($this->feedsOut[0]);
+ $success = new Response(self::FEEDS_OUT[0]);
return [
[[], [], true, $success],
[[], [], new ExceptionInput("subjectMissing"), new ErrorResponse("404", 404)],
@@ -730,7 +737,6 @@ class TestV1 extends \JKingWeb\Arsse\Test\AbstractTest {
}
public function provideIcons(): iterable {
- self::clearData();
return [
[['id' => 44, 'type' => "image/svg+xml", 'data' => " "], new Response(['id' => 44, 'data' => "image/svg+xml;base64,PHN2Zy8+", 'mime_type' => "image/svg+xml"])],
[['id' => 47, 'type' => "", 'data' => " "], new ErrorResponse("404", 404)],
@@ -740,4 +746,38 @@ class TestV1 extends \JKingWeb\Arsse\Test\AbstractTest {
[new ExceptionInput("subjectMissing"), new ErrorResponse("404", 404)],
];
}
+
+ /** @dataProvider provideEntryQueries */
+ public function testGetEntries(string $url, ?Context $c, ?array $order, $out, bool $getFeeds, ResponseInterface $exp) {
+ if ($out instanceof \Exception) {
+ \Phake::when(Arsse::$db)->articleList->thenThrow($out);
+ } else {
+ \Phake::when(Arsse::$db)->articleList->thenReturn(new Result($this->v($out)));
+ }
+ $this->assertMessage($exp, $this->req("GET", $url));
+ if ($c) {
+ \Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, $c, array_keys(self::ENTRIES[0]), $order);
+ } else {
+ \Phake::verify(Arsse::$db, \Phake::times(0))->articleList;
+ }
+ }
+
+ public function provideEntryQueries(): iterable {
+ self::clearData();
+ $c = new Context;
+ return [
+ ["/entries?after=A", null, null, [], false, new ErrorResponse(["InvalidInputValue", 'field' => "after"], 400)],
+ ["/entries?before=B", null, null, [], false, new ErrorResponse(["InvalidInputValue", 'field' => "before"], 400)],
+ ["/entries?category_id=0", null, null, [], false, new ErrorResponse(["InvalidInputValue", 'field' => "category_id"], 400)],
+ ["/entries?after_entry_id=0", null, null, [], false, new ErrorResponse(["InvalidInputValue", 'field' => "after_entry_id"], 400)],
+ ["/entries?before_entry_id=0", null, null, [], false, new ErrorResponse(["InvalidInputValue", 'field' => "before_entry_id"], 400)],
+ ["/entries?limit=-1", null, null, [], false, new ErrorResponse(["InvalidInputValue", 'field' => "limit"], 400)],
+ ["/entries?offset=-1", null, null, [], false, new ErrorResponse(["InvalidInputValue", 'field' => "offset"], 400)],
+ ["/entries?direction=sideways", null, null, [], false, new ErrorResponse(["InvalidInputValue", 'field' => "direction"], 400)],
+ ["/entries?order=false", null, null, [], false, new ErrorResponse(["InvalidInputValue", 'field' => "order"], 400)],
+ ["/entries?starred&starred", null, null, [], false, new ErrorResponse(["DuplicateInputValue", 'field' => "starred"], 400)],
+ ["/entries?after&after=0", null, null, [], false, new ErrorResponse(["DuplicateInputValue", 'field' => "after"], 400)],
+ ["/entries", $c, [], [], false, new Response(['total' => 0, 'entries' => []])],
+ ];
+ }
}
From f7b3a473a995c5f5412fff4658f234ea5e8c9bd7 Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Wed, 3 Feb 2021 14:20:34 -0500
Subject: [PATCH 152/366] Clarify ordering syntax rationale
---
lib/Db/Driver.php | 4 ++--
tests/cases/Db/BaseDriver.php | 4 ++--
2 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/lib/Db/Driver.php b/lib/Db/Driver.php
index cc522dc7..09f16e78 100644
--- a/lib/Db/Driver.php
+++ b/lib/Db/Driver.php
@@ -75,8 +75,8 @@ interface Driver {
* - "nocase": the name of a general-purpose case-insensitive collation sequence
* - "like": the case-insensitive LIKE operator
* - "integer": the integer type to use for explicit casts
- * - "asc": ascending sort order
- * - "desc": descending sort order
+ * - "asc": ascending sort order when dealing with nulls
+ * - "desc": descending sort order when dealing with nulls
*/
public function sqlToken(string $token): string;
diff --git a/tests/cases/Db/BaseDriver.php b/tests/cases/Db/BaseDriver.php
index f47bcf06..e34cf65f 100644
--- a/tests/cases/Db/BaseDriver.php
+++ b/tests/cases/Db/BaseDriver.php
@@ -394,7 +394,7 @@ abstract class BaseDriver extends \JKingWeb\Arsse\Test\AbstractTest {
$this->assertSame("Z", $this->drv->query("SELECT 'Z' collate $nocase")->getValue());
$this->assertSame("Z", $this->drv->query("SELECT 'Z' where 'Z' $like 'z'")->getValue());
$this->assertEquals(1, $this->drv->query("SELECT CAST((1=1) as $integer)")->getValue());
- $this->assertEquals([null, 1], array_column($this->drv->query("SELECT 1 as t union select null as t order by t $asc")->getAll(), "t"));
- $this->assertEquals([1, null], array_column($this->drv->query("SELECT 1 as t union select null as t order by t $desc")->getAll(), "t"));
+ $this->assertEquals([null, 1, 2], array_column($this->drv->query("SELECT 1 as t union select null as t union select 2 as t order by t $asc")->getAll(), "t"));
+ $this->assertEquals([2, 1, null], array_column($this->drv->query("SELECT 1 as t union select null as t union select 2 as t order by t $desc")->getAll(), "t"));
}
}
From e42e25d333b5eff16187260e5a325da518de046d Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Wed, 3 Feb 2021 16:27:55 -0500
Subject: [PATCH 153/366] More article query tests
---
lib/REST/Miniflux/V1.php | 20 +++---
tests/cases/REST/Miniflux/TestV1.php | 96 +++++++++++++---------------
2 files changed, 56 insertions(+), 60 deletions(-)
diff --git a/lib/REST/Miniflux/V1.php b/lib/REST/Miniflux/V1.php
index b1c16c09..3e8674f3 100644
--- a/lib/REST/Miniflux/V1.php
+++ b/lib/REST/Miniflux/V1.php
@@ -33,6 +33,8 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
protected const ACCEPTED_TYPES_OPML = ["application/xml", "text/xml", "text/x-opml"];
protected const ACCEPTED_TYPES_JSON = ["application/json"];
protected const TOKEN_LENGTH = 32;
+ protected const DATE_FORMAT_SEC = "Y-m-d\TH:i:sP";
+ protected const DATE_FORMAT_MICRO = "Y-m-d\TH:i:s.uP";
protected const VALID_QUERY = [
'status' => V::T_STRING + V::M_ARRAY,
'offset' => V::T_INT,
@@ -742,7 +744,7 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
return new EmptyResponse(204);
}
- protected function transformFeed(array $sub, int $uid, string $rootName): array {
+ protected function transformFeed(array $sub, int $uid, string $rootName, \DateTimeZone $tz): array {
$url = new Uri($sub['url']);
return [
'id' => (int) $sub['id'],
@@ -750,8 +752,8 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
'feed_url' => (string) $url->withUserInfo(""),
'site_url' => (string) $sub['source'],
'title' => (string) $sub['title'],
- 'checked_at' => Date::transform($sub['updated'], "iso8601m", "sql"),
- 'next_check_at' => Date::transform($sub['next_fetch'], "iso8601m", "sql") ?? "0001-01-01T00:00:00.000000Z",
+ 'checked_at' => Date::normalize($sub['updated'], "sql")->setTimezone($tz)->format(self::DATE_FORMAT_MICRO),
+ 'next_check_at' => $sub['next_fetch'] ? Date::normalize($sub['next_fetch'], "sql")->setTimezone($tz)->format(self::DATE_FORMAT_MICRO) : "0001-01-01T00:00:00Z",
'etag_header' => (string) $sub['etag'],
'last_modified_header' => (string) Date::transform($sub['edited'], "http", "sql"),
'parsing_error_message' => (string) $sub['err_msg'],
@@ -781,7 +783,7 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
$tr = Arsse::$db->begin();
$meta = $this->userMeta(Arsse::$user->id);
foreach (Arsse::$db->subscriptionList(Arsse::$user->id) as $r) {
- $out[] = $this->transformFeed($r, $meta['num'], $meta['root']);
+ $out[] = $this->transformFeed($r, $meta['num'], $meta['root'], $meta['tz']);
}
return new Response($out);
}
@@ -797,7 +799,7 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
try {
$meta = $this->userMeta(Arsse::$user->id);
foreach (Arsse::$db->subscriptionList(Arsse::$user->id, $folder, $recursive) as $r) {
- $out[] = $this->transformFeed($r, $meta['num'], $meta['root']);
+ $out[] = $this->transformFeed($r, $meta['num'], $meta['root'], $meta['tz']);
}
} catch (ExceptionInput $e) {
// the folder does not exist
@@ -811,7 +813,7 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
$meta = $this->userMeta(Arsse::$user->id);
try {
$sub = Arsse::$db->subscriptionPropertiesGet(Arsse::$user->id, (int) $path[1]);
- return new Response($this->transformFeed($sub, $meta['num'], $meta['root']));
+ return new Response($this->transformFeed($sub, $meta['num'], $meta['root'], $meta['tz']));
} catch (ExceptionInput $e) {
return new ErrorResponse("404", 404);
}
@@ -987,8 +989,8 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
'title' => $entry['title'],
'url' => $entry['url'],
'comments_url' => "",
- 'published_at' => Date::transform(Date::normalize($entry['published_date'], "sql")->setTimezone($tz), "iso8601"),
- 'created_at' => Date::transform(Date::normalize($entry['modified_date'], "sql")->setTimezone($tz), "iso8601m"),
+ 'published_at' => Date::normalize($entry['published_date'], "sql")->setTimezone($tz)->format(self::DATE_FORMAT_SEC),
+ 'created_at' => Date::normalize($entry['modified_date'], "sql")->setTimezone($tz)->format(self::DATE_FORMAT_MICRO),
'content' => $entry['content'],
'author' => (string) $entry['author'],
'share_code' => "",
@@ -1018,7 +1020,7 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
if ($out) {
$feeds = [];
foreach (Arsse::$db->subscriptionList(Arsse::$user->id) as $r) {
- $feeds[(int) $r['id']] = $this->transformFeed($r, $meta['num'], $meta['root']);
+ $feeds[(int) $r['id']] = $this->transformFeed($r, $meta['num'], $meta['root'], $meta['tz']);
}
// add the feed objects to each entry
// NOTE: If ever we implement multiple enclosure, this would be the right place to add them
diff --git a/tests/cases/REST/Miniflux/TestV1.php b/tests/cases/REST/Miniflux/TestV1.php
index 899ab9b6..395e080c 100644
--- a/tests/cases/REST/Miniflux/TestV1.php
+++ b/tests/cases/REST/Miniflux/TestV1.php
@@ -36,47 +36,20 @@ class TestV1 extends \JKingWeb\Arsse\Test\AbstractTest {
['id' => 55, 'feed' => 12, 'url' => "http://j%20k:super%20secret@example.com/eek", 'title' => "Eek", 'source' => "http://example.com/", 'icon_id' => null, 'icon_url' => null, 'folder' => null, 'top_folder' => null, 'folder_name' => null, 'top_folder_name' => null, 'pinned' => 0, 'err_count' => 0, 'err_msg' => null, 'order_type' => 0, 'keep_rule' => null, 'block_rule' => null, 'added' => "2020-12-21 21:12:00", 'updated' => "2021-01-05 13:51:32", 'edited' => null, 'modified' => "2020-11-30 04:08:52", 'next_fetch' => null, 'etag' => null, 'scrape' => 1, 'unread' => 0],
];
protected const FEEDS_OUT = [
- ['id' => 1, 'user_id' => 42, 'feed_url' => "http://example.com/ook", 'site_url' => "http://example.com/", 'title' => "Ook", 'checked_at' => "2021-01-05T13:51:32.000000Z", 'next_check_at' => "2021-01-20T00:00:00.000000Z", 'etag_header' => "OOKEEK", 'last_modified_header' => "Fri, 01 Jan 2021 00:00:00 GMT", 'parsing_error_message' => "Oopsie", 'parsing_error_count' => 1, 'scraper_rules' => "", 'rewrite_rules' => "", 'crawler' => false, 'blocklist_rules' => "both", 'keeplist_rules' => "this|that", 'user_agent' => "", 'username' => "", 'password' => "", 'disabled' => false, 'ignore_http_cache' => false, 'fetch_via_proxy' => false, 'category' => ['id' => 6, 'title' => "Cat Ook", 'user_id' => 42], 'icon' => ['feed_id' => 1,'icon_id' => 47]],
- ['id' => 55, 'user_id' => 42, 'feed_url' => "http://example.com/eek", 'site_url' => "http://example.com/", 'title' => "Eek", 'checked_at' => "2021-01-05T13:51:32.000000Z", 'next_check_at' => "0001-01-01T00:00:00.000000Z", 'etag_header' => "", 'last_modified_header' => "", 'parsing_error_message' => "", 'parsing_error_count' => 0, 'scraper_rules' => "", 'rewrite_rules' => "", 'crawler' => true, 'blocklist_rules' => "", 'keeplist_rules' => "", 'user_agent' => "", 'username' => "j k", 'password' => "super secret", 'disabled' => false, 'ignore_http_cache' => false, 'fetch_via_proxy' => false, 'category' => ['id' => 1,'title' => "All", 'user_id' => 42], 'icon' => null],
+ ['id' => 1, 'user_id' => 42, 'feed_url' => "http://example.com/ook", 'site_url' => "http://example.com/", 'title' => "Ook", 'checked_at' => "2021-01-05T15:51:32.000000+02:00", 'next_check_at' => "2021-01-20T02:00:00.000000+02:00", 'etag_header' => "OOKEEK", 'last_modified_header' => "Fri, 01 Jan 2021 00:00:00 GMT", 'parsing_error_message' => "Oopsie", 'parsing_error_count' => 1, 'scraper_rules' => "", 'rewrite_rules' => "", 'crawler' => false, 'blocklist_rules' => "both", 'keeplist_rules' => "this|that", 'user_agent' => "", 'username' => "", 'password' => "", 'disabled' => false, 'ignore_http_cache' => false, 'fetch_via_proxy' => false, 'category' => ['id' => 6, 'title' => "Cat Ook", 'user_id' => 42], 'icon' => ['feed_id' => 1,'icon_id' => 47]],
+ ['id' => 55, 'user_id' => 42, 'feed_url' => "http://example.com/eek", 'site_url' => "http://example.com/", 'title' => "Eek", 'checked_at' => "2021-01-05T15:51:32.000000+02:00", 'next_check_at' => "0001-01-01T00:00:00Z", 'etag_header' => "", 'last_modified_header' => "", 'parsing_error_message' => "", 'parsing_error_count' => 0, 'scraper_rules' => "", 'rewrite_rules' => "", 'crawler' => true, 'blocklist_rules' => "", 'keeplist_rules' => "", 'user_agent' => "", 'username' => "j k", 'password' => "super secret", 'disabled' => false, 'ignore_http_cache' => false, 'fetch_via_proxy' => false, 'category' => ['id' => 1,'title' => "All", 'user_id' => 42], 'icon' => null],
];
protected const ENTRIES = [
- [
- 'id' => 42,
- 'url' => "http://example.com/42",
- 'title' => "Title 42",
- 'subscription' => 2112,
- 'author' => "Thomas Costain",
- 'fingerprint' => "FINGERPRINT",
- 'published_date' => "2021-01-22 02:21:12",
- 'modified_date' => "2021-01-22 13:44:47",
- 'starred' => 0,
- 'unread' => 0,
- 'hidden' => 0,
- 'content' => "Content 42",
- 'media_url' => null,
- 'media_type' => null,
- ],
+ ['id' => 42, 'url' => "http://example.com/42", 'title' => "Title 42", 'subscription' => 55, 'author' => "Thomas Costain", 'fingerprint' => "FINGERPRINT", 'published_date' => "2021-01-22 02:21:12", 'modified_date' => "2021-01-22 13:44:47", 'starred' => 0, 'unread' => 0, 'hidden' => 0, 'content' => "Content 42", 'media_url' => null, 'media_type' => null],
+ ['id' => 44, 'url' => "http://example.com/44", 'title' => "Title 44", 'subscription' => 55, 'author' => null, 'fingerprint' => "FINGERPRINT", 'published_date' => "2021-01-22 02:21:12", 'modified_date' => "2021-01-22 13:44:47", 'starred' => 1, 'unread' => 1, 'hidden' => 0, 'content' => "Content 44", 'media_url' => "http://example.com/44/enclosure", 'media_type' => null],
+ ['id' => 47, 'url' => "http://example.com/47", 'title' => "Title 47", 'subscription' => 55, 'author' => null, 'fingerprint' => "FINGERPRINT", 'published_date' => "2021-01-22 02:21:12", 'modified_date' => "2021-01-22 13:44:47", 'starred' => 0, 'unread' => 1, 'hidden' => 1, 'content' => "Content 47", 'media_url' => "http://example.com/47/enclosure", 'media_type' => ""],
+ ['id' => 2112, 'url' => "http://example.com/2112", 'title' => "Title 2112", 'subscription' => 55, 'author' => null, 'fingerprint' => "FINGERPRINT", 'published_date' => "2021-01-22 02:21:12", 'modified_date' => "2021-01-22 13:44:47", 'starred' => 0, 'unread' => 0, 'hidden' => 1, 'content' => "Content 2112", 'media_url' => "http://example.com/2112/enclosure", 'media_type' => "image/png"]
];
protected const ENTRIES_OUT = [
- [
- 'id' => 42,
- 'user_id' => 42,
- 'feed_id' => 55,
- 'status' => "read",
- 'hash' => "FINGERPRINT",
- 'title' => "Title 42",
- 'url' => "http://example.com/42",
- 'comments_url' => "",
- 'published_at' => "2021-01-22T02:21:12+00:00",
- 'created_at' => "2021-01-22T13:44:47.000000+00:00",
- 'content' => "Content 42",
- 'author' => "Thomas Costain",
- 'share_code' => "",
- 'starred' => false,
- 'reading_time' => 0,
- 'enclosures' => null,
- 'feed' => self::FEEDS_OUT[1],
- ],
+ ['id' => 42, 'user_id' => 42, 'feed_id' => 55, 'status' => "read", 'hash' => "FINGERPRINT", 'title' => "Title 42", 'url' => "http://example.com/42", 'comments_url' => "", 'published_at' => "2021-01-22T04:21:12+02:00", 'created_at' => "2021-01-22T15:44:47.000000+02:00", 'content' => "Content 42", 'author' => "Thomas Costain", 'share_code' => "", 'starred' => false, 'reading_time' => 0, 'enclosures' => null, 'feed' => self::FEEDS_OUT[1]],
+ ['id' => 44, 'user_id' => 42, 'feed_id' => 55, 'status' => "unread", 'hash' => "FINGERPRINT", 'title' => "Title 44", 'url' => "http://example.com/44", 'comments_url' => "", 'published_at' => "2021-01-22T04:21:12+02:00", 'created_at' => "2021-01-22T15:44:47.000000+02:00", 'content' => "Content 44", 'author' => "", 'share_code' => "", 'starred' => true, 'reading_time' => 0, 'enclosures' => [['id' => 44, 'user_id' => 42, 'entry_id' => 44, 'url' => "http://example.com/44/enclosure", 'mime_type' => "application/octet-stream", 'size' => 0]], 'feed' => self::FEEDS_OUT[1]],
+ ['id' => 47, 'user_id' => 42, 'feed_id' => 55, 'status' => "removed", 'hash' => "FINGERPRINT", 'title' => "Title 47", 'url' => "http://example.com/47", 'comments_url' => "", 'published_at' => "2021-01-22T04:21:12+02:00", 'created_at' => "2021-01-22T15:44:47.000000+02:00", 'content' => "Content 47", 'author' => "", 'share_code' => "", 'starred' => false, 'reading_time' => 0, 'enclosures' => [['id' => 47, 'user_id' => 42, 'entry_id' => 47, 'url' => "http://example.com/47/enclosure", 'mime_type' => "application/octet-stream", 'size' => 0]], 'feed' => self::FEEDS_OUT[1]],
+ ['id' => 2112, 'user_id' => 42, 'feed_id' => 55, 'status' => "removed", 'hash' => "FINGERPRINT", 'title' => "Title 2112", 'url' => "http://example.com/2112", 'comments_url' => "", 'published_at' => "2021-01-22T04:21:12+02:00", 'created_at' => "2021-01-22T15:44:47.000000+02:00", 'content' => "Content 2112", 'author' => "", 'share_code' => "", 'starred' => false, 'reading_time' => 0, 'enclosures' => [['id' => 2112, 'user_id' => 42, 'entry_id' => 2112, 'url' => "http://example.com/2112/enclosure", 'mime_type' => "image/png", 'size' => 0]], 'feed' => self::FEEDS_OUT[1]],
];
protected $h;
@@ -104,7 +77,7 @@ class TestV1 extends \JKingWeb\Arsse\Test\AbstractTest {
\Phake::when(Arsse::$db)->begin->thenReturn($this->transaction);
// create a mock user manager; we use a PHPUnitmock because Phake for reasons unknown is unable to mock the User class correctly, sometimes
Arsse::$user = $this->createMock(User::class);
- Arsse::$user->method("propertiesGet")->willReturn(['num' => 42, 'admin' => false, 'root_folder_name' => null]);
+ Arsse::$user->method("propertiesGet")->willReturn(['num' => 42, 'admin' => false, 'root_folder_name' => null, 'tz' => "Asia/Gaza"]);
Arsse::$user->method("begin")->willReturn($this->transaction);
//initialize a handler
$this->h = new V1();
@@ -748,7 +721,8 @@ class TestV1 extends \JKingWeb\Arsse\Test\AbstractTest {
}
/** @dataProvider provideEntryQueries */
- public function testGetEntries(string $url, ?Context $c, ?array $order, $out, bool $getFeeds, ResponseInterface $exp) {
+ public function testGetEntries(string $url, ?Context $c, ?array $order, $out, ResponseInterface $exp) {
+ \Phake::when(Arsse::$db)->subscriptionList->thenReturn(new Result($this->v(self::FEEDS)));
if ($out instanceof \Exception) {
\Phake::when(Arsse::$db)->articleList->thenThrow($out);
} else {
@@ -760,24 +734,44 @@ class TestV1 extends \JKingWeb\Arsse\Test\AbstractTest {
} else {
\Phake::verify(Arsse::$db, \Phake::times(0))->articleList;
}
+ if ($out) {
+ \Phake::verify(Arsse::$db)->subscriptionList(Arsse::$user->id);
+ } else {
+ \Phake::verify(Arsse::$db, \Phake::times(0))->subscriptionList;
+ }
}
public function provideEntryQueries(): iterable {
self::clearData();
$c = new Context;
return [
- ["/entries?after=A", null, null, [], false, new ErrorResponse(["InvalidInputValue", 'field' => "after"], 400)],
- ["/entries?before=B", null, null, [], false, new ErrorResponse(["InvalidInputValue", 'field' => "before"], 400)],
- ["/entries?category_id=0", null, null, [], false, new ErrorResponse(["InvalidInputValue", 'field' => "category_id"], 400)],
- ["/entries?after_entry_id=0", null, null, [], false, new ErrorResponse(["InvalidInputValue", 'field' => "after_entry_id"], 400)],
- ["/entries?before_entry_id=0", null, null, [], false, new ErrorResponse(["InvalidInputValue", 'field' => "before_entry_id"], 400)],
- ["/entries?limit=-1", null, null, [], false, new ErrorResponse(["InvalidInputValue", 'field' => "limit"], 400)],
- ["/entries?offset=-1", null, null, [], false, new ErrorResponse(["InvalidInputValue", 'field' => "offset"], 400)],
- ["/entries?direction=sideways", null, null, [], false, new ErrorResponse(["InvalidInputValue", 'field' => "direction"], 400)],
- ["/entries?order=false", null, null, [], false, new ErrorResponse(["InvalidInputValue", 'field' => "order"], 400)],
- ["/entries?starred&starred", null, null, [], false, new ErrorResponse(["DuplicateInputValue", 'field' => "starred"], 400)],
- ["/entries?after&after=0", null, null, [], false, new ErrorResponse(["DuplicateInputValue", 'field' => "after"], 400)],
- ["/entries", $c, [], [], false, new Response(['total' => 0, 'entries' => []])],
+ ["/entries?after=A", null, null, [], new ErrorResponse(["InvalidInputValue", 'field' => "after"], 400)],
+ ["/entries?before=B", null, null, [], new ErrorResponse(["InvalidInputValue", 'field' => "before"], 400)],
+ ["/entries?category_id=0", null, null, [], new ErrorResponse(["InvalidInputValue", 'field' => "category_id"], 400)],
+ ["/entries?after_entry_id=0", null, null, [], new ErrorResponse(["InvalidInputValue", 'field' => "after_entry_id"], 400)],
+ ["/entries?before_entry_id=0", null, null, [], new ErrorResponse(["InvalidInputValue", 'field' => "before_entry_id"], 400)],
+ ["/entries?limit=-1", null, null, [], new ErrorResponse(["InvalidInputValue", 'field' => "limit"], 400)],
+ ["/entries?offset=-1", null, null, [], new ErrorResponse(["InvalidInputValue", 'field' => "offset"], 400)],
+ ["/entries?direction=sideways", null, null, [], new ErrorResponse(["InvalidInputValue", 'field' => "direction"], 400)],
+ ["/entries?order=false", null, null, [], new ErrorResponse(["InvalidInputValue", 'field' => "order"], 400)],
+ ["/entries?starred&starred", null, null, [], new ErrorResponse(["DuplicateInputValue", 'field' => "starred"], 400)],
+ ["/entries?after&after=0", null, null, [], new ErrorResponse(["DuplicateInputValue", 'field' => "after"], 400)],
+ ["/entries", $c, [], self::ENTRIES, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])],
+ ["/entries?category_id=47", (clone $c)->folder(46), [], self::ENTRIES, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])],
+ ["/entries?category_id=1", (clone $c)->folderShallow(0), [], self::ENTRIES, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])],
+ ["/entries?status=unread", (clone $c)->unread(true)->hidden(false), [], self::ENTRIES, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])],
+ ["/entries?status=read", (clone $c)->unread(false)->hidden(false), [], self::ENTRIES, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])],
+ ["/entries?status=removed", (clone $c)->hidden(true), [], self::ENTRIES, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])],
+ ["/entries?status=unread&status=read", (clone $c)->hidden(false), [], self::ENTRIES, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])],
+ ["/entries?status=unread&status=removed", (clone $c)->unread(true), [], self::ENTRIES, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])],
+ ["/entries?status=removed&status=read", (clone $c)->unread(false), [], self::ENTRIES, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])],
+ ["/entries?status=removed&status=read&status=removed", (clone $c)->unread(false), [], self::ENTRIES, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])],
+ ["/entries?status=removed&status=read&status=unread", $c, [], self::ENTRIES, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])],
+ ["/entries?starred", (clone $c)->starred(true), [], self::ENTRIES, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])],
+ ["/entries?starred=", (clone $c)->starred(true), [], self::ENTRIES, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])],
+ ["/entries?starred=true", (clone $c)->starred(true), [], self::ENTRIES, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])],
+ ["/entries?starred=false", (clone $c)->starred(true), [], self::ENTRIES, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])],
+
];
}
}
From d4a6909cf6773c9f67c982ad3ad12d8ef8d6deb7 Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Wed, 3 Feb 2021 23:00:14 -0500
Subject: [PATCH 154/366] Positional article queries tests
---
tests/cases/REST/Miniflux/TestV1.php | 5 ++++-
1 file changed, 4 insertions(+), 1 deletion(-)
diff --git a/tests/cases/REST/Miniflux/TestV1.php b/tests/cases/REST/Miniflux/TestV1.php
index 395e080c..d390bad5 100644
--- a/tests/cases/REST/Miniflux/TestV1.php
+++ b/tests/cases/REST/Miniflux/TestV1.php
@@ -771,7 +771,10 @@ class TestV1 extends \JKingWeb\Arsse\Test\AbstractTest {
["/entries?starred=", (clone $c)->starred(true), [], self::ENTRIES, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])],
["/entries?starred=true", (clone $c)->starred(true), [], self::ENTRIES, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])],
["/entries?starred=false", (clone $c)->starred(true), [], self::ENTRIES, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])],
-
+ ["/entries?after=0", (clone $c)->modifiedSince(0), [], self::ENTRIES, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])],
+ ["/entries?before=0", (clone $c)->notModifiedSince(0), [], self::ENTRIES, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])],
+ ["/entries?after_entry_id=42", (clone $c)->oldestArticle(43), [], self::ENTRIES, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])],
+ ["/entries?before_entry_id=47", (clone $c)->latestArticle(46), [], self::ENTRIES, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])],
];
}
}
From 00ad1cc5b942dc6191ead7c3df2283df1e571ec5 Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Thu, 4 Feb 2021 17:07:22 -0500
Subject: [PATCH 155/366] Last tests for article querying
---
lib/REST/Miniflux/V1.php | 15 ++---
tests/cases/REST/Miniflux/TestV1.php | 89 +++++++++++++++++-----------
2 files changed, 64 insertions(+), 40 deletions(-)
diff --git a/lib/REST/Miniflux/V1.php b/lib/REST/Miniflux/V1.php
index 3e8674f3..2d427680 100644
--- a/lib/REST/Miniflux/V1.php
+++ b/lib/REST/Miniflux/V1.php
@@ -33,6 +33,8 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
protected const ACCEPTED_TYPES_OPML = ["application/xml", "text/xml", "text/x-opml"];
protected const ACCEPTED_TYPES_JSON = ["application/json"];
protected const TOKEN_LENGTH = 32;
+ protected const DEFAULT_ENTRY_LIMIT = 100;
+ protected const DEFAULT_ORDER_COL = "modified_date";
protected const DATE_FORMAT_SEC = "Y-m-d\TH:i:sP";
protected const DATE_FORMAT_MICRO = "Y-m-d\TH:i:s.uP";
protected const VALID_QUERY = [
@@ -903,7 +905,7 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
protected function computeContext(array $query, Context $c = null): Context {
$c = ($c ?? new Context)
- ->limit($query['limit'])
+ ->limit($query['limit'] ?? self::DEFAULT_ENTRY_LIMIT) // NOTE: This does not honour user preferences
->offset($query['offset'])
->starred($query['starred'])
->modifiedSince($query['after']) // FIXME: This may not be the correct date field
@@ -951,10 +953,10 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
return ["modified_date".$desc];
} elseif ($query['order'] === "category_title") {
return ["top_folder_name".$desc];
- } elseif ($query['order'] === "catgory_id") {
+ } elseif ($query['order'] === "category_id") {
return ["top_folder".$desc];
} else {
- return [];
+ return [self::DEFAULT_ORDER_COL.$desc];
}
}
@@ -1028,11 +1030,10 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
$out[$a]['feed'] = $feeds[$out[$a]['feed_id']];
}
}
- // finally compute the total number of entries match the query, if the query hs a limit or offset
- if ($c->limit || $c->offset) {
+ // finally compute the total number of entries match the query, where necessary
+ $count = sizeof($out);
+ if ($c->offset || ($c->limit && $count >= $c->limit)) {
$count = Arsse::$db->articleCount(Arsse::$user->id, (clone $c)->limit(0)->offset(0));
- } else {
- $count = sizeof($out);
}
return new Response(['total' => $count, 'entries' => $out]);
}
diff --git a/tests/cases/REST/Miniflux/TestV1.php b/tests/cases/REST/Miniflux/TestV1.php
index d390bad5..392a2781 100644
--- a/tests/cases/REST/Miniflux/TestV1.php
+++ b/tests/cases/REST/Miniflux/TestV1.php
@@ -721,8 +721,9 @@ class TestV1 extends \JKingWeb\Arsse\Test\AbstractTest {
}
/** @dataProvider provideEntryQueries */
- public function testGetEntries(string $url, ?Context $c, ?array $order, $out, ResponseInterface $exp) {
+ public function testGetEntries(string $url, ?Context $c, ?array $order, $out, bool $count, ResponseInterface $exp) {
\Phake::when(Arsse::$db)->subscriptionList->thenReturn(new Result($this->v(self::FEEDS)));
+ \Phake::when(Arsse::$db)->articleCount->thenReturn(2112);
if ($out instanceof \Exception) {
\Phake::when(Arsse::$db)->articleList->thenThrow($out);
} else {
@@ -734,47 +735,69 @@ class TestV1 extends \JKingWeb\Arsse\Test\AbstractTest {
} else {
\Phake::verify(Arsse::$db, \Phake::times(0))->articleList;
}
- if ($out) {
+ if ($out && !$out instanceof \Exception) {
\Phake::verify(Arsse::$db)->subscriptionList(Arsse::$user->id);
} else {
\Phake::verify(Arsse::$db, \Phake::times(0))->subscriptionList;
}
+ if ($count) {
+ \Phake::verify(Arsse::$db)->articleCount(Arsse::$user->id, (clone $c)->limit(0)->offset(0));
+ } else {
+ \Phake::verify(Arsse::$db, \Phake::times(0))->articleCount;
+ }
}
public function provideEntryQueries(): iterable {
self::clearData();
- $c = new Context;
+ $c = (new Context)->limit(100);
+ $o = ["modified_date"]; // the default sort order
return [
- ["/entries?after=A", null, null, [], new ErrorResponse(["InvalidInputValue", 'field' => "after"], 400)],
- ["/entries?before=B", null, null, [], new ErrorResponse(["InvalidInputValue", 'field' => "before"], 400)],
- ["/entries?category_id=0", null, null, [], new ErrorResponse(["InvalidInputValue", 'field' => "category_id"], 400)],
- ["/entries?after_entry_id=0", null, null, [], new ErrorResponse(["InvalidInputValue", 'field' => "after_entry_id"], 400)],
- ["/entries?before_entry_id=0", null, null, [], new ErrorResponse(["InvalidInputValue", 'field' => "before_entry_id"], 400)],
- ["/entries?limit=-1", null, null, [], new ErrorResponse(["InvalidInputValue", 'field' => "limit"], 400)],
- ["/entries?offset=-1", null, null, [], new ErrorResponse(["InvalidInputValue", 'field' => "offset"], 400)],
- ["/entries?direction=sideways", null, null, [], new ErrorResponse(["InvalidInputValue", 'field' => "direction"], 400)],
- ["/entries?order=false", null, null, [], new ErrorResponse(["InvalidInputValue", 'field' => "order"], 400)],
- ["/entries?starred&starred", null, null, [], new ErrorResponse(["DuplicateInputValue", 'field' => "starred"], 400)],
- ["/entries?after&after=0", null, null, [], new ErrorResponse(["DuplicateInputValue", 'field' => "after"], 400)],
- ["/entries", $c, [], self::ENTRIES, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])],
- ["/entries?category_id=47", (clone $c)->folder(46), [], self::ENTRIES, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])],
- ["/entries?category_id=1", (clone $c)->folderShallow(0), [], self::ENTRIES, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])],
- ["/entries?status=unread", (clone $c)->unread(true)->hidden(false), [], self::ENTRIES, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])],
- ["/entries?status=read", (clone $c)->unread(false)->hidden(false), [], self::ENTRIES, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])],
- ["/entries?status=removed", (clone $c)->hidden(true), [], self::ENTRIES, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])],
- ["/entries?status=unread&status=read", (clone $c)->hidden(false), [], self::ENTRIES, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])],
- ["/entries?status=unread&status=removed", (clone $c)->unread(true), [], self::ENTRIES, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])],
- ["/entries?status=removed&status=read", (clone $c)->unread(false), [], self::ENTRIES, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])],
- ["/entries?status=removed&status=read&status=removed", (clone $c)->unread(false), [], self::ENTRIES, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])],
- ["/entries?status=removed&status=read&status=unread", $c, [], self::ENTRIES, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])],
- ["/entries?starred", (clone $c)->starred(true), [], self::ENTRIES, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])],
- ["/entries?starred=", (clone $c)->starred(true), [], self::ENTRIES, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])],
- ["/entries?starred=true", (clone $c)->starred(true), [], self::ENTRIES, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])],
- ["/entries?starred=false", (clone $c)->starred(true), [], self::ENTRIES, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])],
- ["/entries?after=0", (clone $c)->modifiedSince(0), [], self::ENTRIES, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])],
- ["/entries?before=0", (clone $c)->notModifiedSince(0), [], self::ENTRIES, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])],
- ["/entries?after_entry_id=42", (clone $c)->oldestArticle(43), [], self::ENTRIES, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])],
- ["/entries?before_entry_id=47", (clone $c)->latestArticle(46), [], self::ENTRIES, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])],
+ ["/entries?after=A", null, null, [], false, new ErrorResponse(["InvalidInputValue", 'field' => "after"], 400)],
+ ["/entries?before=B", null, null, [], false, new ErrorResponse(["InvalidInputValue", 'field' => "before"], 400)],
+ ["/entries?category_id=0", null, null, [], false, new ErrorResponse(["InvalidInputValue", 'field' => "category_id"], 400)],
+ ["/entries?after_entry_id=0", null, null, [], false, new ErrorResponse(["InvalidInputValue", 'field' => "after_entry_id"], 400)],
+ ["/entries?before_entry_id=0", null, null, [], false, new ErrorResponse(["InvalidInputValue", 'field' => "before_entry_id"], 400)],
+ ["/entries?limit=-1", null, null, [], false, new ErrorResponse(["InvalidInputValue", 'field' => "limit"], 400)],
+ ["/entries?offset=-1", null, null, [], false, new ErrorResponse(["InvalidInputValue", 'field' => "offset"], 400)],
+ ["/entries?direction=sideways", null, null, [], false, new ErrorResponse(["InvalidInputValue", 'field' => "direction"], 400)],
+ ["/entries?order=false", null, null, [], false, new ErrorResponse(["InvalidInputValue", 'field' => "order"], 400)],
+ ["/entries?starred&starred", null, null, [], false, new ErrorResponse(["DuplicateInputValue", 'field' => "starred"], 400)],
+ ["/entries?after&after=0", null, null, [], false, new ErrorResponse(["DuplicateInputValue", 'field' => "after"], 400)],
+ ["/entries", $c, $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])],
+ ["/entries?category_id=47", (clone $c)->folder(46), $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])],
+ ["/entries?category_id=1", (clone $c)->folderShallow(0), $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])],
+ ["/entries?status=unread", (clone $c)->unread(true)->hidden(false), $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])],
+ ["/entries?status=read", (clone $c)->unread(false)->hidden(false), $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])],
+ ["/entries?status=removed", (clone $c)->hidden(true), $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])],
+ ["/entries?status=unread&status=read", (clone $c)->hidden(false), $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])],
+ ["/entries?status=unread&status=removed", (clone $c)->unread(true), $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])],
+ ["/entries?status=removed&status=read", (clone $c)->unread(false), $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])],
+ ["/entries?status=removed&status=read&status=removed", (clone $c)->unread(false), $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])],
+ ["/entries?status=removed&status=read&status=unread", $c, $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])],
+ ["/entries?starred", (clone $c)->starred(true), $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])],
+ ["/entries?starred=", (clone $c)->starred(true), $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])],
+ ["/entries?starred=true", (clone $c)->starred(true), $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])],
+ ["/entries?starred=false", (clone $c)->starred(true), $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])],
+ ["/entries?after=0", (clone $c)->modifiedSince(0), $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])],
+ ["/entries?before=0", (clone $c)->notModifiedSince(0), $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])],
+ ["/entries?after_entry_id=42", (clone $c)->oldestArticle(43), $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])],
+ ["/entries?before_entry_id=47", (clone $c)->latestArticle(46), $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])],
+ ["/entries?search=alpha%20beta", (clone $c)->searchTerms(["alpha", "beta"]), $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])],
+ ["/entries?limit=4", (clone $c)->limit(4), $o, self::ENTRIES, true, new Response(['total' => 2112, 'entries' => self::ENTRIES_OUT])],
+ ["/entries?offset=20", (clone $c)->offset(20), $o, [], true, new Response(['total' => 2112, 'entries' => []])],
+ ["/entries?direction=asc", $c, $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])],
+ ["/entries?order=id", $c, ["id"], self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])],
+ ["/entries?order=published_at", $c, ["modified_date"], self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])],
+ ["/entries?order=category_id", $c, ["top_folder"], self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])],
+ ["/entries?order=category_title", $c, ["top_folder_name"], self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])],
+ ["/entries?order=status", $c, ["hidden", "unread desc"], self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])],
+ ["/entries?direction=desc", $c, ["modified_date desc"], self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])],
+ ["/entries?order=id&direction=desc", $c, ["id desc"], self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])],
+ ["/entries?order=published_at&direction=desc", $c, ["modified_date desc"], self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])],
+ ["/entries?order=category_id&direction=desc", $c, ["top_folder desc"], self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])],
+ ["/entries?order=category_title&direction=desc", $c, ["top_folder_name desc"], self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])],
+ ["/entries?order=status&direction=desc", $c, ["hidden desc", "unread"], self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])],
+ ["/entries?category_id=2112", (clone $c)->folder(2111), $o, new ExceptionInput("idMissing"), false, new ErrorResponse("MissingCategory")],
];
}
}
From a7d05a77173c87a65c8ff28e5f005ab5d960037f Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Thu, 4 Feb 2021 17:52:40 -0500
Subject: [PATCH 156/366] Feed- and category-specific entry list routes
---
lib/REST/Miniflux/V1.php | 40 +++++++++++++++++++++-------
tests/cases/REST/Miniflux/TestV1.php | 8 ++++++
2 files changed, 39 insertions(+), 9 deletions(-)
diff --git a/lib/REST/Miniflux/V1.php b/lib/REST/Miniflux/V1.php
index 2d427680..be093d48 100644
--- a/lib/REST/Miniflux/V1.php
+++ b/lib/REST/Miniflux/V1.php
@@ -1003,19 +1003,14 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
];
}
- protected function getEntries(array $query): ResponseInterface {
- $c = $this->computeContext($query);
+ protected function listEntries(array $query, Context $c): array {
+ $c = $this->computeContext($query, $c);
$order = $this->computeOrder($query);
$tr = Arsse::$db->begin();
$meta = $this->userMeta(Arsse::$user->id);
// compile the list of entries
- try {
- $entries = Arsse::$db->articleList(Arsse::$user->id, $c, self::ARTICLE_COLUMNS, $order);
- } catch (ExceptionInput $e) {
- return new ErrorResponse("MissingCategory", 400);
- }
$out = [];
- foreach ($entries as $entry) {
+ foreach (Arsse::$db->articleList(Arsse::$user->id, $c, self::ARTICLE_COLUMNS, $order) as $entry) {
$out[] = $this->transformEntry($entry, $meta['num'], $meta['tz']);
}
// next compile a map of feeds to add to the entries
@@ -1035,7 +1030,34 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
if ($c->offset || ($c->limit && $count >= $c->limit)) {
$count = Arsse::$db->articleCount(Arsse::$user->id, (clone $c)->limit(0)->offset(0));
}
- return new Response(['total' => $count, 'entries' => $out]);
+ return ['total' => $count, 'entries' => $out];
+ }
+
+ protected function getEntries(array $query): ResponseInterface {
+ try {
+ return new Response($this->listEntries($query, new Context));
+ } catch (ExceptionInput $e) {
+ return new ErrorResponse("MissingCategory", 400);
+ }
+ }
+
+ protected function getFeedEntries(array $path, array $query): ResponseInterface {
+ $c = (new Context)->subscription((int) $path[1]);
+ try {
+ return new Response($this->listEntries($query, $c));
+ } catch (ExceptionInput $e) {
+ // FIXME: this should differentiate between a missing feed and a missing category, but doesn't
+ return new ErrorResponse("404", 404);
+ }
+ }
+
+ protected function getCategoryEntries(array $path, array $query): ResponseInterface {
+ $query['category_id'] = (int) $path[1];
+ try {
+ return new Response($this->listEntries($query, new Context));
+ } catch (ExceptionInput $e) {
+ return new ErrorResponse("404", 404);
+ }
}
public static function tokenGenerate(string $user, string $label): string {
diff --git a/tests/cases/REST/Miniflux/TestV1.php b/tests/cases/REST/Miniflux/TestV1.php
index 392a2781..02387dc1 100644
--- a/tests/cases/REST/Miniflux/TestV1.php
+++ b/tests/cases/REST/Miniflux/TestV1.php
@@ -798,6 +798,14 @@ class TestV1 extends \JKingWeb\Arsse\Test\AbstractTest {
["/entries?order=category_title&direction=desc", $c, ["top_folder_name desc"], self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])],
["/entries?order=status&direction=desc", $c, ["hidden desc", "unread"], self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])],
["/entries?category_id=2112", (clone $c)->folder(2111), $o, new ExceptionInput("idMissing"), false, new ErrorResponse("MissingCategory")],
+ ["/feeds/42/entries", (clone $c)->subscription(42), $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])],
+ ["/feeds/42/entries?category_id=47", (clone $c)->subscription(42)->folder(46), $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])],
+ ["/feeds/2112/entries", (clone $c)->subscription(2112), $o, new ExceptionInput("idMissing"), false, new ErrorResponse("404", 404)],
+ ["/categories/42/entries", (clone $c)->folder(41), $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])],
+ ["/categories/42/entries?category_id=47", (clone $c)->folder(41), $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])],
+ ["/categories/42/entries?starred", (clone $c)->folder(41)->starred(true), $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])],
+ ["/categories/1/entries", (clone $c)->folderShallow(0), $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])],
+ ["/categories/2112/entries", (clone $c)->folder(2111), $o, new ExceptionInput("idMissing"), false, new ErrorResponse("404", 404)],
];
}
}
From 334a585cb89d79462ad166a510562bcf615b2de9 Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Thu, 4 Feb 2021 20:19:35 -0500
Subject: [PATCH 157/366] Implement single-entry querying
---
lib/REST/Miniflux/V1.php | 46 ++++++++++++++++++++++++++++
tests/cases/REST/Miniflux/TestV1.php | 40 +++++++++++++++++++++++-
2 files changed, 85 insertions(+), 1 deletion(-)
diff --git a/lib/REST/Miniflux/V1.php b/lib/REST/Miniflux/V1.php
index be093d48..47decbfa 100644
--- a/lib/REST/Miniflux/V1.php
+++ b/lib/REST/Miniflux/V1.php
@@ -1032,6 +1032,21 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
}
return ['total' => $count, 'entries' => $out];
}
+
+ protected function findEntry(int $id, Context $c = null): array {
+ $c = ($c ?? new Context)->article($id);
+ $tr = Arsse::$db->begin();
+ $meta = $this->userMeta(Arsse::$user->id);
+ // find the entry we want
+ $entry = Arsse::$db->articleList(Arsse::$user->id, $c, self::ARTICLE_COLUMNS)->getRow();
+ if (!$entry) {
+ throw new ExceptionInput("idMissing");
+ }
+ $out = $this->transformEntry($entry, $meta['num'], $meta['tz']);
+ // next transform the parent feed of the entry
+ $out['feed'] = $this->transformFeed(Arsse::$db->subscriptionPropertiesGet(Arsse::$user->id, $out['feed_id']), $meta['num'], $meta['root'], $meta['tz']);
+ return $out;
+ }
protected function getEntries(array $query): ResponseInterface {
try {
@@ -1059,6 +1074,37 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
return new ErrorResponse("404", 404);
}
}
+
+ protected function getEntry(array $path): ResponseInterface {
+ try {
+ return new Response($this->findEntry((int) $path[1]));
+ } catch (ExceptionInput $e) {
+ return new ErrorResponse("404", 404);
+ }
+ }
+
+ protected function getFeedEntry(array $path): ResponseInterface {
+ $c = (new Context)->subscription((int) $path[1]);
+ try {
+ return new Response($this->findEntry((int) $path[3], $c));
+ } catch (ExceptionInput $e) {
+ return new ErrorResponse("404", 404);
+ }
+ }
+
+ protected function getCategoryEntry(array $path): ResponseInterface {
+ $c = new Context;
+ if ($path[1] === "1") {
+ $c->folderShallow(0);
+ } else {
+ $c->folder((int) $path[1] - 1);
+ }
+ try {
+ return new Response($this->findEntry((int) $path[3], $c));
+ } catch (ExceptionInput $e) {
+ return new ErrorResponse("404", 404);
+ }
+ }
public static function tokenGenerate(string $user, string $label): string {
// Miniflux produces tokenss in base64url alphabet
diff --git a/tests/cases/REST/Miniflux/TestV1.php b/tests/cases/REST/Miniflux/TestV1.php
index 02387dc1..a2c6aa82 100644
--- a/tests/cases/REST/Miniflux/TestV1.php
+++ b/tests/cases/REST/Miniflux/TestV1.php
@@ -721,7 +721,7 @@ class TestV1 extends \JKingWeb\Arsse\Test\AbstractTest {
}
/** @dataProvider provideEntryQueries */
- public function testGetEntries(string $url, ?Context $c, ?array $order, $out, bool $count, ResponseInterface $exp) {
+ public function testGetEntries(string $url, ?Context $c, ?array $order, $out, bool $count, ResponseInterface $exp): void {
\Phake::when(Arsse::$db)->subscriptionList->thenReturn(new Result($this->v(self::FEEDS)));
\Phake::when(Arsse::$db)->articleCount->thenReturn(2112);
if ($out instanceof \Exception) {
@@ -808,4 +808,42 @@ class TestV1 extends \JKingWeb\Arsse\Test\AbstractTest {
["/categories/2112/entries", (clone $c)->folder(2111), $o, new ExceptionInput("idMissing"), false, new ErrorResponse("404", 404)],
];
}
+
+ /** @dataProvider provideSingleEntryQueries */
+ public function testGetASingleEntry(string $url, Context $c, $out, ResponseInterface $exp): void {
+ \Phake::when(Arsse::$db)->subscriptionPropertiesGet->thenReturn($this->v(self::FEEDS[1]));
+ if ($out instanceof \Exception) {
+ \Phake::when(Arsse::$db)->articleList->thenThrow($out);
+ } else {
+ \Phake::when(Arsse::$db)->articleList->thenReturn(new Result($this->v($out)));
+ }
+ $this->assertMessage($exp, $this->req("GET", $url));
+ if ($c) {
+ \Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, $c, array_keys(self::ENTRIES[0]));
+ } else {
+ \Phake::verify(Arsse::$db, \Phake::times(0))->articleList;
+ }
+ if ($out && is_array($out)) {
+ \Phake::verify(Arsse::$db)->subscriptionPropertiesGet(Arsse::$user->id, 55);
+ } else {
+ \Phake::verify(Arsse::$db, \Phake::times(0))->subscriptionList;
+ }
+ }
+
+ public function provideSingleEntryQueries(): iterable {
+ $c = new Context;
+ return [
+ ["/entries/42", (clone $c)->article(42), [self::ENTRIES[1]], new Response(self::ENTRIES_OUT[1])],
+ ["/entries/2112", (clone $c)->article(2112), new ExceptionInput("subjectMissing"), new ErrorResponse("404", 404)],
+ ["/feeds/47/entries/42", (clone $c)->subscription(47)->article(42), [self::ENTRIES[1]], new Response(self::ENTRIES_OUT[1])],
+ ["/feeds/47/entries/44", (clone $c)->subscription(47)->article(44), [], new ErrorResponse("404", 404)],
+ ["/feeds/47/entries/2112", (clone $c)->subscription(47)->article(2112), new ExceptionInput("subjectMissing"), new ErrorResponse("404", 404)],
+ ["/feeds/2112/entries/47", (clone $c)->subscription(2112)->article(47), new ExceptionInput("idMissing"), new ErrorResponse("404", 404)],
+ ["/categories/47/entries/42", (clone $c)->folder(46)->article(42), [self::ENTRIES[1]], new Response(self::ENTRIES_OUT[1])],
+ ["/categories/47/entries/44", (clone $c)->folder(46)->article(44), [], new ErrorResponse("404", 404)],
+ ["/categories/47/entries/2112", (clone $c)->folder(46)->article(2112), new ExceptionInput("subjectMissing"), new ErrorResponse("404", 404)],
+ ["/categories/2112/entries/47", (clone $c)->folder(2111)->article(47), new ExceptionInput("idMissing"), new ErrorResponse("404", 404)],
+ ["/categories/1/entries/42", (clone $c)->folderShallow(0)->article(42), [self::ENTRIES[1]], new Response(self::ENTRIES_OUT[1])],
+ ];
+ }
}
From ab1cf7447bbfed3404e8f85bfec521c608673754 Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Fri, 5 Feb 2021 08:48:14 -0500
Subject: [PATCH 158/366] Implement article marking
---
lib/REST/Miniflux/V1.php | 113 ++++++++++++++++++++-------
tests/cases/REST/Miniflux/TestV1.php | 113 ++++++++++++++++++++++-----
2 files changed, 178 insertions(+), 48 deletions(-)
diff --git a/lib/REST/Miniflux/V1.php b/lib/REST/Miniflux/V1.php
index 47decbfa..51884ea4 100644
--- a/lib/REST/Miniflux/V1.php
+++ b/lib/REST/Miniflux/V1.php
@@ -71,6 +71,8 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
'disabled' => "boolean",
'ignore_http_cache' => "boolean",
'fetch_via_proxy' => "boolean",
+ 'entry_ids' => "array", // this is a special case: it is an array of integers
+ 'status' => "string",
];
protected const USER_META_MAP = [
// Miniflux ID // Arsse ID Default value
@@ -146,7 +148,7 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
],
'/entries' => [
'GET' => ["getEntries", false, false, false, true, []],
- 'PUT' => ["updateEntries", false, false, true, false, []],
+ 'PUT' => ["updateEntries", false, false, true, false, ["entry_ids", "status"]],
],
'/entries/1' => [
'GET' => ["getEntry", false, true, false, false, []],
@@ -349,8 +351,17 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
(in_array($k, ["keeplist_rules", "blocklist_rules"]) && !Rule::validate($body[$k]))
|| (in_array($k, ["url", "feed_url"]) && !URL::absolute($body[$k]))
|| ($k === "category_id" && $body[$k] < 1)
+ || ($k === "status" && !in_array($body[$k], ["read", "unread", "removed"]))
) {
return new ErrorResponse(["InvalidInputValue", 'field' => $k], 422);
+ } elseif ($k === "entry_ids") {
+ foreach ($body[$k] as $v) {
+ if (gettype($v) !== "integer") {
+ return new ErrorResponse(["InvalidInputType", 'field' => $k, 'expected' => "integer", 'actual' => gettype($v)], 422);
+ } elseif ($v < 1) {
+ return new ErrorResponse(["InvalidInputValue", 'field' => $k], 422);
+ }
+ }
}
}
//normalize user-specific input
@@ -368,7 +379,7 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
}
// check for any missing required values
foreach ($req as $k) {
- if (!isset($body[$k])) {
+ if (!isset($body[$k]) || (is_array($body[$k]) && !$body[$k])) {
return new ErrorResponse(["MissingInputValue", 'field' => $k], 422);
}
}
@@ -629,16 +640,6 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
return new EmptyResponse(204);
}
- protected function markUserByNum(array $path): ResponseInterface {
- // this function is restricted to the logged-in user
- $user = Arsse::$user->propertiesGet(Arsse::$user->id, false);
- if (((int) $path[1]) !== $user['num']) {
- return new ErrorResponse("403", 403);
- }
- Arsse::$db->articleMark(Arsse::$user->id, ['read' => true], (new Context)->hidden(false));
- return new EmptyResponse(204);
- }
-
/** Returns a useful subset of user metadata
*
* The following keys are included:
@@ -729,23 +730,6 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
return new EmptyResponse(204);
}
- protected function markCategory(array $path): ResponseInterface {
- $folder = $path[1] - 1;
- $c = new Context;
- if ($folder === 0) {
- // if we're marking the root folder don't also mark its child folders, since Miniflux organizes it as a peer of other folders
- $c = $c->folderShallow($folder);
- } else {
- $c = $c->folder($folder);
- }
- try {
- Arsse::$db->articleMark(Arsse::$user->id, ['read' => true], $c);
- } catch (ExceptionInput $e) {
- return new ErrorResponse("404", 404);
- }
- return new EmptyResponse(204);
- }
-
protected function transformFeed(array $sub, int $uid, string $rootName, \DateTimeZone $tz): array {
$url = new Uri($sub['url']);
return [
@@ -1106,6 +1090,77 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
}
}
+ protected function updateEntries(array $data): ResponseInterface {
+ if ($data['status'] === "read") {
+ $in = ['read' => true, 'hidden' => false];
+ } elseif ($data['status'] === "unread") {
+ $in = ['read' => false, 'hidden' => false];
+ } elseif ($data['status'] === "removed") {
+ $in = ['read' => true, 'hidden' => true];
+ }
+ assert(isset($in), new \Exception("Unknown status specified"));
+ Arsse::$db->articleMark(Arsse::$user->id, $in, (new Context)->articles($data['entry_ids']));
+ return new EmptyResponse(204);
+ }
+
+ protected function massRead(Context $c): void {
+ Arsse::$db->articleMark(Arsse::$user->id, ['read' => true], $c->hidden(false));
+ }
+
+ protected function markUserByNum(array $path): ResponseInterface {
+ // this function is restricted to the logged-in user
+ $user = Arsse::$user->propertiesGet(Arsse::$user->id, false);
+ if (((int) $path[1]) !== $user['num']) {
+ return new ErrorResponse("403", 403);
+ }
+ $this->massRead(new Context);
+ return new EmptyResponse(204);
+ }
+
+ protected function markFeed(array $path): ResponseInterface {
+ try {
+ $this->massRead((new Context)->subscription((int) $path[1]));
+ } catch (ExceptionInput $e) {
+ return new ErrorResponse("404", 404);
+ }
+ return new EmptyResponse(204);
+ }
+
+ protected function markCategory(array $path): ResponseInterface {
+ $folder = $path[1] - 1;
+ $c = new Context;
+ if ($folder === 0) {
+ // if we're marking the root folder don't also mark its child folders, since Miniflux organizes it as a peer of other folders
+ $c->folderShallow($folder);
+ } else {
+ $c->folder($folder);
+ }
+ try {
+ $this->massRead($c);
+ } catch (ExceptionInput $e) {
+ return new ErrorResponse("404", 404);
+ }
+ return new EmptyResponse(204);
+ }
+
+ protected function toggleEntryBookmark(array $path): ResponseInterface {
+ // NOTE: A toggle is bad design, but we have no choice but to implement what Miniflux does
+ $id = (int) $path[1];
+ $c = (new Context)->article($id);
+ try {
+ $tr = Arsse::$db->begin();
+ if (Arsse::$db->articleCount(Arsse::$user->id, (clone $c)->starred(false))) {
+ Arsse::$db->articleMark(Arsse::$user->id, ['starred' => true], $c);
+ } else {
+ Arsse::$db->articleMark(Arsse::$user->id, ['starred' => false], $c);
+ }
+ $tr->commit();
+ } catch (ExceptionInput $e) {
+ return new ErrorResponse("404", 404);
+ }
+ return new EmptyResponse(204);
+ }
+
public static function tokenGenerate(string $user, string $label): string {
// Miniflux produces tokenss in base64url alphabet
$t = str_replace(["+", "/"], ["-", "_"], base64_encode(random_bytes(self::TOKEN_LENGTH)));
diff --git a/tests/cases/REST/Miniflux/TestV1.php b/tests/cases/REST/Miniflux/TestV1.php
index a2c6aa82..18982f34 100644
--- a/tests/cases/REST/Miniflux/TestV1.php
+++ b/tests/cases/REST/Miniflux/TestV1.php
@@ -398,13 +398,6 @@ class TestV1 extends \JKingWeb\Arsse\Test\AbstractTest {
$this->assertMessage(new ErrorResponse("403", 403), $this->req("DELETE", "/users/2112"));
}
- public function testMarkAllArticlesAsRead(): void {
- \Phake::when(Arsse::$db)->articleMark->thenReturn(true);
- $this->assertMessage(new ErrorResponse("403", 403), $this->req("PUT", "/users/1/mark-all-as-read"));
- $this->assertMessage(new EmptyResponse(204), $this->req("PUT", "/users/42/mark-all-as-read"));
- \Phake::verify(Arsse::$db)->articleMark("john.doe@example.com", ['read' => true], (new Context)->hidden(false));
- }
-
public function testListCategories(): void {
\Phake::when(Arsse::$db)->folderList->thenReturn(new Result($this->v([
['id' => 1, 'name' => "Science"],
@@ -512,18 +505,6 @@ class TestV1 extends \JKingWeb\Arsse\Test\AbstractTest {
);
}
- public function testMarkACategoryAsRead(): void {
- \Phake::when(Arsse::$db)->articleMark->thenReturn(1)->thenReturn(1)->thenThrow(new ExceptionInput("idMissing"));
- $this->assertMessage(new EmptyResponse(204), $this->req("PUT", "/categories/2/mark-all-as-read"));
- $this->assertMessage(new EmptyResponse(204), $this->req("PUT", "/categories/1/mark-all-as-read"));
- $this->assertMessage(new ErrorResponse("404", 404), $this->req("PUT", "/categories/2112/mark-all-as-read"));
- \Phake::inOrder(
- \Phake::verify(Arsse::$db)->articleMark("john.doe@example.com", ['read' => true], (new Context)->folder(1)),
- \Phake::verify(Arsse::$db)->articleMark("john.doe@example.com", ['read' => true], (new Context)->folderShallow(0)),
- \Phake::verify(Arsse::$db)->articleMark("john.doe@example.com", ['read' => true], (new Context)->folder(2111))
- );
- }
-
public function testListFeeds(): void {
\Phake::when(Arsse::$db)->subscriptionList->thenReturn(new Result($this->v(self::FEEDS)));
$exp = new Response(self::FEEDS_OUT);
@@ -831,6 +812,7 @@ class TestV1 extends \JKingWeb\Arsse\Test\AbstractTest {
}
public function provideSingleEntryQueries(): iterable {
+ self::clearData();
$c = new Context;
return [
["/entries/42", (clone $c)->article(42), [self::ENTRIES[1]], new Response(self::ENTRIES_OUT[1])],
@@ -846,4 +828,97 @@ class TestV1 extends \JKingWeb\Arsse\Test\AbstractTest {
["/categories/1/entries/42", (clone $c)->folderShallow(0)->article(42), [self::ENTRIES[1]], new Response(self::ENTRIES_OUT[1])],
];
}
+
+ /** @dataProvider provideEntryMarkings */
+ public function testMarkEntries(array $in, ?array $data, ResponseInterface $exp): void {
+ \Phake::when(Arsse::$db)->articleMark->thenReturn(0);
+ $this->assertMessage($exp, $this->req("PUT", "/entries", $in));
+ if ($data) {
+ \Phake::verify(Arsse::$db)->articleMark(Arsse::$user->id, $data, (new Context)->articles($in['entry_ids']));
+ } else {
+ \Phake::verify(Arsse::$db, \Phake::times(0))->articleMark;
+ }
+ }
+
+ public function provideEntryMarkings(): iterable {
+ self::clearData();
+ return [
+ [['status' => "read"], null, new ErrorResponse(["MissingInputValue", 'field' => "entry_ids"], 422)],
+ [['entry_ids' => [1]], null, new ErrorResponse(["MissingInputValue", 'field' => "status"], 422)],
+ [['entry_ids' => [], 'status' => "read"], null, new ErrorResponse(["MissingInputValue", 'field' => "entry_ids"], 422)],
+ [['entry_ids' => 1, 'status' => "read"], null, new ErrorResponse(["InvalidInputType", 'field' => "entry_ids", 'expected' => "array", 'actual' => "integer"], 422)],
+ [['entry_ids' => ["1"], 'status' => "read"], null, new ErrorResponse(["InvalidInputType", 'field' => "entry_ids", 'expected' => "integer", 'actual' => "string"], 422)],
+ [['entry_ids' => [1], 'status' => 1], null, new ErrorResponse(["InvalidInputType", 'field' => "status", 'expected' => "string", 'actual' => "integer"], 422)],
+ [['entry_ids' => [0], 'status' => "read"], null, new ErrorResponse(["InvalidInputValue", 'field' => "entry_ids",], 422)],
+ [['entry_ids' => [1], 'status' => "reread"], null, new ErrorResponse(["InvalidInputValue", 'field' => "status",], 422)],
+ [['entry_ids' => [1, 2], 'status' => "read"], ['read' => true, 'hidden' => false], new EmptyResponse(204)],
+ [['entry_ids' => [1, 2], 'status' => "unread"], ['read' => false, 'hidden' => false], new EmptyResponse(204)],
+ [['entry_ids' => [1, 2], 'status' => "removed"], ['read' => true, 'hidden' => true], new EmptyResponse(204)],
+ ];
+ }
+
+ /** @dataProvider provideMassMarkings */
+ public function testMassMarkEntries(string $url, Context $c, $out, ResponseInterface $exp): void {
+ if ($out instanceof \Exception) {
+ \Phake::when(Arsse::$db)->articleMark->thenThrow($out);
+ } else {
+ \Phake::when(Arsse::$db)->articleMark->thenReturn($out);
+ }
+ $this->assertMessage($exp, $this->req("PUT", $url));
+ if ($out !== null) {
+ \Phake::verify(Arsse::$db)->articleMark(Arsse::$user->id, ['read' => true], $c);
+ } else {
+ \Phake::verify(Arsse::$db, \Phake::times(0))->articleMark;
+ }
+ }
+
+ public function provideMassMarkings(): iterable {
+ self::clearData();
+ $c = (new Context)->hidden(false);
+ return [
+ ["/users/42/mark-all-as-read", $c, 1123, new EmptyResponse(204)],
+ ["/users/2112/mark-all-as-read", $c, null, new ErrorResponse("403", 403)],
+ ["/feeds/47/mark-all-as-read", (clone $c)->subscription(47), 2112, new EmptyResponse(204)],
+ ["/feeds/2112/mark-all-as-read", (clone $c)->subscription(2112), new ExceptionInput("idMissing"), new ErrorResponse("404", 404)],
+ ["/categories/47/mark-all-as-read", (clone $c)->folder(46), 1337, new EmptyResponse(204)],
+ ["/categories/2112/mark-all-as-read", (clone $c)->folder(2111), new ExceptionInput("idMissing"), new ErrorResponse("404", 404)],
+ ["/categories/1/mark-all-as-read", (clone $c)->folderShallow(0), 6666, new EmptyResponse(204)],
+ ];
+ }
+
+ /** @dataProvider provideBookmarkTogglings */
+ public function testToggleABookmark($before, ?bool $after, ResponseInterface $exp): void {
+ $c = (new Context)->article(2112);
+ \Phake::when(Arsse::$db)->articleMark->thenReturn(1);
+ if ($before instanceof \Exception) {
+ \Phake::when(Arsse::$db)->articleCount->thenThrow($before);
+ } else {
+ \Phake::when(Arsse::$db)->articleCount->thenReturn($before);
+ }
+ $this->assertMessage($exp, $this->req("PUT", "/entries/2112/bookmark"));
+ if ($after !== null) {
+ \Phake::inOrder(
+ \Phake::verify(Arsse::$db)->begin(),
+ \Phake::verify(Arsse::$db)->articleCount(Arsse::$user->id, (clone $c)->starred(false)),
+ \Phake::verify(Arsse::$db)->articleMark(Arsse::$user->id, ['starred' => $after], $c),
+ \Phake::verify($this->transaction)->commit()
+ );
+ } else {
+ \Phake::inOrder(
+ \Phake::verify(Arsse::$db)->begin(),
+ \Phake::verify(Arsse::$db)->articleCount(Arsse::$user->id, (clone $c)->starred(false))
+ );
+ \Phake::verify(Arsse::$db, \Phake::times(0))->articleMark;
+ \Phake::verifyNoInteraction($this->transaction);
+ }
+ }
+
+ public function provideBookmarkTogglings(): iterable {
+ self::clearData();
+ return [
+ [1, true, new EmptyResponse(204)],
+ [0, false, new EmptyResponse(204)],
+ [new ExceptionInput("subjectMissing"), null, new ErrorResponse("404", 404)],
+ ];
+ }
}
From dd29ef6c1bd9e026e4e7018b838b1574f6b54e96 Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Fri, 5 Feb 2021 09:04:00 -0500
Subject: [PATCH 159/366] Add feed refreshing stubs
---
lib/REST/Miniflux/V1.php | 16 ++++++++++++++++
tests/cases/REST/Miniflux/TestV1.php | 16 ++++++++++++++++
2 files changed, 32 insertions(+)
diff --git a/lib/REST/Miniflux/V1.php b/lib/REST/Miniflux/V1.php
index 51884ea4..2198ebb6 100644
--- a/lib/REST/Miniflux/V1.php
+++ b/lib/REST/Miniflux/V1.php
@@ -1161,6 +1161,22 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
return new EmptyResponse(204);
}
+ protected function refreshFeed(array $path): ResponseInterface {
+ // NOTE: This is a no-op; we simply check that the feed exists
+ try {
+ Arsse::$db->subscriptionPropertiesGet(Arsse::$user->id, (int) $path[1]);
+ } catch (ExceptionInput $e) {
+ return new ErrorResponse("404", 404);
+ }
+ return new EmptyResponse(204);
+ }
+
+ protected function refreshAllFeeds(): ResponseInterface {
+ // NOTE: This is a no-op
+ // It could be implemented, but the need is considered low since we use a dynamic schedule always
+ return new EmptyResponse(204);
+ }
+
public static function tokenGenerate(string $user, string $label): string {
// Miniflux produces tokenss in base64url alphabet
$t = str_replace(["+", "/"], ["-", "_"], base64_encode(random_bytes(self::TOKEN_LENGTH)));
diff --git a/tests/cases/REST/Miniflux/TestV1.php b/tests/cases/REST/Miniflux/TestV1.php
index 18982f34..22a53db6 100644
--- a/tests/cases/REST/Miniflux/TestV1.php
+++ b/tests/cases/REST/Miniflux/TestV1.php
@@ -921,4 +921,20 @@ class TestV1 extends \JKingWeb\Arsse\Test\AbstractTest {
[new ExceptionInput("subjectMissing"), null, new ErrorResponse("404", 404)],
];
}
+
+ public function testRefreshAFeed(): void {
+ \Phake::when(Arsse::$db)->subscriptionPropertiesGet->thenReturn([]);
+ $this->assertMessage(new EmptyResponse(204), $this->req("PUT", "/feeds/47/refresh"));
+ \Phake::verify(Arsse::$db)->subscriptionPropertiesGet(Arsse::$user->id, 47);
+ }
+
+ public function testRefreshAMissingFeed(): void {
+ \Phake::when(Arsse::$db)->subscriptionPropertiesGet->thenThrow(new ExceptionInput("subjectMissing"));
+ $this->assertMessage(new ErrorResponse("404", 404), $this->req("PUT", "/feeds/2112/refresh"));
+ \Phake::verify(Arsse::$db)->subscriptionPropertiesGet(Arsse::$user->id, 2112);
+ }
+
+ public function testRefreshAllFeeds(): void {
+ $this->assertMessage(new EmptyResponse(204), $this->req("PUT", "/feeds/refresh"));
+ }
}
From 681654f24938b29460ce5d72ddac98751d9141cc Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Fri, 5 Feb 2021 09:22:10 -0500
Subject: [PATCH 160/366] Documentation update
---
docs/en/030_Supported_Protocols/005_Miniflux.md | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/docs/en/030_Supported_Protocols/005_Miniflux.md b/docs/en/030_Supported_Protocols/005_Miniflux.md
index 62a27bd3..1bc47dde 100644
--- a/docs/en/030_Supported_Protocols/005_Miniflux.md
+++ b/docs/en/030_Supported_Protocols/005_Miniflux.md
@@ -13,9 +13,9 @@
API Reference , Filtering Rules
-The Miniflux protocol is a fairly well-designed protocol supporting a wide variety of operations on newsfeeds, folders (termed "categories"), and articles; it also allows for user administration, and native OPML importing and exporting. Architecturally it is similar to the Nextcloud News protocol, but is generally more efficient and has more capabilities.
+The Miniflux protocol is a fairly well-designed protocol supporting a wide variety of operations on newsfeeds, folders (termed "categories"), and articles; it also allows for user administration, and native OPML importing and exporting. Architecturally it is similar to the Nextcloud News protocol, but has more capabilities.
-Miniflux version 2.0.27 is emulated, though not all features are implemented
+Miniflux version 2.0.28 is emulated, though not all features are implemented
# Missing features
@@ -25,6 +25,7 @@ Miniflux version 2.0.27 is emulated, though not all features are implemented
- Custom User-Agent strings
- The `disabled`, `ignore_http_cache`, and `fetch_via_proxy` flags
- Changing the URL, username, or password of a feed
+ - Manually refreshing feeds
- Titles and types are not available during feed discovery and are filled with generic data
- Reading time is not calculated and will always be zero
- Only the first enclosure of an article is retained
From b4ae988b790513672dba1200e7bff7faed2e62d1 Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Fri, 5 Feb 2021 20:29:41 -0500
Subject: [PATCH 161/366] Prototype OPML handling
---
.../030_Supported_Protocols/005_Miniflux.md | 1 +
lib/AbstractException.php | 13 ++++++
lib/REST/Miniflux/V1.php | 40 ++++++++++++++++---
locale/en.php | 7 ++++
4 files changed, 56 insertions(+), 5 deletions(-)
diff --git a/docs/en/030_Supported_Protocols/005_Miniflux.md b/docs/en/030_Supported_Protocols/005_Miniflux.md
index 1bc47dde..2e6c23b1 100644
--- a/docs/en/030_Supported_Protocols/005_Miniflux.md
+++ b/docs/en/030_Supported_Protocols/005_Miniflux.md
@@ -42,6 +42,7 @@ Miniflux version 2.0.28 is emulated, though not all features are implemented
- Creating a feed with the `scrape` property set to `true` might not return scraped content for the initial synchronization
- Querying articles for both read/unread and removed statuses will not return all removed articles
- Search strings will match partial words
+- OPML import either succeeds or fails atomically: if one feed fails, no feeds are imported
# Behaviour of filtering (block and keep) rules
diff --git a/lib/AbstractException.php b/lib/AbstractException.php
index b6696c92..922b9cd8 100644
--- a/lib/AbstractException.php
+++ b/lib/AbstractException.php
@@ -104,7 +104,12 @@ abstract class AbstractException extends \Exception {
"Rule/Exception.invalidPattern" => 10701,
];
+ protected $symbol;
+ protected $params;
+
public function __construct(string $msgID = "", $vars = null, \Throwable $e = null) {
+ $this->symbol = $msgID;
+ $this->params = $vars ?? [];
if ($msgID === "") {
$msg = "Exception.unknown";
$code = 10000;
@@ -121,4 +126,12 @@ abstract class AbstractException extends \Exception {
}
parent::__construct($msg, $code, $e);
}
+
+ public function getSymbol(): string {
+ return $this->symbol;
+ }
+
+ public function getParams(): array {
+ return $this->aparams;
+ }
}
diff --git a/lib/REST/Miniflux/V1.php b/lib/REST/Miniflux/V1.php
index 2198ebb6..00c58f02 100644
--- a/lib/REST/Miniflux/V1.php
+++ b/lib/REST/Miniflux/V1.php
@@ -13,7 +13,8 @@ use JKingWeb\Arsse\Feed\Exception as FeedException;
use JKingWeb\Arsse\AbstractException;
use JKingWeb\Arsse\Context\Context;
use JKingWeb\Arsse\Db\ExceptionInput;
-use JKingWeb\Arsse\Misc\HTTP;
+use JKingWeb\Arsse\ImportExport\OPML;
+use JKingWeb\Arsse\ImportExport\Exception as ImportException;
use JKingWeb\Arsse\Misc\Date;
use JKingWeb\Arsse\Misc\URL;
use JKingWeb\Arsse\Misc\ValueInfo as V;
@@ -25,6 +26,7 @@ use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\ResponseInterface;
use Laminas\Diactoros\Response\EmptyResponse;
use Laminas\Diactoros\Response\JsonResponse as Response;
+use Laminas\Diactoros\Response\TextResponse as GenericResponse;
use Laminas\Diactoros\Uri;
class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
@@ -141,8 +143,8 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
'GET' => ["getCategoryFeeds", false, true, false, false, []],
],
'/categories/1/mark-all-as-read' => [
- 'PUT' => ["markCategory", false, true, false, false, []],
],
+ 'PUT' => ["markCategory", false, true, false, false, []],
'/discover' => [
'POST' => ["discoverSubscriptions", false, false, true, false, ["url"]],
],
@@ -212,6 +214,11 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
public function __construct() {
}
+ /** @codeCoverageIgnore */
+ protected function getInstance(string $class) {
+ return new $class;
+ }
+
protected function authenticate(ServerRequestInterface $req): bool {
// first check any tokens; this is what Miniflux does
if ($req->hasHeader("X-Auth-Token")) {
@@ -261,9 +268,6 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
}
if ($reqBody) {
if ($func === "opmlImport") {
- if (!HTTP::matchType($req, "", ...[self::ACCEPTED_TYPES_OPML])) {
- return new ErrorResponse("", 415, ['Accept' => implode(", ", self::ACCEPTED_TYPES_OPML)]);
- }
$args[] = (string) $req->getBody();
} else {
$data = (string) $req->getBody();
@@ -1177,6 +1181,32 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
return new EmptyResponse(204);
}
+ protected function opmlImport(string $data): ResponseInterface {
+ try {
+ $this->getInstance(OPML::class)->import(Arsse::$user->id, $data);
+ } catch (ImportException $e) {
+ switch ($e->getCode()) {
+ case 10611:
+ return new ErrorResponse("InvalidBodyXML", 400);
+ case 10612:
+ return new ErrorResponse("InvalidBodyOPML", 422);
+ case 10613:
+ return new ErrorResponse("InvalidImportCategory", 422);
+ case 10614:
+ return new ErrorResponse("DuplicateImportCatgory", 422);
+ case 10615:
+ return new ErrorResponse("InvalidImportLabel", 422);
+ }
+ } catch (FeedException $e) {
+ return new ErrorResponse(["FailedImportFeed", 'url' => $e->getParams()['url'], 'code' => $e->getCode()], 502);
+ }
+ return new Response(['message' => Arsse::$lang->msg("ImportSuccess")]);
+ }
+
+ protected function opmlExport(): ResponseInterface {
+ return new GenericResponse($this->getInstance(OPML::class)->export(Arsse::$user->id), 200, ['Content-Type' => "application/xml"]);
+ }
+
public static function tokenGenerate(string $user, string $label): string {
// Miniflux produces tokenss in base64url alphabet
$t = str_replace(["+", "/"], ["-", "_"], base64_encode(random_bytes(self::TOKEN_LENGTH)));
diff --git a/locale/en.php b/locale/en.php
index 03b0579f..470d09ea 100644
--- a/locale/en.php
+++ b/locale/en.php
@@ -8,12 +8,15 @@ return [
'CLI.Auth.Failure' => 'Authentication failed',
'API.Miniflux.DefaultCategoryName' => "All",
+ 'API.Miniflux.ImportSuccess' => 'Feeds imported successfully',
'API.Miniflux.Error.401' => 'Access Unauthorized',
'API.Miniflux.Error.403' => 'Access Forbidden',
'API.Miniflux.Error.404' => 'Resource Not Found',
'API.Miniflux.Error.MissingInputValue' => 'Required key "{field}" was not present in input',
'API.Miniflux.Error.DuplicateInputValue' => 'Key "{field}" accepts only one value',
'API.Miniflux.Error.InvalidBodyJSON' => 'Invalid JSON payload: {0}',
+ 'API.Miniflux.Error.InvalidBodyXML' => 'Invalid XML payload',
+ 'API.Miniflux.Error.InvalidBodyOPML' => 'Payload is not a valid OPML document',
'API.Miniflux.Error.InvalidInputType' => 'Input key "{field}" of type {actual} was expected as {expected}',
'API.Miniflux.Error.InvalidInputValue' => 'Supplied value is not valid for input key "{field}"',
'API.Miniflux.Error.Fetch404' => 'Resource not found (404), this feed doesn\'t exists anymore, check the feed URL',
@@ -28,6 +31,10 @@ return [
'API.Miniflux.Error.DuplicateUser' => 'The user name "{user}" already exists',
'API.Miniflux.Error.DuplicateFeed' => 'This feed already exists.',
'API.Miniflux.Error.InvalidTitle' => 'Invalid feed title',
+ 'API.Miniflux.Error.InvalidImportCategory' => 'Payload contains an invalid category name',
+ 'API.Miniflux.Error.DuplicateImportCategory' => 'Payload contains the same category name twice',
+ 'API.Miniflux.Error.FailedImportFeed' => 'Unable to import feed at URL "{url}" (code {code}',
+ 'API.Miniflux.Error.InvalidImportLabel' => 'Payload contains an invalid label name',
'API.TTRSS.Category.Uncategorized' => 'Uncategorized',
'API.TTRSS.Category.Special' => 'Special',
From a0d563e468f9781d818a65de7fb956390422223d Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Sat, 6 Feb 2021 21:48:27 -0500
Subject: [PATCH 162/366] Update dependencies
---
composer.lock | 36 ++---
vendor-bin/csfixer/composer.lock | 195 ++++++++++++-------------
vendor-bin/daux/composer.lock | 243 ++++++++++++-------------------
vendor-bin/phpunit/composer.lock | 41 +++---
vendor-bin/robo/composer.lock | 122 ++++++++--------
5 files changed, 285 insertions(+), 352 deletions(-)
diff --git a/composer.lock b/composer.lock
index b3e19e88..adc76eb0 100644
--- a/composer.lock
+++ b/composer.lock
@@ -949,16 +949,16 @@
},
{
"name": "symfony/polyfill-intl-idn",
- "version": "v1.20.0",
+ "version": "v1.22.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-intl-idn.git",
- "reference": "3b75acd829741c768bc8b1f84eb33265e7cc5117"
+ "reference": "0eb8293dbbcd6ef6bf81404c9ce7d95bcdf34f44"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/3b75acd829741c768bc8b1f84eb33265e7cc5117",
- "reference": "3b75acd829741c768bc8b1f84eb33265e7cc5117",
+ "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/0eb8293dbbcd6ef6bf81404c9ce7d95bcdf34f44",
+ "reference": "0eb8293dbbcd6ef6bf81404c9ce7d95bcdf34f44",
"shasum": ""
},
"require": {
@@ -972,7 +972,7 @@
"type": "library",
"extra": {
"branch-alias": {
- "dev-main": "1.20-dev"
+ "dev-main": "1.22-dev"
},
"thanks": {
"name": "symfony/polyfill",
@@ -1015,20 +1015,20 @@
"portable",
"shim"
],
- "time": "2020-10-23T14:02:19+00:00"
+ "time": "2021-01-07T16:49:33+00:00"
},
{
"name": "symfony/polyfill-intl-normalizer",
- "version": "v1.20.0",
+ "version": "v1.22.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-intl-normalizer.git",
- "reference": "727d1096295d807c309fb01a851577302394c897"
+ "reference": "6e971c891537eb617a00bb07a43d182a6915faba"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/727d1096295d807c309fb01a851577302394c897",
- "reference": "727d1096295d807c309fb01a851577302394c897",
+ "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/6e971c891537eb617a00bb07a43d182a6915faba",
+ "reference": "6e971c891537eb617a00bb07a43d182a6915faba",
"shasum": ""
},
"require": {
@@ -1040,7 +1040,7 @@
"type": "library",
"extra": {
"branch-alias": {
- "dev-main": "1.20-dev"
+ "dev-main": "1.22-dev"
},
"thanks": {
"name": "symfony/polyfill",
@@ -1082,20 +1082,20 @@
"portable",
"shim"
],
- "time": "2020-10-23T14:02:19+00:00"
+ "time": "2021-01-07T17:09:11+00:00"
},
{
"name": "symfony/polyfill-php72",
- "version": "v1.20.0",
+ "version": "v1.22.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-php72.git",
- "reference": "cede45fcdfabdd6043b3592e83678e42ec69e930"
+ "reference": "cc6e6f9b39fe8075b3dabfbaf5b5f645ae1340c9"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/polyfill-php72/zipball/cede45fcdfabdd6043b3592e83678e42ec69e930",
- "reference": "cede45fcdfabdd6043b3592e83678e42ec69e930",
+ "url": "https://api.github.com/repos/symfony/polyfill-php72/zipball/cc6e6f9b39fe8075b3dabfbaf5b5f645ae1340c9",
+ "reference": "cc6e6f9b39fe8075b3dabfbaf5b5f645ae1340c9",
"shasum": ""
},
"require": {
@@ -1104,7 +1104,7 @@
"type": "library",
"extra": {
"branch-alias": {
- "dev-main": "1.20-dev"
+ "dev-main": "1.22-dev"
},
"thanks": {
"name": "symfony/polyfill",
@@ -1141,7 +1141,7 @@
"portable",
"shim"
],
- "time": "2020-10-23T14:02:19+00:00"
+ "time": "2021-01-07T16:49:33+00:00"
}
],
"packages-dev": [
diff --git a/vendor-bin/csfixer/composer.lock b/vendor-bin/csfixer/composer.lock
index 17bfa2ef..70c3175d 100644
--- a/vendor-bin/csfixer/composer.lock
+++ b/vendor-bin/csfixer/composer.lock
@@ -262,16 +262,16 @@
},
{
"name": "friendsofphp/php-cs-fixer",
- "version": "v2.17.3",
+ "version": "v2.18.2",
"source": {
"type": "git",
"url": "https://github.com/FriendsOfPHP/PHP-CS-Fixer.git",
- "reference": "bd32f5dd72cdfc7b53f54077f980e144bfa2f595"
+ "reference": "18f8c9d184ba777380794a389fabc179896ba913"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/FriendsOfPHP/PHP-CS-Fixer/zipball/bd32f5dd72cdfc7b53f54077f980e144bfa2f595",
- "reference": "bd32f5dd72cdfc7b53f54077f980e144bfa2f595",
+ "url": "https://api.github.com/repos/FriendsOfPHP/PHP-CS-Fixer/zipball/18f8c9d184ba777380794a389fabc179896ba913",
+ "reference": "18f8c9d184ba777380794a389fabc179896ba913",
"shasum": ""
},
"require": {
@@ -293,7 +293,6 @@
"symfony/stopwatch": "^3.0 || ^4.0 || ^5.0"
},
"require-dev": {
- "johnkary/phpunit-speedtrap": "^1.1 || ^2.0 || ^3.0",
"justinrainbow/json-schema": "^5.0",
"keradus/cli-executor": "^1.4",
"mikey179/vfsstream": "^1.6",
@@ -302,11 +301,11 @@
"php-cs-fixer/phpunit-constraint-isidenticalstring": "^1.2",
"php-cs-fixer/phpunit-constraint-xmlmatchesxsd": "^1.2.1",
"phpspec/prophecy-phpunit": "^1.1 || ^2.0",
- "phpunit/phpunit": "^5.7.27 || ^6.5.14 || ^7.5.20 || ^8.5.13 || ^9.4.4 <9.5",
+ "phpunit/phpunit": "^5.7.27 || ^6.5.14 || ^7.5.20 || ^8.5.13 || ^9.5",
"phpunitgoodpractices/polyfill": "^1.5",
"phpunitgoodpractices/traits": "^1.9.1",
"sanmai/phpunit-legacy-adapter": "^6.4 || ^8.2.1",
- "symfony/phpunit-bridge": "^5.1",
+ "symfony/phpunit-bridge": "^5.2.1",
"symfony/yaml": "^3.0 || ^4.0 || ^5.0"
},
"suggest": {
@@ -352,7 +351,7 @@
}
],
"description": "A tool to automatically fix PHP code style",
- "time": "2020-12-24T11:14:44+00:00"
+ "time": "2021-01-26T00:22:21+00:00"
},
{
"name": "php-cs-fixer/diff",
@@ -549,16 +548,16 @@
},
{
"name": "symfony/console",
- "version": "v5.2.1",
+ "version": "v5.2.3",
"source": {
"type": "git",
"url": "https://github.com/symfony/console.git",
- "reference": "47c02526c532fb381374dab26df05e7313978976"
+ "reference": "89d4b176d12a2946a1ae4e34906a025b7b6b135a"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/console/zipball/47c02526c532fb381374dab26df05e7313978976",
- "reference": "47c02526c532fb381374dab26df05e7313978976",
+ "url": "https://api.github.com/repos/symfony/console/zipball/89d4b176d12a2946a1ae4e34906a025b7b6b135a",
+ "reference": "89d4b176d12a2946a1ae4e34906a025b7b6b135a",
"shasum": ""
},
"require": {
@@ -617,7 +616,7 @@
"homepage": "https://symfony.com/contributors"
}
],
- "description": "Symfony Console Component",
+ "description": "Eases the creation of beautiful and testable command line interfaces",
"homepage": "https://symfony.com",
"keywords": [
"cli",
@@ -625,7 +624,7 @@
"console",
"terminal"
],
- "time": "2020-12-18T08:03:05+00:00"
+ "time": "2021-01-28T22:06:19+00:00"
},
{
"name": "symfony/deprecation-contracts",
@@ -679,16 +678,16 @@
},
{
"name": "symfony/event-dispatcher",
- "version": "v5.2.1",
+ "version": "v5.2.3",
"source": {
"type": "git",
"url": "https://github.com/symfony/event-dispatcher.git",
- "reference": "1c93f7a1dff592c252574c79a8635a8a80856042"
+ "reference": "4f9760f8074978ad82e2ce854dff79a71fe45367"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/1c93f7a1dff592c252574c79a8635a8a80856042",
- "reference": "1c93f7a1dff592c252574c79a8635a8a80856042",
+ "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/4f9760f8074978ad82e2ce854dff79a71fe45367",
+ "reference": "4f9760f8074978ad82e2ce854dff79a71fe45367",
"shasum": ""
},
"require": {
@@ -741,9 +740,9 @@
"homepage": "https://symfony.com/contributors"
}
],
- "description": "Symfony EventDispatcher Component",
+ "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them",
"homepage": "https://symfony.com",
- "time": "2020-12-18T08:03:05+00:00"
+ "time": "2021-01-27T10:36:42+00:00"
},
{
"name": "symfony/event-dispatcher-contracts",
@@ -809,16 +808,16 @@
},
{
"name": "symfony/filesystem",
- "version": "v5.2.1",
+ "version": "v5.2.3",
"source": {
"type": "git",
"url": "https://github.com/symfony/filesystem.git",
- "reference": "fa8f8cab6b65e2d99a118e082935344c5ba8c60d"
+ "reference": "262d033b57c73e8b59cd6e68a45c528318b15038"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/filesystem/zipball/fa8f8cab6b65e2d99a118e082935344c5ba8c60d",
- "reference": "fa8f8cab6b65e2d99a118e082935344c5ba8c60d",
+ "url": "https://api.github.com/repos/symfony/filesystem/zipball/262d033b57c73e8b59cd6e68a45c528318b15038",
+ "reference": "262d033b57c73e8b59cd6e68a45c528318b15038",
"shasum": ""
},
"require": {
@@ -848,22 +847,22 @@
"homepage": "https://symfony.com/contributors"
}
],
- "description": "Symfony Filesystem Component",
+ "description": "Provides basic utilities for the filesystem",
"homepage": "https://symfony.com",
- "time": "2020-11-30T17:05:38+00:00"
+ "time": "2021-01-27T10:01:46+00:00"
},
{
"name": "symfony/finder",
- "version": "v5.2.1",
+ "version": "v5.2.3",
"source": {
"type": "git",
"url": "https://github.com/symfony/finder.git",
- "reference": "0b9231a5922fd7287ba5b411893c0ecd2733e5ba"
+ "reference": "4adc8d172d602008c204c2e16956f99257248e03"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/finder/zipball/0b9231a5922fd7287ba5b411893c0ecd2733e5ba",
- "reference": "0b9231a5922fd7287ba5b411893c0ecd2733e5ba",
+ "url": "https://api.github.com/repos/symfony/finder/zipball/4adc8d172d602008c204c2e16956f99257248e03",
+ "reference": "4adc8d172d602008c204c2e16956f99257248e03",
"shasum": ""
},
"require": {
@@ -892,22 +891,22 @@
"homepage": "https://symfony.com/contributors"
}
],
- "description": "Symfony Finder Component",
+ "description": "Finds files and directories via an intuitive fluent interface",
"homepage": "https://symfony.com",
- "time": "2020-12-08T17:02:38+00:00"
+ "time": "2021-01-28T22:06:19+00:00"
},
{
"name": "symfony/options-resolver",
- "version": "v5.2.1",
+ "version": "v5.2.3",
"source": {
"type": "git",
"url": "https://github.com/symfony/options-resolver.git",
- "reference": "87a2a4a766244e796dd9cb9d6f58c123358cd986"
+ "reference": "5d0f633f9bbfcf7ec642a2b5037268e61b0a62ce"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/options-resolver/zipball/87a2a4a766244e796dd9cb9d6f58c123358cd986",
- "reference": "87a2a4a766244e796dd9cb9d6f58c123358cd986",
+ "url": "https://api.github.com/repos/symfony/options-resolver/zipball/5d0f633f9bbfcf7ec642a2b5037268e61b0a62ce",
+ "reference": "5d0f633f9bbfcf7ec642a2b5037268e61b0a62ce",
"shasum": ""
},
"require": {
@@ -939,27 +938,27 @@
"homepage": "https://symfony.com/contributors"
}
],
- "description": "Symfony OptionsResolver Component",
+ "description": "Provides an improved replacement for the array_replace PHP function",
"homepage": "https://symfony.com",
"keywords": [
"config",
"configuration",
"options"
],
- "time": "2020-10-24T12:08:07+00:00"
+ "time": "2021-01-27T12:56:27+00:00"
},
{
"name": "symfony/polyfill-ctype",
- "version": "v1.20.0",
+ "version": "v1.22.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-ctype.git",
- "reference": "f4ba089a5b6366e453971d3aad5fe8e897b37f41"
+ "reference": "c6c942b1ac76c82448322025e084cadc56048b4e"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/f4ba089a5b6366e453971d3aad5fe8e897b37f41",
- "reference": "f4ba089a5b6366e453971d3aad5fe8e897b37f41",
+ "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/c6c942b1ac76c82448322025e084cadc56048b4e",
+ "reference": "c6c942b1ac76c82448322025e084cadc56048b4e",
"shasum": ""
},
"require": {
@@ -971,7 +970,7 @@
"type": "library",
"extra": {
"branch-alias": {
- "dev-main": "1.20-dev"
+ "dev-main": "1.22-dev"
},
"thanks": {
"name": "symfony/polyfill",
@@ -1008,20 +1007,20 @@
"polyfill",
"portable"
],
- "time": "2020-10-23T14:02:19+00:00"
+ "time": "2021-01-07T16:49:33+00:00"
},
{
"name": "symfony/polyfill-intl-grapheme",
- "version": "v1.20.0",
+ "version": "v1.22.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-intl-grapheme.git",
- "reference": "c7cf3f858ec7d70b89559d6e6eb1f7c2517d479c"
+ "reference": "267a9adeb8ecb8071040a740930e077cdfb987af"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/c7cf3f858ec7d70b89559d6e6eb1f7c2517d479c",
- "reference": "c7cf3f858ec7d70b89559d6e6eb1f7c2517d479c",
+ "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/267a9adeb8ecb8071040a740930e077cdfb987af",
+ "reference": "267a9adeb8ecb8071040a740930e077cdfb987af",
"shasum": ""
},
"require": {
@@ -1033,7 +1032,7 @@
"type": "library",
"extra": {
"branch-alias": {
- "dev-main": "1.20-dev"
+ "dev-main": "1.22-dev"
},
"thanks": {
"name": "symfony/polyfill",
@@ -1072,20 +1071,20 @@
"portable",
"shim"
],
- "time": "2020-10-23T14:02:19+00:00"
+ "time": "2021-01-07T16:49:33+00:00"
},
{
"name": "symfony/polyfill-intl-normalizer",
- "version": "v1.20.0",
+ "version": "v1.22.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-intl-normalizer.git",
- "reference": "727d1096295d807c309fb01a851577302394c897"
+ "reference": "6e971c891537eb617a00bb07a43d182a6915faba"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/727d1096295d807c309fb01a851577302394c897",
- "reference": "727d1096295d807c309fb01a851577302394c897",
+ "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/6e971c891537eb617a00bb07a43d182a6915faba",
+ "reference": "6e971c891537eb617a00bb07a43d182a6915faba",
"shasum": ""
},
"require": {
@@ -1097,7 +1096,7 @@
"type": "library",
"extra": {
"branch-alias": {
- "dev-main": "1.20-dev"
+ "dev-main": "1.22-dev"
},
"thanks": {
"name": "symfony/polyfill",
@@ -1139,20 +1138,20 @@
"portable",
"shim"
],
- "time": "2020-10-23T14:02:19+00:00"
+ "time": "2021-01-07T17:09:11+00:00"
},
{
"name": "symfony/polyfill-mbstring",
- "version": "v1.20.0",
+ "version": "v1.22.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-mbstring.git",
- "reference": "39d483bdf39be819deabf04ec872eb0b2410b531"
+ "reference": "f377a3dd1fde44d37b9831d68dc8dea3ffd28e13"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/39d483bdf39be819deabf04ec872eb0b2410b531",
- "reference": "39d483bdf39be819deabf04ec872eb0b2410b531",
+ "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/f377a3dd1fde44d37b9831d68dc8dea3ffd28e13",
+ "reference": "f377a3dd1fde44d37b9831d68dc8dea3ffd28e13",
"shasum": ""
},
"require": {
@@ -1164,7 +1163,7 @@
"type": "library",
"extra": {
"branch-alias": {
- "dev-main": "1.20-dev"
+ "dev-main": "1.22-dev"
},
"thanks": {
"name": "symfony/polyfill",
@@ -1202,7 +1201,7 @@
"portable",
"shim"
],
- "time": "2020-10-23T14:02:19+00:00"
+ "time": "2021-01-07T16:49:33+00:00"
},
{
"name": "symfony/polyfill-php70",
@@ -1257,16 +1256,16 @@
},
{
"name": "symfony/polyfill-php72",
- "version": "v1.20.0",
+ "version": "v1.22.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-php72.git",
- "reference": "cede45fcdfabdd6043b3592e83678e42ec69e930"
+ "reference": "cc6e6f9b39fe8075b3dabfbaf5b5f645ae1340c9"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/polyfill-php72/zipball/cede45fcdfabdd6043b3592e83678e42ec69e930",
- "reference": "cede45fcdfabdd6043b3592e83678e42ec69e930",
+ "url": "https://api.github.com/repos/symfony/polyfill-php72/zipball/cc6e6f9b39fe8075b3dabfbaf5b5f645ae1340c9",
+ "reference": "cc6e6f9b39fe8075b3dabfbaf5b5f645ae1340c9",
"shasum": ""
},
"require": {
@@ -1275,7 +1274,7 @@
"type": "library",
"extra": {
"branch-alias": {
- "dev-main": "1.20-dev"
+ "dev-main": "1.22-dev"
},
"thanks": {
"name": "symfony/polyfill",
@@ -1312,20 +1311,20 @@
"portable",
"shim"
],
- "time": "2020-10-23T14:02:19+00:00"
+ "time": "2021-01-07T16:49:33+00:00"
},
{
"name": "symfony/polyfill-php73",
- "version": "v1.20.0",
+ "version": "v1.22.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-php73.git",
- "reference": "8ff431c517be11c78c48a39a66d37431e26a6bed"
+ "reference": "a678b42e92f86eca04b7fa4c0f6f19d097fb69e2"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/polyfill-php73/zipball/8ff431c517be11c78c48a39a66d37431e26a6bed",
- "reference": "8ff431c517be11c78c48a39a66d37431e26a6bed",
+ "url": "https://api.github.com/repos/symfony/polyfill-php73/zipball/a678b42e92f86eca04b7fa4c0f6f19d097fb69e2",
+ "reference": "a678b42e92f86eca04b7fa4c0f6f19d097fb69e2",
"shasum": ""
},
"require": {
@@ -1334,7 +1333,7 @@
"type": "library",
"extra": {
"branch-alias": {
- "dev-main": "1.20-dev"
+ "dev-main": "1.22-dev"
},
"thanks": {
"name": "symfony/polyfill",
@@ -1374,20 +1373,20 @@
"portable",
"shim"
],
- "time": "2020-10-23T14:02:19+00:00"
+ "time": "2021-01-07T16:49:33+00:00"
},
{
"name": "symfony/polyfill-php80",
- "version": "v1.20.0",
+ "version": "v1.22.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-php80.git",
- "reference": "e70aa8b064c5b72d3df2abd5ab1e90464ad009de"
+ "reference": "dc3063ba22c2a1fd2f45ed856374d79114998f91"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/e70aa8b064c5b72d3df2abd5ab1e90464ad009de",
- "reference": "e70aa8b064c5b72d3df2abd5ab1e90464ad009de",
+ "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/dc3063ba22c2a1fd2f45ed856374d79114998f91",
+ "reference": "dc3063ba22c2a1fd2f45ed856374d79114998f91",
"shasum": ""
},
"require": {
@@ -1396,7 +1395,7 @@
"type": "library",
"extra": {
"branch-alias": {
- "dev-main": "1.20-dev"
+ "dev-main": "1.22-dev"
},
"thanks": {
"name": "symfony/polyfill",
@@ -1440,20 +1439,20 @@
"portable",
"shim"
],
- "time": "2020-10-23T14:02:19+00:00"
+ "time": "2021-01-07T16:49:33+00:00"
},
{
"name": "symfony/process",
- "version": "v5.2.1",
+ "version": "v5.2.3",
"source": {
"type": "git",
"url": "https://github.com/symfony/process.git",
- "reference": "bd8815b8b6705298beaa384f04fabd459c10bedd"
+ "reference": "313a38f09c77fbcdc1d223e57d368cea76a2fd2f"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/process/zipball/bd8815b8b6705298beaa384f04fabd459c10bedd",
- "reference": "bd8815b8b6705298beaa384f04fabd459c10bedd",
+ "url": "https://api.github.com/repos/symfony/process/zipball/313a38f09c77fbcdc1d223e57d368cea76a2fd2f",
+ "reference": "313a38f09c77fbcdc1d223e57d368cea76a2fd2f",
"shasum": ""
},
"require": {
@@ -1483,9 +1482,9 @@
"homepage": "https://symfony.com/contributors"
}
],
- "description": "Symfony Process Component",
+ "description": "Executes commands in sub-processes",
"homepage": "https://symfony.com",
- "time": "2020-12-08T17:03:37+00:00"
+ "time": "2021-01-27T10:15:41+00:00"
},
{
"name": "symfony/service-contracts",
@@ -1551,16 +1550,16 @@
},
{
"name": "symfony/stopwatch",
- "version": "v5.2.1",
+ "version": "v5.2.3",
"source": {
"type": "git",
"url": "https://github.com/symfony/stopwatch.git",
- "reference": "2b105c0354f39a63038a1d8bf776ee92852813af"
+ "reference": "b12274acfab9d9850c52583d136a24398cdf1a0c"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/stopwatch/zipball/2b105c0354f39a63038a1d8bf776ee92852813af",
- "reference": "2b105c0354f39a63038a1d8bf776ee92852813af",
+ "url": "https://api.github.com/repos/symfony/stopwatch/zipball/b12274acfab9d9850c52583d136a24398cdf1a0c",
+ "reference": "b12274acfab9d9850c52583d136a24398cdf1a0c",
"shasum": ""
},
"require": {
@@ -1590,22 +1589,22 @@
"homepage": "https://symfony.com/contributors"
}
],
- "description": "Symfony Stopwatch Component",
+ "description": "Provides a way to profile code",
"homepage": "https://symfony.com",
- "time": "2020-11-01T16:14:45+00:00"
+ "time": "2021-01-27T10:15:41+00:00"
},
{
"name": "symfony/string",
- "version": "v5.2.1",
+ "version": "v5.2.3",
"source": {
"type": "git",
"url": "https://github.com/symfony/string.git",
- "reference": "5bd67751d2e3f7d6f770c9154b8fbcb2aa05f7ed"
+ "reference": "c95468897f408dd0aca2ff582074423dd0455122"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/string/zipball/5bd67751d2e3f7d6f770c9154b8fbcb2aa05f7ed",
- "reference": "5bd67751d2e3f7d6f770c9154b8fbcb2aa05f7ed",
+ "url": "https://api.github.com/repos/symfony/string/zipball/c95468897f408dd0aca2ff582074423dd0455122",
+ "reference": "c95468897f408dd0aca2ff582074423dd0455122",
"shasum": ""
},
"require": {
@@ -1648,7 +1647,7 @@
"homepage": "https://symfony.com/contributors"
}
],
- "description": "Symfony String component",
+ "description": "Provides an object-oriented API to strings and deals with bytes, UTF-8 code points and grapheme clusters in a unified way",
"homepage": "https://symfony.com",
"keywords": [
"grapheme",
@@ -1658,7 +1657,7 @@
"utf-8",
"utf8"
],
- "time": "2020-12-05T07:33:16+00:00"
+ "time": "2021-01-25T15:14:59+00:00"
}
],
"aliases": [],
diff --git a/vendor-bin/daux/composer.lock b/vendor-bin/daux/composer.lock
index 35bc4c60..2ed3ed66 100644
--- a/vendor-bin/daux/composer.lock
+++ b/vendor-bin/daux/composer.lock
@@ -655,16 +655,16 @@
},
{
"name": "symfony/console",
- "version": "v4.4.18",
+ "version": "v4.4.19",
"source": {
"type": "git",
"url": "https://github.com/symfony/console.git",
- "reference": "12e071278e396cc3e1c149857337e9e192deca0b"
+ "reference": "24026c44fc37099fa145707fecd43672831b837a"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/console/zipball/12e071278e396cc3e1c149857337e9e192deca0b",
- "reference": "12e071278e396cc3e1c149857337e9e192deca0b",
+ "url": "https://api.github.com/repos/symfony/console/zipball/24026c44fc37099fa145707fecd43672831b837a",
+ "reference": "24026c44fc37099fa145707fecd43672831b837a",
"shasum": ""
},
"require": {
@@ -721,9 +721,9 @@
"homepage": "https://symfony.com/contributors"
}
],
- "description": "Symfony Console Component",
+ "description": "Eases the creation of beautiful and testable command line interfaces",
"homepage": "https://symfony.com",
- "time": "2020-12-18T07:41:31+00:00"
+ "time": "2021-01-27T09:09:26+00:00"
},
{
"name": "symfony/deprecation-contracts",
@@ -780,16 +780,16 @@
},
{
"name": "symfony/http-foundation",
- "version": "v4.4.18",
+ "version": "v4.4.19",
"source": {
"type": "git",
"url": "https://github.com/symfony/http-foundation.git",
- "reference": "5ebda66b51612516bf338d5f87da2f37ff74cf34"
+ "reference": "8888741b633f6c3d1e572b7735ad2cae3e03f9c5"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/http-foundation/zipball/5ebda66b51612516bf338d5f87da2f37ff74cf34",
- "reference": "5ebda66b51612516bf338d5f87da2f37ff74cf34",
+ "url": "https://api.github.com/repos/symfony/http-foundation/zipball/8888741b633f6c3d1e572b7735ad2cae3e03f9c5",
+ "reference": "8888741b633f6c3d1e572b7735ad2cae3e03f9c5",
"shasum": ""
},
"require": {
@@ -825,93 +825,22 @@
"homepage": "https://symfony.com/contributors"
}
],
- "description": "Symfony HttpFoundation Component",
+ "description": "Defines an object-oriented layer for the HTTP specification",
"homepage": "https://symfony.com",
- "time": "2020-12-18T07:41:31+00:00"
- },
- {
- "name": "symfony/intl",
- "version": "v5.2.1",
- "source": {
- "type": "git",
- "url": "https://github.com/symfony/intl.git",
- "reference": "53927f98c9201fe5db3cfc4d574b1f4039020297"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/symfony/intl/zipball/53927f98c9201fe5db3cfc4d574b1f4039020297",
- "reference": "53927f98c9201fe5db3cfc4d574b1f4039020297",
- "shasum": ""
- },
- "require": {
- "php": ">=7.2.5",
- "symfony/polyfill-intl-icu": "~1.0",
- "symfony/polyfill-php80": "^1.15"
- },
- "require-dev": {
- "symfony/filesystem": "^4.4|^5.0"
- },
- "suggest": {
- "ext-intl": "to use the component with locales other than \"en\""
- },
- "type": "library",
- "autoload": {
- "psr-4": {
- "Symfony\\Component\\Intl\\": ""
- },
- "classmap": [
- "Resources/stubs"
- ],
- "exclude-from-classmap": [
- "/Tests/"
- ]
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "authors": [
- {
- "name": "Bernhard Schussek",
- "email": "bschussek@gmail.com"
- },
- {
- "name": "Eriksen Costa",
- "email": "eriksen.costa@infranology.com.br"
- },
- {
- "name": "Igor Wiedler",
- "email": "igor@wiedler.ch"
- },
- {
- "name": "Symfony Community",
- "homepage": "https://symfony.com/contributors"
- }
- ],
- "description": "A PHP replacement layer for the C intl extension that includes additional data from the ICU library.",
- "homepage": "https://symfony.com",
- "keywords": [
- "i18n",
- "icu",
- "internationalization",
- "intl",
- "l10n",
- "localization"
- ],
- "time": "2020-12-14T10:10:03+00:00"
+ "time": "2021-01-27T09:09:26+00:00"
},
{
"name": "symfony/mime",
- "version": "v5.2.1",
+ "version": "v5.2.3",
"source": {
"type": "git",
"url": "https://github.com/symfony/mime.git",
- "reference": "de97005aef7426ba008c46ba840fc301df577ada"
+ "reference": "7dee6a43493f39b51ff6c5bb2bd576fe40a76c86"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/mime/zipball/de97005aef7426ba008c46ba840fc301df577ada",
- "reference": "de97005aef7426ba008c46ba840fc301df577ada",
+ "url": "https://api.github.com/repos/symfony/mime/zipball/7dee6a43493f39b51ff6c5bb2bd576fe40a76c86",
+ "reference": "7dee6a43493f39b51ff6c5bb2bd576fe40a76c86",
"shasum": ""
},
"require": {
@@ -922,6 +851,8 @@
"symfony/polyfill-php80": "^1.15"
},
"conflict": {
+ "phpdocumentor/reflection-docblock": "<3.2.2",
+ "phpdocumentor/type-resolver": "<1.4.0",
"symfony/mailer": "<4.4"
},
"require-dev": {
@@ -955,26 +886,26 @@
"homepage": "https://symfony.com/contributors"
}
],
- "description": "A library to manipulate MIME messages",
+ "description": "Allows manipulating MIME messages",
"homepage": "https://symfony.com",
"keywords": [
"mime",
"mime-type"
],
- "time": "2020-12-09T18:54:12+00:00"
+ "time": "2021-02-02T06:10:15+00:00"
},
{
"name": "symfony/polyfill-ctype",
- "version": "v1.20.0",
+ "version": "v1.22.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-ctype.git",
- "reference": "f4ba089a5b6366e453971d3aad5fe8e897b37f41"
+ "reference": "c6c942b1ac76c82448322025e084cadc56048b4e"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/f4ba089a5b6366e453971d3aad5fe8e897b37f41",
- "reference": "f4ba089a5b6366e453971d3aad5fe8e897b37f41",
+ "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/c6c942b1ac76c82448322025e084cadc56048b4e",
+ "reference": "c6c942b1ac76c82448322025e084cadc56048b4e",
"shasum": ""
},
"require": {
@@ -986,7 +917,7 @@
"type": "library",
"extra": {
"branch-alias": {
- "dev-main": "1.20-dev"
+ "dev-main": "1.22-dev"
},
"thanks": {
"name": "symfony/polyfill",
@@ -1023,33 +954,32 @@
"polyfill",
"portable"
],
- "time": "2020-10-23T14:02:19+00:00"
+ "time": "2021-01-07T16:49:33+00:00"
},
{
"name": "symfony/polyfill-intl-icu",
- "version": "v1.20.0",
+ "version": "v1.22.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-intl-icu.git",
- "reference": "c44d5bf6a75eed79555c6bf37505c6d39559353e"
+ "reference": "b2b1e732a6c039f1a3ea3414b3379a2433e183d6"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/polyfill-intl-icu/zipball/c44d5bf6a75eed79555c6bf37505c6d39559353e",
- "reference": "c44d5bf6a75eed79555c6bf37505c6d39559353e",
+ "url": "https://api.github.com/repos/symfony/polyfill-intl-icu/zipball/b2b1e732a6c039f1a3ea3414b3379a2433e183d6",
+ "reference": "b2b1e732a6c039f1a3ea3414b3379a2433e183d6",
"shasum": ""
},
"require": {
- "php": ">=7.1",
- "symfony/intl": "~2.3|~3.0|~4.0|~5.0"
+ "php": ">=7.1"
},
"suggest": {
- "ext-intl": "For best performance"
+ "ext-intl": "For best performance and support of other locales than \"en\""
},
"type": "library",
"extra": {
"branch-alias": {
- "dev-main": "1.20-dev"
+ "dev-main": "1.22-dev"
},
"thanks": {
"name": "symfony/polyfill",
@@ -1059,6 +989,15 @@
"autoload": {
"files": [
"bootstrap.php"
+ ],
+ "psr-4": {
+ "Symfony\\Polyfill\\Intl\\Icu\\": ""
+ },
+ "classmap": [
+ "Resources/stubs"
+ ],
+ "exclude-from-classmap": [
+ "/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
@@ -1085,20 +1024,20 @@
"portable",
"shim"
],
- "time": "2020-10-23T14:02:19+00:00"
+ "time": "2021-01-07T16:49:33+00:00"
},
{
"name": "symfony/polyfill-intl-idn",
- "version": "v1.20.0",
+ "version": "v1.22.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-intl-idn.git",
- "reference": "3b75acd829741c768bc8b1f84eb33265e7cc5117"
+ "reference": "0eb8293dbbcd6ef6bf81404c9ce7d95bcdf34f44"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/3b75acd829741c768bc8b1f84eb33265e7cc5117",
- "reference": "3b75acd829741c768bc8b1f84eb33265e7cc5117",
+ "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/0eb8293dbbcd6ef6bf81404c9ce7d95bcdf34f44",
+ "reference": "0eb8293dbbcd6ef6bf81404c9ce7d95bcdf34f44",
"shasum": ""
},
"require": {
@@ -1112,7 +1051,7 @@
"type": "library",
"extra": {
"branch-alias": {
- "dev-main": "1.20-dev"
+ "dev-main": "1.22-dev"
},
"thanks": {
"name": "symfony/polyfill",
@@ -1155,20 +1094,20 @@
"portable",
"shim"
],
- "time": "2020-10-23T14:02:19+00:00"
+ "time": "2021-01-07T16:49:33+00:00"
},
{
"name": "symfony/polyfill-intl-normalizer",
- "version": "v1.20.0",
+ "version": "v1.22.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-intl-normalizer.git",
- "reference": "727d1096295d807c309fb01a851577302394c897"
+ "reference": "6e971c891537eb617a00bb07a43d182a6915faba"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/727d1096295d807c309fb01a851577302394c897",
- "reference": "727d1096295d807c309fb01a851577302394c897",
+ "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/6e971c891537eb617a00bb07a43d182a6915faba",
+ "reference": "6e971c891537eb617a00bb07a43d182a6915faba",
"shasum": ""
},
"require": {
@@ -1180,7 +1119,7 @@
"type": "library",
"extra": {
"branch-alias": {
- "dev-main": "1.20-dev"
+ "dev-main": "1.22-dev"
},
"thanks": {
"name": "symfony/polyfill",
@@ -1222,20 +1161,20 @@
"portable",
"shim"
],
- "time": "2020-10-23T14:02:19+00:00"
+ "time": "2021-01-07T17:09:11+00:00"
},
{
"name": "symfony/polyfill-mbstring",
- "version": "v1.20.0",
+ "version": "v1.22.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-mbstring.git",
- "reference": "39d483bdf39be819deabf04ec872eb0b2410b531"
+ "reference": "f377a3dd1fde44d37b9831d68dc8dea3ffd28e13"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/39d483bdf39be819deabf04ec872eb0b2410b531",
- "reference": "39d483bdf39be819deabf04ec872eb0b2410b531",
+ "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/f377a3dd1fde44d37b9831d68dc8dea3ffd28e13",
+ "reference": "f377a3dd1fde44d37b9831d68dc8dea3ffd28e13",
"shasum": ""
},
"require": {
@@ -1247,7 +1186,7 @@
"type": "library",
"extra": {
"branch-alias": {
- "dev-main": "1.20-dev"
+ "dev-main": "1.22-dev"
},
"thanks": {
"name": "symfony/polyfill",
@@ -1285,20 +1224,20 @@
"portable",
"shim"
],
- "time": "2020-10-23T14:02:19+00:00"
+ "time": "2021-01-07T16:49:33+00:00"
},
{
"name": "symfony/polyfill-php72",
- "version": "v1.20.0",
+ "version": "v1.22.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-php72.git",
- "reference": "cede45fcdfabdd6043b3592e83678e42ec69e930"
+ "reference": "cc6e6f9b39fe8075b3dabfbaf5b5f645ae1340c9"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/polyfill-php72/zipball/cede45fcdfabdd6043b3592e83678e42ec69e930",
- "reference": "cede45fcdfabdd6043b3592e83678e42ec69e930",
+ "url": "https://api.github.com/repos/symfony/polyfill-php72/zipball/cc6e6f9b39fe8075b3dabfbaf5b5f645ae1340c9",
+ "reference": "cc6e6f9b39fe8075b3dabfbaf5b5f645ae1340c9",
"shasum": ""
},
"require": {
@@ -1307,7 +1246,7 @@
"type": "library",
"extra": {
"branch-alias": {
- "dev-main": "1.20-dev"
+ "dev-main": "1.22-dev"
},
"thanks": {
"name": "symfony/polyfill",
@@ -1344,20 +1283,20 @@
"portable",
"shim"
],
- "time": "2020-10-23T14:02:19+00:00"
+ "time": "2021-01-07T16:49:33+00:00"
},
{
"name": "symfony/polyfill-php73",
- "version": "v1.20.0",
+ "version": "v1.22.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-php73.git",
- "reference": "8ff431c517be11c78c48a39a66d37431e26a6bed"
+ "reference": "a678b42e92f86eca04b7fa4c0f6f19d097fb69e2"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/polyfill-php73/zipball/8ff431c517be11c78c48a39a66d37431e26a6bed",
- "reference": "8ff431c517be11c78c48a39a66d37431e26a6bed",
+ "url": "https://api.github.com/repos/symfony/polyfill-php73/zipball/a678b42e92f86eca04b7fa4c0f6f19d097fb69e2",
+ "reference": "a678b42e92f86eca04b7fa4c0f6f19d097fb69e2",
"shasum": ""
},
"require": {
@@ -1366,7 +1305,7 @@
"type": "library",
"extra": {
"branch-alias": {
- "dev-main": "1.20-dev"
+ "dev-main": "1.22-dev"
},
"thanks": {
"name": "symfony/polyfill",
@@ -1406,20 +1345,20 @@
"portable",
"shim"
],
- "time": "2020-10-23T14:02:19+00:00"
+ "time": "2021-01-07T16:49:33+00:00"
},
{
"name": "symfony/polyfill-php80",
- "version": "v1.20.0",
+ "version": "v1.22.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-php80.git",
- "reference": "e70aa8b064c5b72d3df2abd5ab1e90464ad009de"
+ "reference": "dc3063ba22c2a1fd2f45ed856374d79114998f91"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/e70aa8b064c5b72d3df2abd5ab1e90464ad009de",
- "reference": "e70aa8b064c5b72d3df2abd5ab1e90464ad009de",
+ "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/dc3063ba22c2a1fd2f45ed856374d79114998f91",
+ "reference": "dc3063ba22c2a1fd2f45ed856374d79114998f91",
"shasum": ""
},
"require": {
@@ -1428,7 +1367,7 @@
"type": "library",
"extra": {
"branch-alias": {
- "dev-main": "1.20-dev"
+ "dev-main": "1.22-dev"
},
"thanks": {
"name": "symfony/polyfill",
@@ -1472,20 +1411,20 @@
"portable",
"shim"
],
- "time": "2020-10-23T14:02:19+00:00"
+ "time": "2021-01-07T16:49:33+00:00"
},
{
"name": "symfony/process",
- "version": "v4.4.18",
+ "version": "v4.4.19",
"source": {
"type": "git",
"url": "https://github.com/symfony/process.git",
- "reference": "075316ff72233ce3d04a9743414292e834f2cb4a"
+ "reference": "7e950b6366d4da90292c2e7fa820b3c1842b965a"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/process/zipball/075316ff72233ce3d04a9743414292e834f2cb4a",
- "reference": "075316ff72233ce3d04a9743414292e834f2cb4a",
+ "url": "https://api.github.com/repos/symfony/process/zipball/7e950b6366d4da90292c2e7fa820b3c1842b965a",
+ "reference": "7e950b6366d4da90292c2e7fa820b3c1842b965a",
"shasum": ""
},
"require": {
@@ -1514,9 +1453,9 @@
"homepage": "https://symfony.com/contributors"
}
],
- "description": "Symfony Process Component",
+ "description": "Executes commands in sub-processes",
"homepage": "https://symfony.com",
- "time": "2020-12-08T16:59:59+00:00"
+ "time": "2021-01-27T09:09:26+00:00"
},
{
"name": "symfony/service-contracts",
@@ -1582,16 +1521,16 @@
},
{
"name": "symfony/yaml",
- "version": "v5.2.1",
+ "version": "v5.2.3",
"source": {
"type": "git",
"url": "https://github.com/symfony/yaml.git",
- "reference": "290ea5e03b8cf9b42c783163123f54441fb06939"
+ "reference": "338cddc6d74929f6adf19ca5682ac4b8e109cdb0"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/yaml/zipball/290ea5e03b8cf9b42c783163123f54441fb06939",
- "reference": "290ea5e03b8cf9b42c783163123f54441fb06939",
+ "url": "https://api.github.com/repos/symfony/yaml/zipball/338cddc6d74929f6adf19ca5682ac4b8e109cdb0",
+ "reference": "338cddc6d74929f6adf19ca5682ac4b8e109cdb0",
"shasum": ""
},
"require": {
@@ -1634,9 +1573,9 @@
"homepage": "https://symfony.com/contributors"
}
],
- "description": "Symfony Yaml Component",
+ "description": "Loads and dumps YAML files",
"homepage": "https://symfony.com",
- "time": "2020-12-08T17:02:38+00:00"
+ "time": "2021-02-03T04:42:09+00:00"
},
{
"name": "webuni/commonmark-table-extension",
diff --git a/vendor-bin/phpunit/composer.lock b/vendor-bin/phpunit/composer.lock
index 45e101b1..2dd58522 100644
--- a/vendor-bin/phpunit/composer.lock
+++ b/vendor-bin/phpunit/composer.lock
@@ -879,16 +879,16 @@
},
{
"name": "phpunit/phpunit",
- "version": "8.5.13",
+ "version": "8.5.14",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/phpunit.git",
- "reference": "8e86be391a58104ef86037ba8a846524528d784e"
+ "reference": "c25f79895d27b6ecd5abfa63de1606b786a461a3"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/8e86be391a58104ef86037ba8a846524528d784e",
- "reference": "8e86be391a58104ef86037ba8a846524528d784e",
+ "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/c25f79895d27b6ecd5abfa63de1606b786a461a3",
+ "reference": "c25f79895d27b6ecd5abfa63de1606b786a461a3",
"shasum": ""
},
"require": {
@@ -958,7 +958,7 @@
"testing",
"xunit"
],
- "time": "2020-12-01T04:53:52+00:00"
+ "time": "2021-01-17T07:37:30+00:00"
},
{
"name": "sebastian/code-unit-reverse-lookup",
@@ -1577,16 +1577,16 @@
},
{
"name": "symfony/polyfill-ctype",
- "version": "v1.20.0",
+ "version": "v1.22.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-ctype.git",
- "reference": "f4ba089a5b6366e453971d3aad5fe8e897b37f41"
+ "reference": "c6c942b1ac76c82448322025e084cadc56048b4e"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/f4ba089a5b6366e453971d3aad5fe8e897b37f41",
- "reference": "f4ba089a5b6366e453971d3aad5fe8e897b37f41",
+ "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/c6c942b1ac76c82448322025e084cadc56048b4e",
+ "reference": "c6c942b1ac76c82448322025e084cadc56048b4e",
"shasum": ""
},
"require": {
@@ -1598,7 +1598,7 @@
"type": "library",
"extra": {
"branch-alias": {
- "dev-main": "1.20-dev"
+ "dev-main": "1.22-dev"
},
"thanks": {
"name": "symfony/polyfill",
@@ -1635,7 +1635,7 @@
"polyfill",
"portable"
],
- "time": "2020-10-23T14:02:19+00:00"
+ "time": "2021-01-07T16:49:33+00:00"
},
{
"name": "theseer/tokenizer",
@@ -1728,26 +1728,25 @@
},
{
"name": "webmozart/glob",
- "version": "4.1.0",
+ "version": "4.3.0",
"source": {
"type": "git",
- "url": "https://github.com/webmozart/glob.git",
- "reference": "3cbf63d4973cf9d780b93d2da8eec7e4a9e63bbe"
+ "url": "https://github.com/webmozarts/glob.git",
+ "reference": "06358fafde0f32edb4513f4fd88fe113a40c90ee"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/webmozart/glob/zipball/3cbf63d4973cf9d780b93d2da8eec7e4a9e63bbe",
- "reference": "3cbf63d4973cf9d780b93d2da8eec7e4a9e63bbe",
+ "url": "https://api.github.com/repos/webmozarts/glob/zipball/06358fafde0f32edb4513f4fd88fe113a40c90ee",
+ "reference": "06358fafde0f32edb4513f4fd88fe113a40c90ee",
"shasum": ""
},
"require": {
- "php": "^5.3.3|^7.0",
+ "php": "^7.3 || ^8.0.0",
"webmozart/path-util": "^2.2"
},
"require-dev": {
- "phpunit/phpunit": "^4.6",
- "sebastian/version": "^1.0.1",
- "symfony/filesystem": "^2.5"
+ "phpunit/phpunit": "^8.0",
+ "symfony/filesystem": "^5.1"
},
"type": "library",
"extra": {
@@ -1771,7 +1770,7 @@
}
],
"description": "A PHP implementation of Ant's glob.",
- "time": "2015-12-29T11:14:33+00:00"
+ "time": "2021-01-21T06:17:15+00:00"
},
{
"name": "webmozart/path-util",
diff --git a/vendor-bin/robo/composer.lock b/vendor-bin/robo/composer.lock
index d4395718..f5ef2e37 100644
--- a/vendor-bin/robo/composer.lock
+++ b/vendor-bin/robo/composer.lock
@@ -660,16 +660,16 @@
},
{
"name": "pear/archive_tar",
- "version": "1.4.11",
+ "version": "1.4.12",
"source": {
"type": "git",
"url": "https://github.com/pear/Archive_Tar.git",
- "reference": "17d355cb7d3c4ff08e5729f29cd7660145208d9d"
+ "reference": "19bb8e95490d3e3ad92fcac95500ca80bdcc7495"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/pear/Archive_Tar/zipball/17d355cb7d3c4ff08e5729f29cd7660145208d9d",
- "reference": "17d355cb7d3c4ff08e5729f29cd7660145208d9d",
+ "url": "https://api.github.com/repos/pear/Archive_Tar/zipball/19bb8e95490d3e3ad92fcac95500ca80bdcc7495",
+ "reference": "19bb8e95490d3e3ad92fcac95500ca80bdcc7495",
"shasum": ""
},
"require": {
@@ -722,11 +722,7 @@
"archive",
"tar"
],
- "support": {
- "issues": "http://pear.php.net/bugs/search.php?cmd=display&package_name[]=Archive_Tar",
- "source": "https://github.com/pear/Archive_Tar"
- },
- "time": "2020-11-19T22:10:24+00:00"
+ "time": "2021-01-18T19:32:54+00:00"
},
{
"name": "pear/console_getopt",
@@ -972,16 +968,16 @@
},
{
"name": "symfony/console",
- "version": "v4.4.18",
+ "version": "v4.4.19",
"source": {
"type": "git",
"url": "https://github.com/symfony/console.git",
- "reference": "12e071278e396cc3e1c149857337e9e192deca0b"
+ "reference": "24026c44fc37099fa145707fecd43672831b837a"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/console/zipball/12e071278e396cc3e1c149857337e9e192deca0b",
- "reference": "12e071278e396cc3e1c149857337e9e192deca0b",
+ "url": "https://api.github.com/repos/symfony/console/zipball/24026c44fc37099fa145707fecd43672831b837a",
+ "reference": "24026c44fc37099fa145707fecd43672831b837a",
"shasum": ""
},
"require": {
@@ -1038,22 +1034,22 @@
"homepage": "https://symfony.com/contributors"
}
],
- "description": "Symfony Console Component",
+ "description": "Eases the creation of beautiful and testable command line interfaces",
"homepage": "https://symfony.com",
- "time": "2020-12-18T07:41:31+00:00"
+ "time": "2021-01-27T09:09:26+00:00"
},
{
"name": "symfony/event-dispatcher",
- "version": "v4.4.18",
+ "version": "v4.4.19",
"source": {
"type": "git",
"url": "https://github.com/symfony/event-dispatcher.git",
- "reference": "5d4c874b0eb1c32d40328a09dbc37307a5a910b0"
+ "reference": "c352647244bd376bf7d31efbd5401f13f50dad0c"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/5d4c874b0eb1c32d40328a09dbc37307a5a910b0",
- "reference": "5d4c874b0eb1c32d40328a09dbc37307a5a910b0",
+ "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/c352647244bd376bf7d31efbd5401f13f50dad0c",
+ "reference": "c352647244bd376bf7d31efbd5401f13f50dad0c",
"shasum": ""
},
"require": {
@@ -1104,9 +1100,9 @@
"homepage": "https://symfony.com/contributors"
}
],
- "description": "Symfony EventDispatcher Component",
+ "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them",
"homepage": "https://symfony.com",
- "time": "2020-12-18T07:41:31+00:00"
+ "time": "2021-01-27T09:09:26+00:00"
},
{
"name": "symfony/event-dispatcher-contracts",
@@ -1172,16 +1168,16 @@
},
{
"name": "symfony/filesystem",
- "version": "v4.4.18",
+ "version": "v4.4.19",
"source": {
"type": "git",
"url": "https://github.com/symfony/filesystem.git",
- "reference": "d99fbef7e0f69bf162ae6131b31132fa3cc4bcbe"
+ "reference": "83a6feed14846d2d9f3916adbaf838819e4e3380"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/filesystem/zipball/d99fbef7e0f69bf162ae6131b31132fa3cc4bcbe",
- "reference": "d99fbef7e0f69bf162ae6131b31132fa3cc4bcbe",
+ "url": "https://api.github.com/repos/symfony/filesystem/zipball/83a6feed14846d2d9f3916adbaf838819e4e3380",
+ "reference": "83a6feed14846d2d9f3916adbaf838819e4e3380",
"shasum": ""
},
"require": {
@@ -1211,22 +1207,22 @@
"homepage": "https://symfony.com/contributors"
}
],
- "description": "Symfony Filesystem Component",
+ "description": "Provides basic utilities for the filesystem",
"homepage": "https://symfony.com",
- "time": "2020-11-30T13:04:35+00:00"
+ "time": "2021-01-27T09:09:26+00:00"
},
{
"name": "symfony/finder",
- "version": "v5.2.1",
+ "version": "v5.2.3",
"source": {
"type": "git",
"url": "https://github.com/symfony/finder.git",
- "reference": "0b9231a5922fd7287ba5b411893c0ecd2733e5ba"
+ "reference": "4adc8d172d602008c204c2e16956f99257248e03"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/finder/zipball/0b9231a5922fd7287ba5b411893c0ecd2733e5ba",
- "reference": "0b9231a5922fd7287ba5b411893c0ecd2733e5ba",
+ "url": "https://api.github.com/repos/symfony/finder/zipball/4adc8d172d602008c204c2e16956f99257248e03",
+ "reference": "4adc8d172d602008c204c2e16956f99257248e03",
"shasum": ""
},
"require": {
@@ -1255,22 +1251,22 @@
"homepage": "https://symfony.com/contributors"
}
],
- "description": "Symfony Finder Component",
+ "description": "Finds files and directories via an intuitive fluent interface",
"homepage": "https://symfony.com",
- "time": "2020-12-08T17:02:38+00:00"
+ "time": "2021-01-28T22:06:19+00:00"
},
{
"name": "symfony/polyfill-ctype",
- "version": "v1.20.0",
+ "version": "v1.22.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-ctype.git",
- "reference": "f4ba089a5b6366e453971d3aad5fe8e897b37f41"
+ "reference": "c6c942b1ac76c82448322025e084cadc56048b4e"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/f4ba089a5b6366e453971d3aad5fe8e897b37f41",
- "reference": "f4ba089a5b6366e453971d3aad5fe8e897b37f41",
+ "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/c6c942b1ac76c82448322025e084cadc56048b4e",
+ "reference": "c6c942b1ac76c82448322025e084cadc56048b4e",
"shasum": ""
},
"require": {
@@ -1282,7 +1278,7 @@
"type": "library",
"extra": {
"branch-alias": {
- "dev-main": "1.20-dev"
+ "dev-main": "1.22-dev"
},
"thanks": {
"name": "symfony/polyfill",
@@ -1319,20 +1315,20 @@
"polyfill",
"portable"
],
- "time": "2020-10-23T14:02:19+00:00"
+ "time": "2021-01-07T16:49:33+00:00"
},
{
"name": "symfony/polyfill-mbstring",
- "version": "v1.20.0",
+ "version": "v1.22.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-mbstring.git",
- "reference": "39d483bdf39be819deabf04ec872eb0b2410b531"
+ "reference": "f377a3dd1fde44d37b9831d68dc8dea3ffd28e13"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/39d483bdf39be819deabf04ec872eb0b2410b531",
- "reference": "39d483bdf39be819deabf04ec872eb0b2410b531",
+ "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/f377a3dd1fde44d37b9831d68dc8dea3ffd28e13",
+ "reference": "f377a3dd1fde44d37b9831d68dc8dea3ffd28e13",
"shasum": ""
},
"require": {
@@ -1344,7 +1340,7 @@
"type": "library",
"extra": {
"branch-alias": {
- "dev-main": "1.20-dev"
+ "dev-main": "1.22-dev"
},
"thanks": {
"name": "symfony/polyfill",
@@ -1382,20 +1378,20 @@
"portable",
"shim"
],
- "time": "2020-10-23T14:02:19+00:00"
+ "time": "2021-01-07T16:49:33+00:00"
},
{
"name": "symfony/polyfill-php73",
- "version": "v1.20.0",
+ "version": "v1.22.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-php73.git",
- "reference": "8ff431c517be11c78c48a39a66d37431e26a6bed"
+ "reference": "a678b42e92f86eca04b7fa4c0f6f19d097fb69e2"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/polyfill-php73/zipball/8ff431c517be11c78c48a39a66d37431e26a6bed",
- "reference": "8ff431c517be11c78c48a39a66d37431e26a6bed",
+ "url": "https://api.github.com/repos/symfony/polyfill-php73/zipball/a678b42e92f86eca04b7fa4c0f6f19d097fb69e2",
+ "reference": "a678b42e92f86eca04b7fa4c0f6f19d097fb69e2",
"shasum": ""
},
"require": {
@@ -1404,7 +1400,7 @@
"type": "library",
"extra": {
"branch-alias": {
- "dev-main": "1.20-dev"
+ "dev-main": "1.22-dev"
},
"thanks": {
"name": "symfony/polyfill",
@@ -1444,20 +1440,20 @@
"portable",
"shim"
],
- "time": "2020-10-23T14:02:19+00:00"
+ "time": "2021-01-07T16:49:33+00:00"
},
{
"name": "symfony/polyfill-php80",
- "version": "v1.20.0",
+ "version": "v1.22.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-php80.git",
- "reference": "e70aa8b064c5b72d3df2abd5ab1e90464ad009de"
+ "reference": "dc3063ba22c2a1fd2f45ed856374d79114998f91"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/e70aa8b064c5b72d3df2abd5ab1e90464ad009de",
- "reference": "e70aa8b064c5b72d3df2abd5ab1e90464ad009de",
+ "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/dc3063ba22c2a1fd2f45ed856374d79114998f91",
+ "reference": "dc3063ba22c2a1fd2f45ed856374d79114998f91",
"shasum": ""
},
"require": {
@@ -1466,7 +1462,7 @@
"type": "library",
"extra": {
"branch-alias": {
- "dev-main": "1.20-dev"
+ "dev-main": "1.22-dev"
},
"thanks": {
"name": "symfony/polyfill",
@@ -1510,7 +1506,7 @@
"portable",
"shim"
],
- "time": "2020-10-23T14:02:19+00:00"
+ "time": "2021-01-07T16:49:33+00:00"
},
{
"name": "symfony/process",
@@ -1623,16 +1619,16 @@
},
{
"name": "symfony/yaml",
- "version": "v4.4.18",
+ "version": "v4.4.19",
"source": {
"type": "git",
"url": "https://github.com/symfony/yaml.git",
- "reference": "bbce94f14d73732340740366fcbe63363663a403"
+ "reference": "17ed9f14c1aa05b1a5cf2e2c5ef2d0be28058ef9"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/yaml/zipball/bbce94f14d73732340740366fcbe63363663a403",
- "reference": "bbce94f14d73732340740366fcbe63363663a403",
+ "url": "https://api.github.com/repos/symfony/yaml/zipball/17ed9f14c1aa05b1a5cf2e2c5ef2d0be28058ef9",
+ "reference": "17ed9f14c1aa05b1a5cf2e2c5ef2d0be28058ef9",
"shasum": ""
},
"require": {
@@ -1671,9 +1667,9 @@
"homepage": "https://symfony.com/contributors"
}
],
- "description": "Symfony Yaml Component",
+ "description": "Loads and dumps YAML files",
"homepage": "https://symfony.com",
- "time": "2020-12-08T16:59:59+00:00"
+ "time": "2021-01-27T09:09:26+00:00"
}
],
"aliases": [],
From 54a6fcc0d63e9a5a6abcc82599447d7da8b1730c Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Sat, 6 Feb 2021 23:51:23 -0500
Subject: [PATCH 163/366] Consolidate object factoriesinto one place
---
lib/Arsse.php | 5 ++++-
lib/CLI.php | 21 ++++++++-------------
lib/Factory.php | 13 +++++++++++++
lib/REST.php | 7 +------
lib/REST/AbstractHandler.php | 3 +--
lib/REST/Miniflux/V1.php | 9 ++-------
tests/cases/CLI/TestCLI.php | 18 ++++++++----------
tests/cases/Misc/TestFactory.php | 17 +++++++++++++++++
tests/cases/REST/Miniflux/TestV1.php | 9 +++------
tests/cases/REST/TestREST.php | 10 +---------
tests/cases/REST/TinyTinyRSS/TestAPI.php | 3 +--
tests/lib/AbstractTest.php | 6 ++++++
tests/phpunit.dist.xml | 1 +
13 files changed, 66 insertions(+), 56 deletions(-)
create mode 100644 lib/Factory.php
create mode 100644 tests/cases/Misc/TestFactory.php
diff --git a/lib/Arsse.php b/lib/Arsse.php
index 7d53a427..0cd7d6c5 100644
--- a/lib/Arsse.php
+++ b/lib/Arsse.php
@@ -7,8 +7,10 @@ declare(strict_types=1);
namespace JKingWeb\Arsse;
class Arsse {
- public const VERSION = "0.8.5";
+ public const VERSION = "0.9.0";
+ /** @var Factory */
+ public static $obj;
/** @var Lang */
public static $lang;
/** @var Conf */
@@ -19,6 +21,7 @@ class Arsse {
public static $user;
public static function load(Conf $conf): void {
+ static::$obj = static::$obj ?? new Factory;
static::$lang = static::$lang ?? new Lang;
static::$conf = $conf;
static::$lang->set($conf->lang);
diff --git a/lib/CLI.php b/lib/CLI.php
index c9a59673..bc96f468 100644
--- a/lib/CLI.php
+++ b/lib/CLI.php
@@ -182,26 +182,26 @@ USAGE_TEXT;
echo Arsse::VERSION.\PHP_EOL;
return 0;
case "daemon":
- $this->getInstance(Service::class)->watch(true);
+ Arsse::$obj->get(Service::class)->watch(true);
return 0;
case "feed refresh":
return (int) !Arsse::$db->feedUpdate((int) $args[''], true);
case "feed refresh-all":
- $this->getInstance(Service::class)->watch(false);
+ Arsse::$obj->get(Service::class)->watch(false);
return 0;
case "conf save-defaults":
$file = $this->resolveFile($args[''], "w");
- return (int) !$this->getInstance(Conf::class)->exportFile($file, true);
+ return (int) !Arsse::$obj->get(Conf::class)->exportFile($file, true);
case "user":
return $this->userManage($args);
case "export":
$u = $args[''];
$file = $this->resolveFile($args[''], "w");
- return (int) !$this->getInstance(OPML::class)->exportFile($file, $u, ($args['--flat'] || $args['-f']));
+ return (int) !Arsse::$obj->get(OPML::class)->exportFile($file, $u, ($args['--flat'] || $args['-f']));
case "import":
$u = $args[''];
$file = $this->resolveFile($args[''], "r");
- return (int) !$this->getInstance(OPML::class)->importFile($file, $u, ($args['--flat'] || $args['-f']), ($args['--replace'] || $args['-r']));
+ return (int) !Arsse::$obj->get(OPML::class)->importFile($file, $u, ($args['--flat'] || $args['-f']), ($args['--replace'] || $args['-r']));
}
} catch (AbstractException $e) {
$this->logError($e->getMessage());
@@ -214,11 +214,6 @@ USAGE_TEXT;
fwrite(STDERR, $msg.\PHP_EOL);
}
- /** @codeCoverageIgnore */
- protected function getInstance(string $class) {
- return new $class;
- }
-
protected function userManage($args): int {
$cmd = $this->command(["add", "remove", "set-pass", "unset-pass", "list", "auth"], $args);
switch ($cmd) {
@@ -226,7 +221,7 @@ USAGE_TEXT;
return $this->userAddOrSetPassword("add", $args[""], $args[""]);
case "set-pass":
if ($args['--fever']) {
- $passwd = $this->getInstance(Fever::class)->register($args[""], $args[""]);
+ $passwd = Arsse::$obj->get(Fever::class)->register($args[""], $args[""]);
if (is_null($args[""])) {
echo $passwd.\PHP_EOL;
}
@@ -237,7 +232,7 @@ USAGE_TEXT;
// no break
case "unset-pass":
if ($args['--fever']) {
- $this->getInstance(Fever::class)->unregister($args[""]);
+ Arsse::$obj->get(Fever::class)->unregister($args[""]);
} else {
Arsse::$user->passwordUnset($args[""], $args["--oldpass"]);
}
@@ -271,7 +266,7 @@ USAGE_TEXT;
}
protected function userAuthenticate(string $user, string $password, bool $fever = false): int {
- $result = $fever ? $this->getInstance(Fever::class)->authenticate($user, $password) : Arsse::$user->auth($user, $password);
+ $result = $fever ? Arsse::$obj->get(Fever::class)->authenticate($user, $password) : Arsse::$user->auth($user, $password);
if ($result) {
echo Arsse::$lang->msg("CLI.Auth.Success").\PHP_EOL;
return 0;
diff --git a/lib/Factory.php b/lib/Factory.php
new file mode 100644
index 00000000..96989025
--- /dev/null
+++ b/lib/Factory.php
@@ -0,0 +1,13 @@
+withMethod(strtoupper($req->getMethod()))->withRequestTarget($target);
// fetch the correct handler
- $drv = $this->getHandler($class);
+ $drv = Arsse::$obj->get($class);
// generate a response
if ($req->getMethod() === "HEAD") {
// if the request is a HEAD request, we act exactly as if it were a GET request, and simply remove the response body later
@@ -105,11 +105,6 @@ class REST {
return $this->normalizeResponse($res, $req);
}
- public function getHandler(string $className): REST\Handler {
- // instantiate the API handler
- return new $className();
- }
-
public function apiMatch(string $url): array {
$map = $this->apis;
// sort the API list so the longest URL prefixes come first
diff --git a/lib/REST/AbstractHandler.php b/lib/REST/AbstractHandler.php
index f0e39e79..7103c34a 100644
--- a/lib/REST/AbstractHandler.php
+++ b/lib/REST/AbstractHandler.php
@@ -16,9 +16,8 @@ abstract class AbstractHandler implements Handler {
abstract public function __construct();
abstract public function dispatch(ServerRequestInterface $req): ResponseInterface;
- /** @codeCoverageIgnore */
protected function now(): \DateTimeImmutable {
- return Date::normalize("now");
+ return Arsse::$obj->get(\DateTimeImmutable::class)->setTimezone(new \DateTimeZone("UTC"));
}
protected function isAdmin(): bool {
diff --git a/lib/REST/Miniflux/V1.php b/lib/REST/Miniflux/V1.php
index 00c58f02..e82d5906 100644
--- a/lib/REST/Miniflux/V1.php
+++ b/lib/REST/Miniflux/V1.php
@@ -214,11 +214,6 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
public function __construct() {
}
- /** @codeCoverageIgnore */
- protected function getInstance(string $class) {
- return new $class;
- }
-
protected function authenticate(ServerRequestInterface $req): bool {
// first check any tokens; this is what Miniflux does
if ($req->hasHeader("X-Auth-Token")) {
@@ -1183,7 +1178,7 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
protected function opmlImport(string $data): ResponseInterface {
try {
- $this->getInstance(OPML::class)->import(Arsse::$user->id, $data);
+ Arsse::$obj->get(OPML::class)->import(Arsse::$user->id, $data);
} catch (ImportException $e) {
switch ($e->getCode()) {
case 10611:
@@ -1204,7 +1199,7 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
}
protected function opmlExport(): ResponseInterface {
- return new GenericResponse($this->getInstance(OPML::class)->export(Arsse::$user->id), 200, ['Content-Type' => "application/xml"]);
+ return new GenericResponse(Arsse::$obj->get(OPML::class)->export(Arsse::$user->id), 200, ['Content-Type' => "application/xml"]);
}
public static function tokenGenerate(string $user, string $label): string {
diff --git a/tests/cases/CLI/TestCLI.php b/tests/cases/CLI/TestCLI.php
index 3d0d6f14..30237757 100644
--- a/tests/cases/CLI/TestCLI.php
+++ b/tests/cases/CLI/TestCLI.php
@@ -60,22 +60,20 @@ class TestCLI extends \JKingWeb\Arsse\Test\AbstractTest {
public function testStartTheDaemon(): void {
$srv = \Phake::mock(Service::class);
+ \Phake::when(Arsse::$obj)->get(Service::class)->thenReturn($srv);
\Phake::when($srv)->watch->thenReturn(new \DateTimeImmutable);
- \Phake::when($this->cli)->getInstance(Service::class)->thenReturn($srv);
$this->assertConsole($this->cli, "arsse.php daemon", 0);
\Phake::verify($this->cli)->loadConf;
\Phake::verify($srv)->watch(true);
- \Phake::verify($this->cli)->getInstance(Service::class);
}
public function testRefreshAllFeeds(): void {
$srv = \Phake::mock(Service::class);
+ \Phake::when(Arsse::$obj)->get(Service::class)->thenReturn($srv);
\Phake::when($srv)->watch->thenReturn(new \DateTimeImmutable);
- \Phake::when($this->cli)->getInstance(Service::class)->thenReturn($srv);
$this->assertConsole($this->cli, "arsse.php feed refresh-all", 0);
\Phake::verify($this->cli)->loadConf;
\Phake::verify($srv)->watch(false);
- \Phake::verify($this->cli)->getInstance(Service::class);
}
/** @dataProvider provideFeedUpdates */
@@ -98,10 +96,10 @@ class TestCLI extends \JKingWeb\Arsse\Test\AbstractTest {
/** @dataProvider provideDefaultConfigurationSaves */
public function testSaveTheDefaultConfiguration(string $cmd, int $exitStatus, string $file): void {
$conf = \Phake::mock(Conf::class);
+ \Phake::when(Arsse::$obj)->get(Conf::class)->thenReturn($conf);
\Phake::when($conf)->exportFile("php://output", true)->thenReturn(true);
\Phake::when($conf)->exportFile("good.conf", true)->thenReturn(true);
\Phake::when($conf)->exportFile("bad.conf", true)->thenThrow(new \JKingWeb\Arsse\Conf\Exception("fileUnwritable"));
- \Phake::when($this->cli)->getInstance(Conf::class)->thenReturn($conf);
$this->assertConsole($this->cli, $cmd, $exitStatus);
\Phake::verify($this->cli, \Phake::times(0))->loadConf;
\Phake::verify($conf)->exportFile($file, true);
@@ -169,10 +167,10 @@ class TestCLI extends \JKingWeb\Arsse\Test\AbstractTest {
;
}));
$fever = \Phake::mock(FeverUser::class);
+ \Phake::when(Arsse::$obj)->get(FeverUser::class)->thenReturn($fever);
\Phake::when($fever)->authenticate->thenReturn(false);
\Phake::when($fever)->authenticate("john.doe@example.com", "ashalla")->thenReturn(true);
\Phake::when($fever)->authenticate("jane.doe@example.com", "thx1138")->thenReturn(true);
- \Phake::when($this->cli)->getInstance(FeverUser::class)->thenReturn($fever);
$this->assertConsole($this->cli, $cmd, $exitStatus, $output);
}
@@ -226,8 +224,8 @@ class TestCLI extends \JKingWeb\Arsse\Test\AbstractTest {
Arsse::$user = $this->createMock(User::class);
Arsse::$user->method("passwordSet")->will($this->returnCallback($passwordChange));
$fever = \Phake::mock(FeverUser::class);
+ \Phake::when(Arsse::$obj)->get(FeverUser::class)->thenReturn($fever);
\Phake::when($fever)->register->thenReturnCallback($passwordChange);
- \Phake::when($this->cli)->getInstance(FeverUser::class)->thenReturn($fever);
$this->assertConsole($this->cli, $cmd, $exitStatus, $output);
}
@@ -256,8 +254,8 @@ class TestCLI extends \JKingWeb\Arsse\Test\AbstractTest {
Arsse::$user = $this->createMock(User::class);
Arsse::$user->method("passwordUnset")->will($this->returnCallback($passwordClear));
$fever = \Phake::mock(FeverUser::class);
+ \Phake::when(Arsse::$obj)->get(FeverUser::class)->thenReturn($fever);
\Phake::when($fever)->unregister->thenReturnCallback($passwordClear);
- \Phake::when($this->cli)->getInstance(FeverUser::class)->thenReturn($fever);
$this->assertConsole($this->cli, $cmd, $exitStatus, $output);
}
@@ -273,10 +271,10 @@ class TestCLI extends \JKingWeb\Arsse\Test\AbstractTest {
/** @dataProvider provideOpmlExports */
public function testExportToOpml(string $cmd, int $exitStatus, string $file, string $user, bool $flat): void {
$opml = \Phake::mock(OPML::class);
+ \Phake::when(Arsse::$obj)->get(OPML::class)->thenReturn($opml);
\Phake::when($opml)->exportFile("php://output", $user, $flat)->thenReturn(true);
\Phake::when($opml)->exportFile("good.opml", $user, $flat)->thenReturn(true);
\Phake::when($opml)->exportFile("bad.opml", $user, $flat)->thenThrow(new \JKingWeb\Arsse\ImportExport\Exception("fileUnwritable"));
- \Phake::when($this->cli)->getInstance(OPML::class)->thenReturn($opml);
$this->assertConsole($this->cli, $cmd, $exitStatus);
\Phake::verify($this->cli)->loadConf;
\Phake::verify($opml)->exportFile($file, $user, $flat);
@@ -314,10 +312,10 @@ class TestCLI extends \JKingWeb\Arsse\Test\AbstractTest {
/** @dataProvider provideOpmlImports */
public function testImportFromOpml(string $cmd, int $exitStatus, string $file, string $user, bool $flat, bool $replace): void {
$opml = \Phake::mock(OPML::class);
+ \Phake::when(Arsse::$obj)->get(OPML::class)->thenReturn($opml);
\Phake::when($opml)->importFile("php://input", $user, $flat, $replace)->thenReturn(true);
\Phake::when($opml)->importFile("good.opml", $user, $flat, $replace)->thenReturn(true);
\Phake::when($opml)->importFile("bad.opml", $user, $flat, $replace)->thenThrow(new \JKingWeb\Arsse\ImportExport\Exception("fileUnreadable"));
- \Phake::when($this->cli)->getInstance(OPML::class)->thenReturn($opml);
$this->assertConsole($this->cli, $cmd, $exitStatus);
\Phake::verify($this->cli)->loadConf;
\Phake::verify($opml)->importFile($file, $user, $flat, $replace);
diff --git a/tests/cases/Misc/TestFactory.php b/tests/cases/Misc/TestFactory.php
new file mode 100644
index 00000000..e400c2f7
--- /dev/null
+++ b/tests/cases/Misc/TestFactory.php
@@ -0,0 +1,17 @@
+assertInstanceOf(\stdClass::class, $f->get(\stdClass::class));
+ }
+}
\ No newline at end of file
diff --git a/tests/cases/REST/Miniflux/TestV1.php b/tests/cases/REST/Miniflux/TestV1.php
index 22a53db6..fc08dea4 100644
--- a/tests/cases/REST/Miniflux/TestV1.php
+++ b/tests/cases/REST/Miniflux/TestV1.php
@@ -211,8 +211,7 @@ class TestV1 extends \JKingWeb\Arsse\Test\AbstractTest {
throw $u[2];
}
});
- $this->h = $this->createPartialMock(V1::class, ["now"]);
- $this->h->method("now")->willReturn(Date::normalize(self::NOW));
+ \Phake::when(Arsse::$obj)->get(\DateTimeImmutable::class)->thenReturn(new \DateTimeImmutable(self::NOW));
$this->assertMessage($exp, $this->req("GET", $route, "", [], $user));
}
@@ -240,8 +239,7 @@ class TestV1 extends \JKingWeb\Arsse\Test\AbstractTest {
/** @dataProvider provideUserModifications */
public function testModifyAUser(bool $admin, string $url, array $body, $in1, $out1, $in2, $out2, $in3, $out3, ResponseInterface $exp): void {
- $this->h = $this->createPartialMock(V1::class, ["now"]);
- $this->h->method("now")->willReturn(Date::normalize(self::NOW));
+ \Phake::when(Arsse::$obj)->get(\DateTimeImmutable::class)->thenReturn(new \DateTimeImmutable(self::NOW));
Arsse::$user = $this->createMock(User::class);
Arsse::$user->method("begin")->willReturn($this->transaction);
Arsse::$user->method("propertiesGet")->willReturnCallback(function(string $u, bool $includeLarge) use ($admin) {
@@ -321,8 +319,7 @@ class TestV1 extends \JKingWeb\Arsse\Test\AbstractTest {
/** @dataProvider provideUserAdditions */
public function testAddAUser(array $body, $in1, $out1, $in2, $out2, ResponseInterface $exp): void {
- $this->h = $this->createPartialMock(V1::class, ["now"]);
- $this->h->method("now")->willReturn(Date::normalize(self::NOW));
+ \Phake::when(Arsse::$obj)->get(\DateTimeImmutable::class)->thenReturn(new \DateTimeImmutable(self::NOW));
Arsse::$user = $this->createMock(User::class);
Arsse::$user->method("begin")->willReturn($this->transaction);
Arsse::$user->method("propertiesGet")->willReturnCallback(function(string $u, bool $includeLarge) {
diff --git a/tests/cases/REST/TestREST.php b/tests/cases/REST/TestREST.php
index da458930..18642150 100644
--- a/tests/cases/REST/TestREST.php
+++ b/tests/cases/REST/TestREST.php
@@ -286,14 +286,6 @@ class TestREST extends \JKingWeb\Arsse\Test\AbstractTest {
];
}
- public function testCreateHandlers(): void {
- $r = new REST();
- foreach (REST::API_LIST as $api) {
- $class = $api['class'];
- $this->assertInstanceOf(Handler::class, $r->getHandler($class));
- }
- }
-
/** @dataProvider provideMockRequests */
public function testDispatchRequests(ServerRequest $req, string $method, bool $called, string $class = "", string $target = ""): void {
$r = \Phake::partialMock(REST::class);
@@ -305,7 +297,7 @@ class TestREST extends \JKingWeb\Arsse\Test\AbstractTest {
});
if ($called) {
$h = \Phake::mock($class);
- \Phake::when($r)->getHandler($class)->thenReturn($h);
+ \Phake::when(Arsse::$obj)->get($class)->thenReturn($h);
\Phake::when($h)->dispatch->thenReturn(new EmptyResponse(204));
}
$out = $r->dispatch($req);
diff --git a/tests/cases/REST/TinyTinyRSS/TestAPI.php b/tests/cases/REST/TinyTinyRSS/TestAPI.php
index 6191d84b..874a103c 100644
--- a/tests/cases/REST/TinyTinyRSS/TestAPI.php
+++ b/tests/cases/REST/TinyTinyRSS/TestAPI.php
@@ -1700,8 +1700,7 @@ LONG_STRING;
public function testRetrieveHeadlines(bool $full, array $in, $out, Context $c, array $fields, array $order, ResponseInterface $exp): void {
$base = ['op' => $full ? "getHeadlines" : "getCompactHeadlines", 'sid' => "PriestsOfSyrinx"];
$in = array_merge($base, $in);
- $this->h = \Phake::partialMock(API::class);
- \Phake::when($this->h)->now->thenReturn(Date::normalize(self::NOW));
+ \Phake::when(Arsse::$obj)->get(\DateTimeImmutable::class)->thenReturn(new \DateTimeImmutable(self::NOW));
\Phake::when(Arsse::$db)->labelList->thenReturn(new Result($this->v($this->labels)));
\Phake::when(Arsse::$db)->labelList($this->anything(), false)->thenReturn(new Result($this->v($this->usedLabels)));
\Phake::when(Arsse::$db)->articleLabelsGet->thenReturn([]);
diff --git a/tests/lib/AbstractTest.php b/tests/lib/AbstractTest.php
index 54cde189..e096ca1e 100644
--- a/tests/lib/AbstractTest.php
+++ b/tests/lib/AbstractTest.php
@@ -13,6 +13,7 @@ use JKingWeb\Arsse\Arsse;
use JKingWeb\Arsse\Conf;
use JKingWeb\Arsse\Db\Driver;
use JKingWeb\Arsse\Db\Result;
+use JKingWeb\Arsse\Factory;
use JKingWeb\Arsse\Misc\Date;
use JKingWeb\Arsse\Misc\ValueInfo;
use JKingWeb\Arsse\Misc\URL;
@@ -45,6 +46,11 @@ abstract class AbstractTest extends \PHPUnit\Framework\TestCase {
}
if ($loadLang) {
Arsse::$lang = new \JKingWeb\Arsse\Lang();
+ // also create the object factory as a mock
+ Arsse::$obj = \Phake::mock(Factory::class);
+ \Phake::when(Arsse::$obj)->get->thenReturnCallback(function(string $class) {
+ return new $class;
+ });
}
}
diff --git a/tests/phpunit.dist.xml b/tests/phpunit.dist.xml
index 0875bf54..3d576064 100644
--- a/tests/phpunit.dist.xml
+++ b/tests/phpunit.dist.xml
@@ -45,6 +45,7 @@
cases/Conf/TestConf.php
+ cases/Misc/TestFactory.php
cases/Misc/TestValueInfo.php
cases/Misc/TestDate.php
cases/Misc/TestQuery.php
From 6c2de89f3e1693aa15e8f452502872a88ff4d6a7 Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Sat, 6 Feb 2021 23:55:40 -0500
Subject: [PATCH 164/366] Revert copy-paste corruption
---
lib/REST/Miniflux/V1.php | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/lib/REST/Miniflux/V1.php b/lib/REST/Miniflux/V1.php
index e82d5906..db2b40d9 100644
--- a/lib/REST/Miniflux/V1.php
+++ b/lib/REST/Miniflux/V1.php
@@ -143,8 +143,8 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
'GET' => ["getCategoryFeeds", false, true, false, false, []],
],
'/categories/1/mark-all-as-read' => [
+ 'PUT' => ["markCategory", false, true, false, false, []],
],
- 'PUT' => ["markCategory", false, true, false, false, []],
'/discover' => [
'POST' => ["discoverSubscriptions", false, false, true, false, ["url"]],
],
From 37fd2ad4e915e35d946fa2a4296cf350470ee006 Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Sun, 7 Feb 2021 09:07:53 -0500
Subject: [PATCH 165/366] Tests for new exception features
---
lib/AbstractException.php | 2 +-
tests/cases/Exception/TestException.php | 10 ++++++++++
2 files changed, 11 insertions(+), 1 deletion(-)
diff --git a/lib/AbstractException.php b/lib/AbstractException.php
index 922b9cd8..d2cb0d5a 100644
--- a/lib/AbstractException.php
+++ b/lib/AbstractException.php
@@ -132,6 +132,6 @@ abstract class AbstractException extends \Exception {
}
public function getParams(): array {
- return $this->aparams;
+ return $this->params;
}
}
diff --git a/tests/cases/Exception/TestException.php b/tests/cases/Exception/TestException.php
index 66a1f3a8..563a4f58 100644
--- a/tests/cases/Exception/TestException.php
+++ b/tests/cases/Exception/TestException.php
@@ -78,4 +78,14 @@ class TestException extends \JKingWeb\Arsse\Test\AbstractTest {
$this->expectException('JKingWeb\Arsse\ExceptionFatal');
throw new \JKingWeb\Arsse\ExceptionFatal("");
}
+
+ public function testGetExceptionSymbol(): void {
+ $e = new LangException("stringMissing", ['msgID' => "OOK"]);
+ $this->assertSame("stringMissing", $e->getSymbol());
+ }
+
+ public function testGetExceptionParams(): void {
+ $e = new LangException("stringMissing", ['msgID' => "OOK"]);
+ $this->assertSame(['msgID' => "OOK"], $e->getParams());
+ }
}
From 9cc779a717fad1389b7b2923ae407b9996735053 Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Sun, 7 Feb 2021 13:04:44 -0500
Subject: [PATCH 166/366] Import/export tests
---
lib/REST/Miniflux/V1.php | 6 ++---
tests/cases/REST/Miniflux/TestV1.php | 37 ++++++++++++++++++++++++++++
2 files changed, 40 insertions(+), 3 deletions(-)
diff --git a/lib/REST/Miniflux/V1.php b/lib/REST/Miniflux/V1.php
index db2b40d9..ee9c3329 100644
--- a/lib/REST/Miniflux/V1.php
+++ b/lib/REST/Miniflux/V1.php
@@ -263,7 +263,7 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
}
if ($reqBody) {
if ($func === "opmlImport") {
- $args[] = (string) $req->getBody();
+ $data = (string) $req->getBody();
} else {
$data = (string) $req->getBody();
if (strlen($data)) {
@@ -1188,14 +1188,14 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
case 10613:
return new ErrorResponse("InvalidImportCategory", 422);
case 10614:
- return new ErrorResponse("DuplicateImportCatgory", 422);
+ return new ErrorResponse("DuplicateImportCategory", 422);
case 10615:
return new ErrorResponse("InvalidImportLabel", 422);
}
} catch (FeedException $e) {
return new ErrorResponse(["FailedImportFeed", 'url' => $e->getParams()['url'], 'code' => $e->getCode()], 502);
}
- return new Response(['message' => Arsse::$lang->msg("ImportSuccess")]);
+ return new Response(['message' => Arsse::$lang->msg("API.Miniflux.ImportSuccess")]);
}
protected function opmlExport(): ResponseInterface {
diff --git a/tests/cases/REST/Miniflux/TestV1.php b/tests/cases/REST/Miniflux/TestV1.php
index fc08dea4..6466fba3 100644
--- a/tests/cases/REST/Miniflux/TestV1.php
+++ b/tests/cases/REST/Miniflux/TestV1.php
@@ -16,12 +16,15 @@ use JKingWeb\Arsse\Misc\Date;
use JKingWeb\Arsse\REST\Miniflux\V1;
use JKingWeb\Arsse\REST\Miniflux\ErrorResponse;
use JKingWeb\Arsse\Feed\Exception as FeedException;
+use JKingWeb\Arsse\ImportExport\Exception as ImportException;
+use JKingWeb\Arsse\ImportExport\OPML;
use JKingWeb\Arsse\User\ExceptionConflict;
use JKingWeb\Arsse\User\ExceptionInput as UserExceptionInput;
use Psr\Http\Message\ResponseInterface;
use Laminas\Diactoros\Response\JsonResponse as Response;
use Laminas\Diactoros\Response\EmptyResponse;
use JKingWeb\Arsse\Test\Result;
+use Laminas\Diactoros\Response\TextResponse;
/** @covers \JKingWeb\Arsse\REST\Miniflux\V1 */
class TestV1 extends \JKingWeb\Arsse\Test\AbstractTest {
@@ -934,4 +937,38 @@ class TestV1 extends \JKingWeb\Arsse\Test\AbstractTest {
public function testRefreshAllFeeds(): void {
$this->assertMessage(new EmptyResponse(204), $this->req("PUT", "/feeds/refresh"));
}
+
+ /** @dataProvider provideImports */
+ public function testImport($out, ResponseInterface $exp): void {
+ $opml = \Phake::mock(OPML::class);
+ \Phake::when(Arsse::$obj)->get(OPML::class)->thenReturn($opml);
+ if ($out instanceof \Exception) {
+ \Phake::when($opml)->import->thenThrow($out);
+ } else {
+ \Phake::when($opml)->import->thenReturn($out);
+ }
+ $this->assertMessage($exp, $this->req("POST", "/import", "IMPORT DATA"));
+ \Phake::verify($opml)->import(Arsse::$user->id, "IMPORT DATA");
+ }
+
+ public function provideImports(): iterable {
+ self::clearData();
+ return [
+ [new ImportException("invalidSyntax"), new ErrorResponse("InvalidBodyXML", 400)],
+ [new ImportException("invalidSemantics"), new ErrorResponse("InvalidBodyOPML", 422)],
+ [new ImportException("invalidFolderName"), new ErrorResponse("InvalidImportCategory", 422)],
+ [new ImportException("invalidFolderCopy"), new ErrorResponse("DuplicateImportCategory", 422)],
+ [new ImportException("invalidTagName"), new ErrorResponse("InvalidImportLabel", 422)],
+ [new FeedException("invalidUrl", ['url' => "http://example.com/"]), new ErrorResponse(["FailedImportFeed", 'url' => "http://example.com/", 'code' => 10502], 502)],
+ [true, new Response(['message' => Arsse::$lang->msg("API.Miniflux.ImportSuccess")])],
+ ];
+ }
+
+ public function testExport(): void {
+ $opml = \Phake::mock(OPML::class);
+ \Phake::when(Arsse::$obj)->get(OPML::class)->thenReturn($opml);
+ \Phake::when($opml)->export->thenReturn("EXPORT DATA");
+ $this->assertMessage(new TextResponse("EXPORT DATA", 200, ['Content-Type' => "application/xml"]), $this->req("GET", "/export"));
+ \Phake::verify($opml)->export(Arsse::$user->id);
+ }
}
From eae0ba4b68c70e82a31405b9b5372ee999fe5cd4 Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Sun, 7 Feb 2021 19:20:10 -0500
Subject: [PATCH 167/366] Tests fortoken operations
---
lib/REST/Miniflux/V1.php | 2 +-
tests/cases/REST/Miniflux/TestV1.php | 40 +++++++++++++++++++++++++++-
2 files changed, 40 insertions(+), 2 deletions(-)
diff --git a/lib/REST/Miniflux/V1.php b/lib/REST/Miniflux/V1.php
index ee9c3329..097a9c86 100644
--- a/lib/REST/Miniflux/V1.php
+++ b/lib/REST/Miniflux/V1.php
@@ -1203,7 +1203,7 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
}
public static function tokenGenerate(string $user, string $label): string {
- // Miniflux produces tokenss in base64url alphabet
+ // Miniflux produces tokens in base64url alphabet
$t = str_replace(["+", "/"], ["-", "_"], base64_encode(random_bytes(self::TOKEN_LENGTH)));
return Arsse::$db->tokenCreate($user, "miniflux.login", $t, null, $label);
}
diff --git a/tests/cases/REST/Miniflux/TestV1.php b/tests/cases/REST/Miniflux/TestV1.php
index 6466fba3..73b9b4a8 100644
--- a/tests/cases/REST/Miniflux/TestV1.php
+++ b/tests/cases/REST/Miniflux/TestV1.php
@@ -667,6 +667,14 @@ class TestV1 extends \JKingWeb\Arsse\Test\AbstractTest {
];
}
+ public function testModifyAFeedWithNoBody(): void {
+ $this->h = \Phake::partialMock(V1::class);
+ \Phake::when($this->h)->getFeed->thenReturn(new Response(self::FEEDS_OUT[0]));
+ \Phake::when(Arsse::$db)->subscriptionPropertiesSet->thenReturn(true);
+ $this->assertMessage(new Response(self::FEEDS_OUT[0]), $this->req("PUT", "/feeds/2112", ""));
+ \Phake::verify(Arsse::$db)->subscriptionPropertiesSet(Arsse::$user->id, 2112, []);
+ }
+
public function testDeleteAFeed(): void {
\Phake::when(Arsse::$db)->subscriptionRemove->thenReturn(true);
$this->assertMessage(new EmptyResponse(204), $this->req("DELETE", "/feeds/2112"));
@@ -971,4 +979,34 @@ class TestV1 extends \JKingWeb\Arsse\Test\AbstractTest {
$this->assertMessage(new TextResponse("EXPORT DATA", 200, ['Content-Type' => "application/xml"]), $this->req("GET", "/export"));
\Phake::verify($opml)->export(Arsse::$user->id);
}
-}
+
+ public function testGenerateTokens(): void {
+ \Phake::when(Arsse::$db)->tokenCreate->thenReturn("RANDOM TOKEN");
+ $this->assertSame("RANDOM TOKEN", V1::tokenGenerate("ook", "Eek"));
+ \Phake::verify(Arsse::$db)->tokenCreate("ook", "miniflux.login", \Phake::capture($token), null, "Eek");
+ $this->assertRegExp("/^[A-Za-z0-9_\-]{43}=$/", $token);
+ }
+
+ public function testListTheTokensOfAUser(): void {
+ $out = [
+ ['id' => "TOKEN 1", 'data' => "Ook"],
+ ['id' => "TOKEN 2", 'data' => "Eek"],
+ ['id' => "TOKEN 3", 'data' => "Ack"],
+ ];
+ $exp = [
+ ['label' => "Ook", 'id' => "TOKEN 1"],
+ ['label' => "Eek", 'id' => "TOKEN 2"],
+ ['label' => "Ack", 'id' => "TOKEN 3"],
+ ];
+ \Phake::when(Arsse::$db)->tokenList->thenReturn(new Result($this->v($out)));
+ \Phake::when(Arsse::$db)->userExists->thenReturn(true);
+ $this->assertSame($exp, V1::tokenList("john.doe@example.com"));
+ \Phake::verify(Arsse::$db)->tokenList("john.doe@example.com", "miniflux.login");
+ }
+
+ public function testListTheTokensOfAMissingUser(): void {
+ \Phake::when(Arsse::$db)->userExists->thenReturn(false);
+ $this->assertException("doesNotExist", "User", "ExceptionConflict");
+ V1::tokenList("john.doe@example.com");
+ }
+}
\ No newline at end of file
From f2e5d567ecb06b31dc86a2c18a0bdd3145551e25 Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Sun, 7 Feb 2021 21:38:16 -0500
Subject: [PATCH 168/366] Update sample Web server configuration
---
dist/apache.conf | 8 ++++----
dist/nginx.conf | 17 +++++++++++++++++
2 files changed, 21 insertions(+), 4 deletions(-)
diff --git a/dist/apache.conf b/dist/apache.conf
index 3c27b5a2..c0122967 100644
--- a/dist/apache.conf
+++ b/dist/apache.conf
@@ -10,13 +10,13 @@
ProxyFCGISetEnvIf "true" SCRIPT_FILENAME "/usr/share/arsse/arsse.php"
ProxyPreserveHost On
- # Nextcloud News v1.2, Tiny Tiny RSS API, TT-RSS newsfeed icons
-
+ # Nextcloud News v1.2, Tiny Tiny RSS API, TT-RSS newsfeed icons, Miniflux API
+
ProxyPass "unix:/var/run/php/php7.2-fpm.sock|fcgi://localhost/usr/share/arsse"
- # Nextcloud News API detection, Fever API
-
+ # Nextcloud News API detection, Fever API, Miniflux miscellanies
+
# these locations should not be behind HTTP authentication
ProxyPass "unix:/var/run/php/php7.2-fpm.sock|fcgi://localhost/usr/share/arsse"
diff --git a/dist/nginx.conf b/dist/nginx.conf
index c9c7845b..c12ff214 100644
--- a/dist/nginx.conf
+++ b/dist/nginx.conf
@@ -55,4 +55,21 @@ server {
# this path should not be behind HTTP authentication
try_files $uri @arsse;
}
+
+ # Miniflux protocol
+ location /v1/ {
+ try_files $uri @arsse;
+ }
+
+ # Miniflux version number
+ location /version {
+ # this path should not be behind HTTP authentication
+ try_files $uri @arsse;
+ }
+
+ # Miniflux "health check"
+ location /healthcheck {
+ # this path should not be behind HTTP authentication
+ try_files $uri @arsse;
+ }
}
From 211cea648e18c5ecb5b4d73b2fd0269b49ce2264 Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Mon, 8 Feb 2021 19:07:49 -0500
Subject: [PATCH 169/366] Implement TT-RSS API level 15
---
lib/REST/TinyTinyRSS/API.php | 16 ++++-
tests/cases/REST/TinyTinyRSS/TestAPI.php | 87 +++++++++++-------------
2 files changed, 53 insertions(+), 50 deletions(-)
diff --git a/lib/REST/TinyTinyRSS/API.php b/lib/REST/TinyTinyRSS/API.php
index 3de48637..0d4d12ae 100644
--- a/lib/REST/TinyTinyRSS/API.php
+++ b/lib/REST/TinyTinyRSS/API.php
@@ -24,7 +24,7 @@ use Laminas\Diactoros\Response\JsonResponse as Response;
use Laminas\Diactoros\Response\EmptyResponse;
class API extends \JKingWeb\Arsse\REST\AbstractHandler {
- public const LEVEL = 14; // emulated API level
+ public const LEVEL = 15; // emulated API level
public const VERSION = "17.4"; // emulated TT-RSS version
protected const LABEL_OFFSET = 1024; // offset below zero at which labels begin, counting down
@@ -79,7 +79,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler {
'include_header' => ValueInfo::T_BOOL | ValueInfo::M_DROP, // whether to attach a header to the results of `getHeadlines`
'search' => ValueInfo::T_STRING, // search string for `getHeadlines`
'field' => ValueInfo::T_INT, // which state to change in `updateArticle`
- 'mode' => ValueInfo::T_INT, // whether to set, clear, or toggle the selected state in `updateArticle`
+ 'mode' => ValueInfo::T_MIXED, // whether to set, clear, or toggle the selected state in `updateArticle` (integer), or whether to ignore a certain recent timeframe in `catchupFeed` (string)
'data' => ValueInfo::T_STRING, // note text in `updateArticle` if setting a note
];
protected const VIEW_MODES = ["all_articles", "adaptive", "unread", "marked", "has_note", "published"];
@@ -1037,6 +1037,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler {
public function opCatchUpFeed(array $data): array {
$id = $data['feed_id'] ?? self::FEED_ARCHIVED;
$cat = $data['is_cat'] ?? false;
+ $mode = $data['mode'] ?? "all";
$out = ['status' => "OK"];
// first prepare the context; unsupported contexts simply return early
$c = (new Context)->hidden(false);
@@ -1089,6 +1090,16 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler {
}
}
}
+ switch ($mode) {
+ case "2week":
+ $c->notModifiedSince(Date::sub("P2W", $this->now()));
+ break;
+ case "1week":
+ $c->notModifiedSince(Date::sub("P1W", $this->now()));
+ break;
+ case "1day":
+ $c->notModifiedSince(Date::sub("PT24H", $this->now()));
+ }
// perform the marking
try {
Arsse::$db->articleMark(Arsse::$user->id, ['read' => true], $c);
@@ -1102,6 +1113,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler {
public function opUpdateArticle(array $data): array {
// normalize input
$articles = array_filter(ValueInfo::normalize(explode(",", (string) $data['article_ids']), ValueInfo::T_INT | ValueInfo::M_ARRAY), [ValueInfo::class, "id"]);
+ $data['mode'] = ValueInfo::normalize($data['mode'], ValueInfo::T_INT);
if (!$articles) {
// if there are no valid articles this is an error
throw new Exception("INCORRECT_USAGE");
diff --git a/tests/cases/REST/TinyTinyRSS/TestAPI.php b/tests/cases/REST/TinyTinyRSS/TestAPI.php
index 874a103c..e79a2f65 100644
--- a/tests/cases/REST/TinyTinyRSS/TestAPI.php
+++ b/tests/cases/REST/TinyTinyRSS/TestAPI.php
@@ -19,8 +19,8 @@ use JKingWeb\Arsse\REST\TinyTinyRSS\API;
use Psr\Http\Message\ResponseInterface;
use Laminas\Diactoros\Response\JsonResponse as Response;
use Laminas\Diactoros\Response\EmptyResponse;
-
/** @covers \JKingWeb\Arsse\REST\TinyTinyRSS\API
+
* @covers \JKingWeb\Arsse\REST\TinyTinyRSS\Exception */
class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest {
protected const NOW = "2020-12-21T23:09:17.189065Z";
@@ -1309,55 +1309,46 @@ LONG_STRING;
$this->assertMessage($this->respGood($exp), $this->req($in[1]));
}
- public function testMarkFeedsAsRead(): void {
- $in1 = [
- // no-ops
- ['op' => "catchupFeed", 'sid' => "PriestsOfSyrinx"],
- ['op' => "catchupFeed", 'sid' => "PriestsOfSyrinx", 'feed_id' => 0],
- ['op' => "catchupFeed", 'sid' => "PriestsOfSyrinx", 'feed_id' => -2],
- ['op' => "catchupFeed", 'sid' => "PriestsOfSyrinx", 'feed_id' => -6],
- ['op' => "catchupFeed", 'sid' => "PriestsOfSyrinx", 'feed_id' => -1, 'is_cat' => true],
- ['op' => "catchupFeed", 'sid' => "PriestsOfSyrinx", 'feed_id' => -3, 'is_cat' => true],
- ['op' => "catchupFeed", 'sid' => "PriestsOfSyrinx", 'feed_id' => -4, 'is_cat' => true],
- ];
- $in2 = [
- // simple contexts
- ['op' => "catchupFeed", 'sid' => "PriestsOfSyrinx", 'feed_id' => -1],
- ['op' => "catchupFeed", 'sid' => "PriestsOfSyrinx", 'feed_id' => -4],
- ['op' => "catchupFeed", 'sid' => "PriestsOfSyrinx", 'feed_id' => -2112],
- ['op' => "catchupFeed", 'sid' => "PriestsOfSyrinx", 'feed_id' => 2112],
- ['op' => "catchupFeed", 'sid' => "PriestsOfSyrinx", 'feed_id' => 42, 'is_cat' => true],
- ['op' => "catchupFeed", 'sid' => "PriestsOfSyrinx", 'feed_id' => 0, 'is_cat' => true],
- ['op' => "catchupFeed", 'sid' => "PriestsOfSyrinx", 'feed_id' => -2, 'is_cat' => true],
- ];
- $in3 = [
- // this one has a tricky time-based context
- ['op' => "catchupFeed", 'sid' => "PriestsOfSyrinx", 'feed_id' => -3],
- ];
+ /** @dataProvider provideMassMarkings */
+ public function testMarkFeedsAsRead(array $in, ?Context $c): void {
+ $base = ['op' => "catchupFeed", 'sid' => "PriestsOfSyrinx"];
+ $in = array_merge($base, $in);
\Phake::when(Arsse::$db)->articleMark->thenThrow(new ExceptionInput("typeViolation"));
- $exp = $this->respGood(['status' => "OK"]);
- // verify the above are in fact no-ops
- for ($a = 0; $a < sizeof($in1); $a++) {
- $this->assertMessage($exp, $this->req($in1[$a]), "Test $a failed");
+ // create a mock-current time
+ \Phake::when(Arsse::$obj)->get(\DateTimeImmutable::class)->thenReturn(new \DateTimeImmutable(self::NOW));
+ // TT-RSS always responds the same regardless of success or failure
+ $this->assertMessage($this->respGood(['status' => "OK"]), $this->req($in));
+ if (isset($c)) {
+ \Phake::verify(Arsse::$db)->articleMark(Arsse::$user->id, ['read' => true], $c);
+ } else {
+ \Phake::verify(Arsse::$db, \Phake::times(0))->articleMark;
}
- \Phake::verify(Arsse::$db, \Phake::times(0))->articleMark;
- // verify the simple contexts
- for ($a = 0; $a < sizeof($in2); $a++) {
- $this->assertMessage($exp, $this->req($in2[$a]), "Test $a failed");
- }
- \Phake::verify(Arsse::$db)->articleMark($this->anything(), ['read' => true], (new Context)->hidden(false));
- \Phake::verify(Arsse::$db)->articleMark($this->anything(), ['read' => true], (new Context)->starred(true)->hidden(false));
- \Phake::verify(Arsse::$db)->articleMark($this->anything(), ['read' => true], (new Context)->label(1088)->hidden(false));
- \Phake::verify(Arsse::$db)->articleMark($this->anything(), ['read' => true], (new Context)->subscription(2112)->hidden(false));
- \Phake::verify(Arsse::$db)->articleMark($this->anything(), ['read' => true], (new Context)->folder(42)->hidden(false));
- \Phake::verify(Arsse::$db)->articleMark($this->anything(), ['read' => true], (new Context)->folderShallow(0)->hidden(false));
- \Phake::verify(Arsse::$db)->articleMark($this->anything(), ['read' => true], (new Context)->labelled(true)->hidden(false));
- // verify the time-based mock
- $t = Date::sub("PT24H");
- for ($a = 0; $a < sizeof($in3); $a++) {
- $this->assertMessage($exp, $this->req($in3[$a]), "Test $a failed");
- }
- \Phake::verify(Arsse::$db)->articleMark($this->anything(), ['read' => true], $this->equalTo((new Context)->hidden(false)->modifiedSince($t), 2)); // within two seconds
+ }
+
+ public function provideMassMarkings(): iterable {
+ $c = (new Context)->hidden(false);
+ return [
+ [[], null],
+ [['feed_id' => 0], null],
+ [['feed_id' => 0, 'is_cat' => true], (clone $c)->folderShallow(0)],
+ [['feed_id' => 0, 'is_cat' => true, 'mode' => "bogus"], (clone $c)->folderShallow(0)],
+ [['feed_id' => -1], (clone $c)->starred(true)],
+ [['feed_id' => -1, 'is_cat' => true], null],
+ [['feed_id' => -3], (clone $c)->modifiedSince(Date::sub("PT24H", self::NOW))],
+ [['feed_id' => -3, 'mode' => "1day"], (clone $c)->modifiedSince(Date::sub("PT24H", self::NOW))->notModifiedSince(Date::sub("PT24H", self::NOW))], // this is a nonsense query, but it's what TT-RSS appearsto do
+ [['feed_id' => -3, 'is_cat' => true], null],
+ [['feed_id' => -2], null],
+ [['feed_id' => -2, 'is_cat' => true], (clone $c)->labelled(true)],
+ [['feed_id' => -2, 'is_cat' => true, 'mode' => "all"], (clone $c)->labelled(true)],
+ [['feed_id' => -4], $c],
+ [['feed_id' => -4, 'is_cat' => true], null],
+ [['feed_id' => -6], null],
+ [['feed_id' => -2112], (clone $c)->label(1088)],
+ [['feed_id' => 42, 'is_cat' => true], (clone $c)->folder(42)],
+ [['feed_id' => 42, 'is_cat' => true, 'mode' => "1week"], (clone $c)->folder(42)->notModifiedSince(Date::sub("P1W", self::NOW))],
+ [['feed_id' => 2112], (clone $c)->subscription(2112)],
+ [['feed_id' => 2112, 'mode' => "2week"], (clone $c)->subscription(2112)->notModifiedSince(Date::sub("P2W", self::NOW))],
+ ];
}
public function testRetrieveFeedList(): void {
From 90034ac1f8a3a046538bf7131ffb7a69e7a21bf0 Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Mon, 8 Feb 2021 19:14:11 -0500
Subject: [PATCH 170/366] Style fixes
---
lib/Database.php | 11 +++----
lib/Factory.php | 2 +-
lib/Misc/ValueInfo.php | 1 -
lib/REST/Miniflux/V1.php | 40 ++++++++++++------------
tests/cases/Misc/TestFactory.php | 2 +-
tests/cases/REST/Miniflux/TestV1.php | 11 +++----
tests/cases/REST/TestREST.php | 1 -
tests/cases/REST/TinyTinyRSS/TestAPI.php | 4 +--
8 files changed, 34 insertions(+), 38 deletions(-)
diff --git a/lib/Database.php b/lib/Database.php
index db6f087d..b3b2b22a 100644
--- a/lib/Database.php
+++ b/lib/Database.php
@@ -755,7 +755,7 @@ class Database {
}
/** Lists a user's subscriptions, returning various data
- *
+ *
* Each record has the following keys:
*
* - "id": The numeric identifier of the subscription
@@ -993,7 +993,7 @@ class Database {
* - "url": The URL of the icon
* - "type": The Content-Type of the icon e.g. "image/png"
* - "data": The icon itself, as a binary sring; if $withData is false this will be null
- *
+ *
* If the subscription has no icon null is returned instead of an array
*
* @param string|null $user The user who owns the subscription being queried; using null here is supported for TT-RSS and SHOULD NOT be used elsewhere as it leaks information
@@ -1031,7 +1031,7 @@ class Database {
}
/** Evalutes the filter rules specified for a subscription against every article associated with the subscription's feed
- *
+ *
* @param string $user The user who owns the subscription
* @param integer $id The identifier of the subscription whose rules are to be evaluated
*/
@@ -1072,7 +1072,6 @@ class Database {
}
}
-
/** Ensures the specified subscription exists and raises an exception otherwise
*
* Returns an associative array containing the id of the subscription and the id of the underlying newsfeed
@@ -1747,7 +1746,7 @@ class Database {
} elseif (!$context->$m) {
throw new Db\ExceptionInput("tooShort", ['field' => $m, 'action' => $this->caller(), 'min' => 1]); // must have at least one array element
}
- $columns = array_map(function ($c) use ($colDefs) {
+ $columns = array_map(function($c) use ($colDefs) {
assert(isset($colDefs[$c]), new Exception("constantUnknown", $c));
return $colDefs[$c];
}, $columns);
@@ -1758,7 +1757,7 @@ class Database {
if (!$context->not->$m() || !$context->not->$m) {
continue;
}
- $columns = array_map(function ($c) use ($colDefs) {
+ $columns = array_map(function($c) use ($colDefs) {
assert(isset($colDefs[$c]), new Exception("constantUnknown", $c));
return $colDefs[$c];
}, $columns);
diff --git a/lib/Factory.php b/lib/Factory.php
index 96989025..0dfcea85 100644
--- a/lib/Factory.php
+++ b/lib/Factory.php
@@ -10,4 +10,4 @@ class Factory {
public function get(string $class) {
return new $class;
}
-}
\ No newline at end of file
+}
diff --git a/lib/Misc/ValueInfo.php b/lib/Misc/ValueInfo.php
index 9977e786..688a394b 100644
--- a/lib/Misc/ValueInfo.php
+++ b/lib/Misc/ValueInfo.php
@@ -59,7 +59,6 @@ class ValueInfo {
'float' => ["U.u", "U.u" ],
];
-
public static function normalize($value, int $type, string $dateInFormat = null, $dateOutFormat = null) {
$allowNull = ($type & self::M_NULL);
$strict = ($type & (self::M_STRICT | self::M_DROP));
diff --git a/lib/REST/Miniflux/V1.php b/lib/REST/Miniflux/V1.php
index 097a9c86..3b192f90 100644
--- a/lib/REST/Miniflux/V1.php
+++ b/lib/REST/Miniflux/V1.php
@@ -55,7 +55,7 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
];
protected const VALID_JSON = [
// user properties which map directly to Arsse user metadata are listed separately;
- // not all these properties are used by our implementation, but they are treated
+ // not all these properties are used by our implementation, but they are treated
// with the same strictness as in Miniflux to ease cross-compatibility
'url' => "string",
'username' => "string",
@@ -90,7 +90,7 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
'stylesheet' => ["stylesheet", ""],
];
/** A map between Miniflux's input properties and our input properties when modifiying feeds
- *
+ *
* Miniflux also allows changing the following properties:
*
* - feed_url
@@ -107,7 +107,7 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
* or cannot be changed because feeds are deduplicated and changing
* how they are fetched is not practical with our implementation.
* The properties are still checked for type and syntactic validity
- * where practical, on the assumption Miniflux would also reject
+ * where practical, on the assumption Miniflux would also reject
* invalid values.
*/
protected const FEED_META_MAP = [
@@ -118,11 +118,11 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
'blocklist_rules' => "block_rule",
];
protected const ARTICLE_COLUMNS = [
- "id", "url", "title", "subscription",
+ "id", "url", "title", "subscription",
"author", "fingerprint",
- "published_date", "modified_date",
+ "published_date", "modified_date",
"starred", "unread", "hidden",
- "content", "media_url", "media_type"
+ "content", "media_url", "media_type",
];
protected const CALLS = [ // handler method Admin Path Body Query Required fields
'/categories' => [
@@ -291,7 +291,7 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
}
try {
return $this->$func(...$args);
- // @codeCoverageIgnoreStart
+ // @codeCoverageIgnoreStart
} catch (Exception $e) {
// if there was a REST exception return 400
return new EmptyResponse(400);
@@ -348,7 +348,7 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
return new ErrorResponse(["InvalidInputType", 'field' => $k, 'expected' => $t, 'actual' => gettype($body[$k])], 422);
} elseif (
(in_array($k, ["keeplist_rules", "blocklist_rules"]) && !Rule::validate($body[$k]))
- || (in_array($k, ["url", "feed_url"]) && !URL::absolute($body[$k]))
+ || (in_array($k, ["url", "feed_url"]) && !URL::absolute($body[$k]))
|| ($k === "category_id" && $body[$k] < 1)
|| ($k === "status" && !in_array($body[$k], ["read", "unread", "removed"]))
) {
@@ -492,7 +492,7 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
protected function editUser(string $user, array $data): array {
// map Miniflux properties to internal metadata properties
$in = [];
- foreach (self::USER_META_MAP as $i => [$o,]) {
+ foreach (self::USER_META_MAP as $i => [$o]) {
if (isset($data[$i])) {
if ($i === "entry_sorting_direction") {
$in[$o] = $data[$i] === "asc";
@@ -640,9 +640,9 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
}
/** Returns a useful subset of user metadata
- *
+ *
* The following keys are included:
- *
+ *
* - "num": The user's numeric ID,
* - "root": The effective name of the root folder
*/
@@ -880,8 +880,8 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
return new ErrorResponse("404", 404);
}
return new Response([
- 'id' => $icon['id'],
- 'data' => $icon['type'].";base64,".base64_encode($icon['data']),
+ 'id' => $icon['id'],
+ 'data' => $icon['type'].";base64,".base64_encode($icon['data']),
'mime_type' => $icon['type'],
]);
}
@@ -960,7 +960,7 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
'url' => $entry['media_url'],
'mime_type' => $entry['media_type'] ?: "application/octet-stream",
'size' => 0,
- ]
+ ],
];
} else {
$enclosures = null;
@@ -1030,7 +1030,7 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
$out['feed'] = $this->transformFeed(Arsse::$db->subscriptionPropertiesGet(Arsse::$user->id, $out['feed_id']), $meta['num'], $meta['root'], $meta['tz']);
return $out;
}
-
+
protected function getEntries(array $query): ResponseInterface {
try {
return new Response($this->listEntries($query, new Context));
@@ -1038,7 +1038,7 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
return new ErrorResponse("MissingCategory", 400);
}
}
-
+
protected function getFeedEntries(array $path, array $query): ResponseInterface {
$c = (new Context)->subscription((int) $path[1]);
try {
@@ -1048,7 +1048,7 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
return new ErrorResponse("404", 404);
}
}
-
+
protected function getCategoryEntries(array $path, array $query): ResponseInterface {
$query['category_id'] = (int) $path[1];
try {
@@ -1057,7 +1057,7 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
return new ErrorResponse("404", 404);
}
}
-
+
protected function getEntry(array $path): ResponseInterface {
try {
return new Response($this->findEntry((int) $path[1]));
@@ -1065,7 +1065,7 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
return new ErrorResponse("404", 404);
}
}
-
+
protected function getFeedEntry(array $path): ResponseInterface {
$c = (new Context)->subscription((int) $path[1]);
try {
@@ -1074,7 +1074,7 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
return new ErrorResponse("404", 404);
}
}
-
+
protected function getCategoryEntry(array $path): ResponseInterface {
$c = new Context;
if ($path[1] === "1") {
diff --git a/tests/cases/Misc/TestFactory.php b/tests/cases/Misc/TestFactory.php
index e400c2f7..c6940193 100644
--- a/tests/cases/Misc/TestFactory.php
+++ b/tests/cases/Misc/TestFactory.php
@@ -14,4 +14,4 @@ class TestFactory extends \JKingWeb\Arsse\Test\AbstractTest {
$f = new Factory;
$this->assertInstanceOf(\stdClass::class, $f->get(\stdClass::class));
}
-}
\ No newline at end of file
+}
diff --git a/tests/cases/REST/Miniflux/TestV1.php b/tests/cases/REST/Miniflux/TestV1.php
index 73b9b4a8..ad6b9886 100644
--- a/tests/cases/REST/Miniflux/TestV1.php
+++ b/tests/cases/REST/Miniflux/TestV1.php
@@ -12,7 +12,6 @@ use JKingWeb\Arsse\User;
use JKingWeb\Arsse\Database;
use JKingWeb\Arsse\Db\Transaction;
use JKingWeb\Arsse\Db\ExceptionInput;
-use JKingWeb\Arsse\Misc\Date;
use JKingWeb\Arsse\REST\Miniflux\V1;
use JKingWeb\Arsse\REST\Miniflux\ErrorResponse;
use JKingWeb\Arsse\Feed\Exception as FeedException;
@@ -46,7 +45,7 @@ class TestV1 extends \JKingWeb\Arsse\Test\AbstractTest {
['id' => 42, 'url' => "http://example.com/42", 'title' => "Title 42", 'subscription' => 55, 'author' => "Thomas Costain", 'fingerprint' => "FINGERPRINT", 'published_date' => "2021-01-22 02:21:12", 'modified_date' => "2021-01-22 13:44:47", 'starred' => 0, 'unread' => 0, 'hidden' => 0, 'content' => "Content 42", 'media_url' => null, 'media_type' => null],
['id' => 44, 'url' => "http://example.com/44", 'title' => "Title 44", 'subscription' => 55, 'author' => null, 'fingerprint' => "FINGERPRINT", 'published_date' => "2021-01-22 02:21:12", 'modified_date' => "2021-01-22 13:44:47", 'starred' => 1, 'unread' => 1, 'hidden' => 0, 'content' => "Content 44", 'media_url' => "http://example.com/44/enclosure", 'media_type' => null],
['id' => 47, 'url' => "http://example.com/47", 'title' => "Title 47", 'subscription' => 55, 'author' => null, 'fingerprint' => "FINGERPRINT", 'published_date' => "2021-01-22 02:21:12", 'modified_date' => "2021-01-22 13:44:47", 'starred' => 0, 'unread' => 1, 'hidden' => 1, 'content' => "Content 47", 'media_url' => "http://example.com/47/enclosure", 'media_type' => ""],
- ['id' => 2112, 'url' => "http://example.com/2112", 'title' => "Title 2112", 'subscription' => 55, 'author' => null, 'fingerprint' => "FINGERPRINT", 'published_date' => "2021-01-22 02:21:12", 'modified_date' => "2021-01-22 13:44:47", 'starred' => 0, 'unread' => 0, 'hidden' => 1, 'content' => "Content 2112", 'media_url' => "http://example.com/2112/enclosure", 'media_type' => "image/png"]
+ ['id' => 2112, 'url' => "http://example.com/2112", 'title' => "Title 2112", 'subscription' => 55, 'author' => null, 'fingerprint' => "FINGERPRINT", 'published_date' => "2021-01-22 02:21:12", 'modified_date' => "2021-01-22 13:44:47", 'starred' => 0, 'unread' => 0, 'hidden' => 1, 'content' => "Content 2112", 'media_url' => "http://example.com/2112/enclosure", 'media_type' => "image/png"],
];
protected const ENTRIES_OUT = [
['id' => 42, 'user_id' => 42, 'feed_id' => 55, 'status' => "read", 'hash' => "FINGERPRINT", 'title' => "Title 42", 'url' => "http://example.com/42", 'comments_url' => "", 'published_at' => "2021-01-22T04:21:12+02:00", 'created_at' => "2021-01-22T15:44:47.000000+02:00", 'content' => "Content 42", 'author' => "Thomas Costain", 'share_code' => "", 'starred' => false, 'reading_time' => 0, 'enclosures' => null, 'feed' => self::FEEDS_OUT[1]],
@@ -663,7 +662,7 @@ class TestV1 extends \JKingWeb\Arsse\Test\AbstractTest {
[['crawler' => false], ['scrape' => false], true, $success],
[['keeplist_rules' => ""], ['keep_rule' => ""], true, $success],
[['blocklist_rules' => "ook"], ['block_rule' => "ook"], true, $success],
- [['title' => "Ook!", 'crawler' => true], ['title' => "Ook!", 'scrape' => true], true, $success]
+ [['title' => "Ook!", 'crawler' => true], ['title' => "Ook!", 'scrape' => true], true, $success],
];
}
@@ -857,8 +856,8 @@ class TestV1 extends \JKingWeb\Arsse\Test\AbstractTest {
[['entry_ids' => 1, 'status' => "read"], null, new ErrorResponse(["InvalidInputType", 'field' => "entry_ids", 'expected' => "array", 'actual' => "integer"], 422)],
[['entry_ids' => ["1"], 'status' => "read"], null, new ErrorResponse(["InvalidInputType", 'field' => "entry_ids", 'expected' => "integer", 'actual' => "string"], 422)],
[['entry_ids' => [1], 'status' => 1], null, new ErrorResponse(["InvalidInputType", 'field' => "status", 'expected' => "string", 'actual' => "integer"], 422)],
- [['entry_ids' => [0], 'status' => "read"], null, new ErrorResponse(["InvalidInputValue", 'field' => "entry_ids",], 422)],
- [['entry_ids' => [1], 'status' => "reread"], null, new ErrorResponse(["InvalidInputValue", 'field' => "status",], 422)],
+ [['entry_ids' => [0], 'status' => "read"], null, new ErrorResponse(["InvalidInputValue", 'field' => "entry_ids"], 422)],
+ [['entry_ids' => [1], 'status' => "reread"], null, new ErrorResponse(["InvalidInputValue", 'field' => "status"], 422)],
[['entry_ids' => [1, 2], 'status' => "read"], ['read' => true, 'hidden' => false], new EmptyResponse(204)],
[['entry_ids' => [1, 2], 'status' => "unread"], ['read' => false, 'hidden' => false], new EmptyResponse(204)],
[['entry_ids' => [1, 2], 'status' => "removed"], ['read' => true, 'hidden' => true], new EmptyResponse(204)],
@@ -1009,4 +1008,4 @@ class TestV1 extends \JKingWeb\Arsse\Test\AbstractTest {
$this->assertException("doesNotExist", "User", "ExceptionConflict");
V1::tokenList("john.doe@example.com");
}
-}
\ No newline at end of file
+}
diff --git a/tests/cases/REST/TestREST.php b/tests/cases/REST/TestREST.php
index 18642150..a7eb83e9 100644
--- a/tests/cases/REST/TestREST.php
+++ b/tests/cases/REST/TestREST.php
@@ -9,7 +9,6 @@ namespace JKingWeb\Arsse\TestCase\REST;
use JKingWeb\Arsse\Arsse;
use JKingWeb\Arsse\User;
use JKingWeb\Arsse\REST;
-use JKingWeb\Arsse\REST\Handler;
use JKingWeb\Arsse\REST\Exception501;
use JKingWeb\Arsse\REST\NextcloudNews\V1_2 as NCN;
use JKingWeb\Arsse\REST\TinyTinyRSS\API as TTRSS;
diff --git a/tests/cases/REST/TinyTinyRSS/TestAPI.php b/tests/cases/REST/TinyTinyRSS/TestAPI.php
index e79a2f65..139f7dcf 100644
--- a/tests/cases/REST/TinyTinyRSS/TestAPI.php
+++ b/tests/cases/REST/TinyTinyRSS/TestAPI.php
@@ -19,8 +19,8 @@ use JKingWeb\Arsse\REST\TinyTinyRSS\API;
use Psr\Http\Message\ResponseInterface;
use Laminas\Diactoros\Response\JsonResponse as Response;
use Laminas\Diactoros\Response\EmptyResponse;
-/** @covers \JKingWeb\Arsse\REST\TinyTinyRSS\API
+/** @covers \JKingWeb\Arsse\REST\TinyTinyRSS\API
* @covers \JKingWeb\Arsse\REST\TinyTinyRSS\Exception */
class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest {
protected const NOW = "2020-12-21T23:09:17.189065Z";
@@ -1317,7 +1317,7 @@ LONG_STRING;
// create a mock-current time
\Phake::when(Arsse::$obj)->get(\DateTimeImmutable::class)->thenReturn(new \DateTimeImmutable(self::NOW));
// TT-RSS always responds the same regardless of success or failure
- $this->assertMessage($this->respGood(['status' => "OK"]), $this->req($in));
+ $this->assertMessage($this->respGood(['status' => "OK"]), $this->req($in));
if (isset($c)) {
\Phake::verify(Arsse::$db)->articleMark(Arsse::$user->id, ['read' => true], $c);
} else {
From dad74c2616dd71874ec07c04d61e0e7770aa314d Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Mon, 8 Feb 2021 23:51:40 -0500
Subject: [PATCH 171/366] Implement Fever icons
---
lib/REST/Fever/API.php | 29 ++++++++++++++++++++---------
tests/cases/REST/Fever/TestAPI.php | 23 ++++++++++++++++-------
2 files changed, 36 insertions(+), 16 deletions(-)
diff --git a/lib/REST/Fever/API.php b/lib/REST/Fever/API.php
index 8c94a8dd..8f43d450 100644
--- a/lib/REST/Fever/API.php
+++ b/lib/REST/Fever/API.php
@@ -150,14 +150,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler {
$out['feeds_groups'] = $this->getRelationships();
}
if ($G['favicons']) {
- // TODO: implement favicons properly
- // we provide a single blank favicon for now
- $out['favicons'] = [
- [
- 'id' => 0,
- 'data' => self::GENERIC_ICON_TYPE.",".self::GENERIC_ICON_DATA,
- ],
- ];
+ $out['favicons'] = $this->getIcons();
}
if ($G['items']) {
$out['items'] = $this->getItems($G);
@@ -333,7 +326,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler {
foreach (arsse::$db->subscriptionList(Arsse::$user->id) as $sub) {
$out[] = [
'id' => (int) $sub['id'],
- 'favicon_id' => 0, // TODO: implement favicons
+ 'favicon_id' => (int) $sub['icon_id'],
'title' => (string) $sub['title'],
'url' => $sub['url'],
'site_url' => $sub['source'],
@@ -344,6 +337,24 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler {
return $out;
}
+ protected function getIcons(): array {
+ $out = [
+ [
+ 'id' => 0,
+ 'data' => self::GENERIC_ICON_TYPE.",".self::GENERIC_ICON_DATA,
+ ],
+ ];
+ foreach (Arsse::$db->iconList(Arsse::$user->id) as $icon) {
+ if ($icon['data']) {
+ $out[] = [
+ 'id' => (int) $icon['id'],
+ 'data' => ($icon['type'] ?: "application/octet-stream").";base64,".base64_encode($icon['data']),
+ ];
+ }
+ }
+ return $out;
+ }
+
protected function getGroups(): array {
$out = [];
foreach (Arsse::$db->tagList(Arsse::$user->id) as $member) {
diff --git a/tests/cases/REST/Fever/TestAPI.php b/tests/cases/REST/Fever/TestAPI.php
index 2d41dd15..eb44d660 100644
--- a/tests/cases/REST/Fever/TestAPI.php
+++ b/tests/cases/REST/Fever/TestAPI.php
@@ -273,9 +273,9 @@ class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest {
public function testListFeeds(): void {
\Phake::when(Arsse::$db)->subscriptionList(Arsse::$user->id)->thenReturn(new Result([
- ['id' => 1, 'feed' => 5, 'title' => "Ankh-Morpork News", 'url' => "http://example.com/feed", 'source' => "http://example.com/", 'edited' => "2019-01-01 21:12:00", 'icon_url' => "http://example.com/favicon.ico"],
- ['id' => 2, 'feed' => 9, 'title' => "Ook, Ook Eek Ook!", 'url' => "http://example.net/feed", 'source' => "http://example.net/", 'edited' => "1988-06-24 12:21:00", 'icon_url' => ""],
- ['id' => 3, 'feed' => 1, 'title' => "The Last Soul", 'url' => "http://example.org/feed", 'source' => "http://example.org/", 'edited' => "1991-08-12 03:22:00", 'icon_url' => "http://example.org/favicon.ico"],
+ ['id' => 1, 'feed' => 5, 'title' => "Ankh-Morpork News", 'url' => "http://example.com/feed", 'source' => "http://example.com/", 'edited' => "2019-01-01 21:12:00", 'icon_url' => "http://example.com/favicon.ico", 'icon_id' => 42],
+ ['id' => 2, 'feed' => 9, 'title' => "Ook, Ook Eek Ook!", 'url' => "http://example.net/feed", 'source' => "http://example.net/", 'edited' => "1988-06-24 12:21:00", 'icon_url' => "", 'icon_id' => null],
+ ['id' => 3, 'feed' => 1, 'title' => "The Last Soul", 'url' => "http://example.org/feed", 'source' => "http://example.org/", 'edited' => "1991-08-12 03:22:00", 'icon_url' => "http://example.org/favicon.ico", 'icon_id' => 42],
]));
\Phake::when(Arsse::$db)->tagSummarize(Arsse::$user->id)->thenReturn(new Result([
['id' => 1, 'name' => "Fascinating", 'subscription' => 1],
@@ -285,9 +285,9 @@ class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest {
]));
$exp = new JsonResponse([
'feeds' => [
- ['id' => 1, 'favicon_id' => 0, 'title' => "Ankh-Morpork News", 'url' => "http://example.com/feed", 'site_url' => "http://example.com/", 'is_spark' => 0, 'last_updated_on_time' => strtotime("2019-01-01T21:12:00Z")],
- ['id' => 2, 'favicon_id' => 0, 'title' => "Ook, Ook Eek Ook!", 'url' => "http://example.net/feed", 'site_url' => "http://example.net/", 'is_spark' => 0, 'last_updated_on_time' => strtotime("1988-06-24T12:21:00Z")],
- ['id' => 3, 'favicon_id' => 0, 'title' => "The Last Soul", 'url' => "http://example.org/feed", 'site_url' => "http://example.org/", 'is_spark' => 0, 'last_updated_on_time' => strtotime("1991-08-12T03:22:00Z")],
+ ['id' => 1, 'favicon_id' => 42, 'title' => "Ankh-Morpork News", 'url' => "http://example.com/feed", 'site_url' => "http://example.com/", 'is_spark' => 0, 'last_updated_on_time' => strtotime("2019-01-01T21:12:00Z")],
+ ['id' => 2, 'favicon_id' => 0, 'title' => "Ook, Ook Eek Ook!", 'url' => "http://example.net/feed", 'site_url' => "http://example.net/", 'is_spark' => 0, 'last_updated_on_time' => strtotime("1988-06-24T12:21:00Z")],
+ ['id' => 3, 'favicon_id' => 42, 'title' => "The Last Soul", 'url' => "http://example.org/feed", 'site_url' => "http://example.org/", 'is_spark' => 0, 'last_updated_on_time' => strtotime("1991-08-12T03:22:00Z")],
],
'feeds_groups' => [
['group_id' => 1, 'feed_ids' => "1,2"],
@@ -492,8 +492,17 @@ class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest {
public function testListFeedIcons(): void {
$iconType = (new \ReflectionClassConstant(API::class, "GENERIC_ICON_TYPE"))->getValue();
$iconData = (new \ReflectionClassConstant(API::class, "GENERIC_ICON_DATA"))->getValue();
+ \Phake::when(Arsse::$db)->iconList->thenReturn(new Result($this->v([
+ ['id' => 42, 'type' => "image/svg+xml", 'data' => " "],
+ ['id' => 44, 'type' => null, 'data' => "IMAGE DATA"],
+ ['id' => 47, 'type' => null, 'data' => null],
+ ])));
$act = $this->h->dispatch($this->req("api&favicons"));
- $exp = new JsonResponse(['favicons' => [['id' => 0, 'data' => $iconType.",".$iconData]]]);
+ $exp = new JsonResponse(['favicons' => [
+ ['id' => 0, 'data' => $iconType.",".$iconData],
+ ['id' => 42, 'data' => "image/svg+xml;base64,PHN2Zy8+"],
+ ['id' => 44, 'data' => "application/octet-stream;base64,SU1BR0UgREFUQQ=="],
+ ]]);
$this->assertMessage($exp, $act);
}
From 29761d767a50d08f3e8c2fc8a416df4cab8400b3 Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Mon, 8 Feb 2021 23:52:13 -0500
Subject: [PATCH 172/366] Update documentation
---
CHANGELOG | 2 ++
docs/en/030_Supported_Protocols/030_Fever.md | 1 -
lib/REST/Miniflux/V1.php | 2 +-
3 files changed, 3 insertions(+), 2 deletions(-)
diff --git a/CHANGELOG b/CHANGELOG
index 8580d40b..f41bf0ff 100644
--- a/CHANGELOG
+++ b/CHANGELOG
@@ -3,6 +3,8 @@ Version 0.9.0 (????-??-??)
New features:
- Support for the Miniflux protocol (see manual for details)
+- Support for API level 15 of Tiny Tiny RSS
+- Support for feed icons in Fever
Bug fixes:
- Use icons specified in Atom feeds when available
diff --git a/docs/en/030_Supported_Protocols/030_Fever.md b/docs/en/030_Supported_Protocols/030_Fever.md
index 094a909f..846d7e1f 100644
--- a/docs/en/030_Supported_Protocols/030_Fever.md
+++ b/docs/en/030_Supported_Protocols/030_Fever.md
@@ -23,7 +23,6 @@ The Fever protocol is incomplete, unusual, _and_ a product of proprietary softwa
- All feeds are considered "Kindling"
- The "Hot Links" feature is not implemented; when requested, an empty array will be returned. As there is no way to classify a feed as a "Spark" in the protocol itself and no documentation exists on how link temperature was calculated, an implementation is unlikely to appear in the future
-- Favicons are not currently supported; all feeds have a simple blank image as their favicon unless the client finds the icons itself
# Special considerations
diff --git a/lib/REST/Miniflux/V1.php b/lib/REST/Miniflux/V1.php
index 3b192f90..4e4c959d 100644
--- a/lib/REST/Miniflux/V1.php
+++ b/lib/REST/Miniflux/V1.php
@@ -30,7 +30,7 @@ use Laminas\Diactoros\Response\TextResponse as GenericResponse;
use Laminas\Diactoros\Uri;
class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
- public const VERSION = "2.0.26";
+ public const VERSION = "2.0.28";
protected const ACCEPTED_TYPES_OPML = ["application/xml", "text/xml", "text/x-opml"];
protected const ACCEPTED_TYPES_JSON = ["application/json"];
From 687995c4972e79b67a758373224af017eebb0f19 Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Tue, 9 Feb 2021 00:33:41 -0500
Subject: [PATCH 173/366] More potential Miniflux Web clints
---
docs/en/040_Compatible_Clients.md | 18 ++++++++++++++++++
1 file changed, 18 insertions(+)
diff --git a/docs/en/040_Compatible_Clients.md b/docs/en/040_Compatible_Clients.md
index f4585c9b..bc089e24 100644
--- a/docs/en/040_Compatible_Clients.md
+++ b/docs/en/040_Compatible_Clients.md
@@ -19,6 +19,24 @@ The Arsse does not at this time have any first party clients. However, because T
Web
+
+ maxiflux
+
+ ✔
+ ✘
+ ✘
+ ✘
+
+
+
+ Miniflux Reader
+
+ ✔
+ ✘
+ ✘
+ ✘
+
+
reminiflux
From 9ad4a37ddfdc84e93139d7e6448d5f46e47597cd Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Tue, 9 Feb 2021 09:26:12 -0500
Subject: [PATCH 174/366] Tests and fixes for Miniflux with PDO
---
lib/REST/Miniflux/V1.php | 6 +++---
tests/cases/REST/Miniflux/PDO/TestV1.php | 13 +++++++++++++
tests/phpunit.dist.xml | 1 +
3 files changed, 17 insertions(+), 3 deletions(-)
create mode 100644 tests/cases/REST/Miniflux/PDO/TestV1.php
diff --git a/lib/REST/Miniflux/V1.php b/lib/REST/Miniflux/V1.php
index 4e4c959d..1a529547 100644
--- a/lib/REST/Miniflux/V1.php
+++ b/lib/REST/Miniflux/V1.php
@@ -880,7 +880,7 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
return new ErrorResponse("404", 404);
}
return new Response([
- 'id' => $icon['id'],
+ 'id' => (int) $icon['id'],
'data' => $icon['type'].";base64,".base64_encode($icon['data']),
'mime_type' => $icon['type'],
]);
@@ -954,9 +954,9 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
if ($entry['media_url']) {
$enclosures = [
[
- 'id' => $entry['id'], // NOTE: We don't have IDs for enclosures, but we also only have one enclosure per entry, so we can just re-use the same ID
+ 'id' => (int) $entry['id'], // NOTE: We don't have IDs for enclosures, but we also only have one enclosure per entry, so we can just re-use the same ID
'user_id' => $uid,
- 'entry_id' => $entry['id'],
+ 'entry_id' => (int) $entry['id'],
'url' => $entry['media_url'],
'mime_type' => $entry['media_type'] ?: "application/octet-stream",
'size' => 0,
diff --git a/tests/cases/REST/Miniflux/PDO/TestV1.php b/tests/cases/REST/Miniflux/PDO/TestV1.php
new file mode 100644
index 00000000..977ffa4e
--- /dev/null
+++ b/tests/cases/REST/Miniflux/PDO/TestV1.php
@@ -0,0 +1,13 @@
+
+ * @group optional */
+class TestV1 extends \JKingWeb\Arsse\TestCase\REST\Miniflux\TestV1 {
+ use \JKingWeb\Arsse\Test\PDOTest;
+}
diff --git a/tests/phpunit.dist.xml b/tests/phpunit.dist.xml
index 3d576064..99831a45 100644
--- a/tests/phpunit.dist.xml
+++ b/tests/phpunit.dist.xml
@@ -118,6 +118,7 @@
cases/REST/Miniflux/TestErrorResponse.php
cases/REST/Miniflux/TestStatus.php
cases/REST/Miniflux/TestV1.php
+ cases/REST/Miniflux/PDO/TestV1.php
cases/REST/NextcloudNews/TestVersions.php
From a760bf2ded3eba8c671a2d48f6487aaaaf177cfe Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Tue, 9 Feb 2021 09:37:31 -0500
Subject: [PATCH 175/366] Implement "t" and "f" booleans in TT-RSS
---
CHANGELOG | 1 +
lib/REST/AbstractHandler.php | 13 ---
lib/REST/NextcloudNews/V1_2.php | 12 +++
lib/REST/TinyTinyRSS/API.php | 122 +++++++++++++----------
tests/cases/REST/TinyTinyRSS/TestAPI.php | 4 +-
5 files changed, 86 insertions(+), 66 deletions(-)
diff --git a/CHANGELOG b/CHANGELOG
index f41bf0ff..ba7040e8 100644
--- a/CHANGELOG
+++ b/CHANGELOG
@@ -11,6 +11,7 @@ Bug fixes:
- Do not return null as subscription unread count
- Explicitly forbid U+003A COLON and control characters in usernames, for
compatibility with RFC 7617
+- Accept "t" and "f" as booleans in Tiny Tiny RSS
Version 0.8.5 (2020-10-27)
==========================
diff --git a/lib/REST/AbstractHandler.php b/lib/REST/AbstractHandler.php
index 7103c34a..2dadfa91 100644
--- a/lib/REST/AbstractHandler.php
+++ b/lib/REST/AbstractHandler.php
@@ -8,7 +8,6 @@ namespace JKingWeb\Arsse\REST;
use JKingWeb\Arsse\Arsse;
use JKingWeb\Arsse\Misc\Date;
-use JKingWeb\Arsse\Misc\ValueInfo;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\ResponseInterface;
@@ -46,16 +45,4 @@ abstract class AbstractHandler implements Handler {
}
return $data;
}
-
- protected function normalizeInput(array $data, array $types, string $dateFormat = null, int $mode = 0): array {
- $out = [];
- foreach ($types as $key => $type) {
- if (isset($data[$key])) {
- $out[$key] = ValueInfo::normalize($data[$key], $type | $mode, $dateFormat);
- } else {
- $out[$key] = null;
- }
- }
- return $out;
- }
}
diff --git a/lib/REST/NextcloudNews/V1_2.php b/lib/REST/NextcloudNews/V1_2.php
index 57d1e732..2b14cbd5 100644
--- a/lib/REST/NextcloudNews/V1_2.php
+++ b/lib/REST/NextcloudNews/V1_2.php
@@ -136,6 +136,18 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler {
return implode("/", $path);
}
+ protected function normalizeInput(array $data, array $types, string $dateFormat = null, int $mode = 0): array {
+ $out = [];
+ foreach ($types as $key => $type) {
+ if (isset($data[$key])) {
+ $out[$key] = ValueInfo::normalize($data[$key], $type | $mode, $dateFormat);
+ } else {
+ $out[$key] = null;
+ }
+ }
+ return $out;
+ }
+
protected function chooseCall(string $url, string $method) {
// // normalize the URL path: change any IDs to 1 for easier comparison
$url = $this->normalizePathIds($url);
diff --git a/lib/REST/TinyTinyRSS/API.php b/lib/REST/TinyTinyRSS/API.php
index 0d4d12ae..11b983d3 100644
--- a/lib/REST/TinyTinyRSS/API.php
+++ b/lib/REST/TinyTinyRSS/API.php
@@ -12,7 +12,7 @@ use JKingWeb\Arsse\Service;
use JKingWeb\Arsse\Database;
use JKingWeb\Arsse\Context\Context;
use JKingWeb\Arsse\Misc\Date;
-use JKingWeb\Arsse\Misc\ValueInfo;
+use JKingWeb\Arsse\Misc\ValueInfo as V;
use JKingWeb\Arsse\AbstractException;
use JKingWeb\Arsse\ExceptionType;
use JKingWeb\Arsse\Db\ExceptionInput;
@@ -46,41 +46,41 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler {
// valid input
protected const ACCEPTED_TYPES = ["application/json", "text/json"];
protected const VALID_INPUT = [
- 'op' => ValueInfo::T_STRING, // the function ("operation") to perform
- 'sid' => ValueInfo::T_STRING, // session ID
- 'seq' => ValueInfo::T_INT, // request number from client
- 'user' => ValueInfo::T_STRING | ValueInfo::M_STRICT, // user name for `login`
- 'password' => ValueInfo::T_STRING | ValueInfo::M_STRICT, // password for `login` or remote password for `subscribeToFeed`
- 'include_empty' => ValueInfo::T_BOOL | ValueInfo::M_DROP, // whether to include empty items in `getFeedTree` and `getCategories`
- 'unread_only' => ValueInfo::T_BOOL | ValueInfo::M_DROP, // whether to exclude items without unread articles in `getCategories` and `getFeeds`
- 'enable_nested' => ValueInfo::T_BOOL | ValueInfo::M_DROP, // whether to NOT show subcategories in `getCategories
- 'include_nested' => ValueInfo::T_BOOL | ValueInfo::M_DROP, // whether to include subcategories in `getFeeds` and the articles thereof in `getHeadlines`
- 'caption' => ValueInfo::T_STRING | ValueInfo::M_STRICT, // name for categories, feed, and labels
- 'parent_id' => ValueInfo::T_INT, // parent category for `addCategory` and `moveCategory`
- 'category_id' => ValueInfo::T_INT, // parent category for `subscribeToFeed` and `moveFeed`, and subject for category-modification functions
- 'cat_id' => ValueInfo::T_INT, // parent category for `getFeeds`
- 'label_id' => ValueInfo::T_INT, // label ID in label-related functions
- 'feed_url' => ValueInfo::T_STRING | ValueInfo::M_STRICT, // URL of feed in `subscribeToFeed`
- 'login' => ValueInfo::T_STRING | ValueInfo::M_STRICT, // remote user name in `subscribeToFeed`
- 'feed_id' => ValueInfo::T_INT, // feed, label, or category ID for various functions
- 'is_cat' => ValueInfo::T_BOOL | ValueInfo::M_DROP, // whether 'feed_id' refers to a category
- 'article_id' => ValueInfo::T_MIXED, // single article ID in `getLabels`; one or more (comma-separated) article IDs in `getArticle`
- 'article_ids' => ValueInfo::T_STRING, // one or more (comma-separated) article IDs in `updateArticle` and `setArticleLabel`
- 'assign' => ValueInfo::T_BOOL | ValueInfo::M_DROP, // whether to assign or clear (false) a label in `setArticleLabel`
- 'limit' => ValueInfo::T_INT, // maximum number of records returned in `getFeeds`, `getHeadlines`, and `getCompactHeadlines`
- 'offset' => ValueInfo::T_INT, // number of records to skip in `getFeeds`, for pagination
- 'skip' => ValueInfo::T_INT, // number of records to skip in `getHeadlines` and `getCompactHeadlines`, for pagination
- 'show_excerpt' => ValueInfo::T_BOOL | ValueInfo::M_DROP, // whether to include article excerpts in `getHeadlines`
- 'show_content' => ValueInfo::T_BOOL | ValueInfo::M_DROP, // whether to include article content in `getHeadlines`
- 'include_attachments' => ValueInfo::T_BOOL | ValueInfo::M_DROP, // whether to include article enclosures in `getHeadlines`
- 'view_mode' => ValueInfo::T_STRING, // various filters for `getHeadlines`
- 'since_id' => ValueInfo::T_INT, // cut-off article ID for `getHeadlines` and `getCompactHeadlines; returns only higher article IDs when specified
- 'order_by' => ValueInfo::T_STRING, // sort order for `getHeadlines`
- 'include_header' => ValueInfo::T_BOOL | ValueInfo::M_DROP, // whether to attach a header to the results of `getHeadlines`
- 'search' => ValueInfo::T_STRING, // search string for `getHeadlines`
- 'field' => ValueInfo::T_INT, // which state to change in `updateArticle`
- 'mode' => ValueInfo::T_MIXED, // whether to set, clear, or toggle the selected state in `updateArticle` (integer), or whether to ignore a certain recent timeframe in `catchupFeed` (string)
- 'data' => ValueInfo::T_STRING, // note text in `updateArticle` if setting a note
+ 'op' => V::T_STRING, // the function ("operation") to perform
+ 'sid' => V::T_STRING, // session ID
+ 'seq' => V::T_INT, // request number from client
+ 'user' => V::T_STRING | V::M_STRICT, // user name for `login`
+ 'password' => V::T_STRING | V::M_STRICT, // password for `login` or remote password for `subscribeToFeed`
+ 'include_empty' => V::T_BOOL | V::M_DROP, // whether to include empty items in `getFeedTree` and `getCategories`
+ 'unread_only' => V::T_BOOL | V::M_DROP, // whether to exclude items without unread articles in `getCategories` and `getFeeds`
+ 'enable_nested' => V::T_BOOL | V::M_DROP, // whether to NOT show subcategories in `getCategories
+ 'include_nested' => V::T_BOOL | V::M_DROP, // whether to include subcategories in `getFeeds` and the articles thereof in `getHeadlines`
+ 'caption' => V::T_STRING | V::M_STRICT, // name for categories, feed, and labels
+ 'parent_id' => V::T_INT, // parent category for `addCategory` and `moveCategory`
+ 'category_id' => V::T_INT, // parent category for `subscribeToFeed` and `moveFeed`, and subject for category-modification functions
+ 'cat_id' => V::T_INT, // parent category for `getFeeds`
+ 'label_id' => V::T_INT, // label ID in label-related functions
+ 'feed_url' => V::T_STRING | V::M_STRICT, // URL of feed in `subscribeToFeed`
+ 'login' => V::T_STRING | V::M_STRICT, // remote user name in `subscribeToFeed`
+ 'feed_id' => V::T_INT, // feed, label, or category ID for various functions
+ 'is_cat' => V::T_BOOL | V::M_DROP, // whether 'feed_id' refers to a category
+ 'article_id' => V::T_MIXED, // single article ID in `getLabels`; one or more (comma-separated) article IDs in `getArticle`
+ 'article_ids' => V::T_STRING, // one or more (comma-separated) article IDs in `updateArticle` and `setArticleLabel`
+ 'assign' => V::T_BOOL | V::M_DROP, // whether to assign or clear (false) a label in `setArticleLabel`
+ 'limit' => V::T_INT, // maximum number of records returned in `getFeeds`, `getHeadlines`, and `getCompactHeadlines`
+ 'offset' => V::T_INT, // number of records to skip in `getFeeds`, for pagination
+ 'skip' => V::T_INT, // number of records to skip in `getHeadlines` and `getCompactHeadlines`, for pagination
+ 'show_excerpt' => V::T_BOOL | V::M_DROP, // whether to include article excerpts in `getHeadlines`
+ 'show_content' => V::T_BOOL | V::M_DROP, // whether to include article content in `getHeadlines`
+ 'include_attachments' => V::T_BOOL | V::M_DROP, // whether to include article enclosures in `getHeadlines`
+ 'view_mode' => V::T_STRING, // various filters for `getHeadlines`
+ 'since_id' => V::T_INT, // cut-off article ID for `getHeadlines` and `getCompactHeadlines; returns only higher article IDs when specified
+ 'order_by' => V::T_STRING, // sort order for `getHeadlines`
+ 'include_header' => V::T_BOOL | V::M_DROP, // whether to attach a header to the results of `getHeadlines`
+ 'search' => V::T_STRING, // search string for `getHeadlines`
+ 'field' => V::T_INT, // which state to change in `updateArticle`
+ 'mode' => V::T_MIXED, // whether to set, clear, or toggle the selected state in `updateArticle` (integer), or whether to ignore a certain recent timeframe in `catchupFeed` (string)
+ 'data' => V::T_STRING, // note text in `updateArticle` if setting a note
];
protected const VIEW_MODES = ["all_articles", "adaptive", "unread", "marked", "has_note", "published"];
// generic error construct
@@ -156,6 +156,26 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler {
}
}
+ protected function normalizeInput(array $data): array {
+ $out = [];
+ foreach (self::VALID_INPUT as $key => $type) {
+ if (isset($data[$key])) {
+ // TT-RSS accepts "t" and "f" as booleans
+ if ($type === V::T_BOOL | V::M_DROP) {
+ if ($data[$key] === "t") {
+ $data[$key] = true;
+ } elseif ($data[$key] === "f") {
+ $data[$key] = false;
+ }
+ }
+ $out[$key] = V::normalize($data[$key], $type, "unix");
+ } else {
+ $out[$key] = null;
+ }
+ }
+ return $out;
+ }
+
protected function resumeSession(string $id): bool {
// if HTTP authentication was successful and sessions are not enforced, proceed unconditionally
if (isset(Arsse::$user->id) && !Arsse::$conf->userSessionEnforced) {
@@ -589,7 +609,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler {
}
public function opRemoveCategory(array $data) {
- if (!ValueInfo::id($data['category_id'])) {
+ if (!V::id($data['category_id'])) {
// if the folder is invalid, throw an error
throw new Exception("INCORRECT_USAGE");
}
@@ -603,7 +623,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler {
}
public function opMoveCategory(array $data) {
- if (!ValueInfo::id($data['category_id']) || !ValueInfo::id($data['parent_id'], true)) {
+ if (!V::id($data['category_id']) || !V::id($data['parent_id'], true)) {
// if the folder or parent is invalid, throw an error
throw new Exception("INCORRECT_USAGE");
}
@@ -620,8 +640,8 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler {
}
public function opRenameCategory(array $data) {
- $info = ValueInfo::str($data['caption']);
- if (!ValueInfo::id($data['category_id']) || !($info & ValueInfo::VALID) || ($info & ValueInfo::EMPTY) || ($info & ValueInfo::WHITE)) {
+ $info = V::str($data['caption']);
+ if (!V::id($data['category_id']) || !($info & V::VALID) || ($info & V::EMPTY) || ($info & V::WHITE)) {
// if the folder or its new name are invalid, throw an error
throw new Exception("INCORRECT_USAGE");
}
@@ -646,7 +666,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler {
$offset = $data['offset'] ?? 0;
$nested = $data['include_nested'] ?? false;
// if a special category was selected, nesting does not apply
- if (!ValueInfo::id($cat)) {
+ if (!V::id($cat)) {
$nested = false;
// if the All, Special, or Labels category was selected, pagination also does not apply
if (in_array($cat, [self::CAT_ALL, self::CAT_SPECIAL, self::CAT_LABELS])) {
@@ -820,7 +840,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler {
}
public function opSubscribeToFeed(array $data): array {
- if (!$data['feed_url'] || !ValueInfo::id($data['category_id'], true)) {
+ if (!$data['feed_url'] || !V::id($data['category_id'], true)) {
// if the feed URL or the category ID is invalid, throw an error
throw new Exception("INCORRECT_USAGE");
}
@@ -887,7 +907,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler {
}
public function opMoveFeed(array $data) {
- if (!ValueInfo::id($data['feed_id']) || !isset($data['category_id']) || !ValueInfo::id($data['category_id'], true)) {
+ if (!V::id($data['feed_id']) || !isset($data['category_id']) || !V::id($data['category_id'], true)) {
// if the feed or folder is invalid, throw an error
throw new Exception("INCORRECT_USAGE");
}
@@ -904,8 +924,8 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler {
}
public function opRenameFeed(array $data) {
- $info = ValueInfo::str($data['caption']);
- if (!ValueInfo::id($data['feed_id']) || !($info & ValueInfo::VALID) || ($info & ValueInfo::EMPTY) || ($info & ValueInfo::WHITE)) {
+ $info = V::str($data['caption']);
+ if (!V::id($data['feed_id']) || !($info & V::VALID) || ($info & V::EMPTY) || ($info & V::WHITE)) {
// if the feed ID or name is invalid, throw an error
throw new Exception("INCORRECT_USAGE");
}
@@ -922,7 +942,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler {
}
public function opUpdateFeed(array $data): array {
- if (!isset($data['feed_id']) || !ValueInfo::id($data['feed_id'])) {
+ if (!isset($data['feed_id']) || !V::id($data['feed_id'])) {
// if the feed is invalid, throw an error
throw new Exception("INCORRECT_USAGE");
}
@@ -935,7 +955,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler {
}
protected function labelIn($id, bool $throw = true): int {
- if (!(ValueInfo::int($id) & ValueInfo::NEG) || $id > (-1 - self::LABEL_OFFSET)) {
+ if (!(V::int($id) & V::NEG) || $id > (-1 - self::LABEL_OFFSET)) {
if ($throw) {
throw new Exception("INCORRECT_USAGE");
} else {
@@ -951,7 +971,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler {
public function opGetLabels(array $data): array {
// this function doesn't complain about invalid article IDs
- $article = ValueInfo::id($data['article_id']) ? $data['article_id'] : 0;
+ $article = V::id($data['article_id']) ? $data['article_id'] : 0;
try {
$list = $article ? Arsse::$db->articleLabelsGet(Arsse::$user->id, $article) : [];
} catch (ExceptionInput $e) {
@@ -1112,8 +1132,8 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler {
public function opUpdateArticle(array $data): array {
// normalize input
- $articles = array_filter(ValueInfo::normalize(explode(",", (string) $data['article_ids']), ValueInfo::T_INT | ValueInfo::M_ARRAY), [ValueInfo::class, "id"]);
- $data['mode'] = ValueInfo::normalize($data['mode'], ValueInfo::T_INT);
+ $articles = array_filter(V::normalize(explode(",", (string) $data['article_ids']), V::T_INT | V::M_ARRAY), [V::class, "id"]);
+ $data['mode'] = V::normalize($data['mode'], V::T_INT);
if (!$articles) {
// if there are no valid articles this is an error
throw new Exception("INCORRECT_USAGE");
@@ -1185,7 +1205,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler {
public function opGetArticle(array $data): array {
// normalize input
- $articles = array_filter(ValueInfo::normalize(explode(",", (string) $data['article_id']), ValueInfo::T_INT | ValueInfo::M_ARRAY), [ValueInfo::class, "id"]);
+ $articles = array_filter(V::normalize(explode(",", (string) $data['article_id']), V::T_INT | V::M_ARRAY), [V::class, "id"]);
if (!$articles) {
// if there are no valid articles this is an error
throw new Exception("INCORRECT_USAGE");
diff --git a/tests/cases/REST/TinyTinyRSS/TestAPI.php b/tests/cases/REST/TinyTinyRSS/TestAPI.php
index 139f7dcf..cac71ddf 100644
--- a/tests/cases/REST/TinyTinyRSS/TestAPI.php
+++ b/tests/cases/REST/TinyTinyRSS/TestAPI.php
@@ -1333,7 +1333,7 @@ LONG_STRING;
[['feed_id' => 0, 'is_cat' => true], (clone $c)->folderShallow(0)],
[['feed_id' => 0, 'is_cat' => true, 'mode' => "bogus"], (clone $c)->folderShallow(0)],
[['feed_id' => -1], (clone $c)->starred(true)],
- [['feed_id' => -1, 'is_cat' => true], null],
+ [['feed_id' => -1, 'is_cat' => "t"], null],
[['feed_id' => -3], (clone $c)->modifiedSince(Date::sub("PT24H", self::NOW))],
[['feed_id' => -3, 'mode' => "1day"], (clone $c)->modifiedSince(Date::sub("PT24H", self::NOW))->notModifiedSince(Date::sub("PT24H", self::NOW))], // this is a nonsense query, but it's what TT-RSS appearsto do
[['feed_id' => -3, 'is_cat' => true], null],
@@ -1342,7 +1342,7 @@ LONG_STRING;
[['feed_id' => -2, 'is_cat' => true, 'mode' => "all"], (clone $c)->labelled(true)],
[['feed_id' => -4], $c],
[['feed_id' => -4, 'is_cat' => true], null],
- [['feed_id' => -6], null],
+ [['feed_id' => -6, 'is_cat' => "f"], null],
[['feed_id' => -2112], (clone $c)->label(1088)],
[['feed_id' => 42, 'is_cat' => true], (clone $c)->folder(42)],
[['feed_id' => 42, 'is_cat' => true, 'mode' => "1week"], (clone $c)->folder(42)->notModifiedSince(Date::sub("P1W", self::NOW))],
From b7c7915a653a43f18ffd634cf2bf498408359733 Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Tue, 9 Feb 2021 10:05:44 -0500
Subject: [PATCH 176/366] Enforce admin rquirements in NCNv1
---
CHANGELOG | 4 +++
.../010_Nextcloud_News.md | 1 -
lib/REST/NextcloudNews/V1_2.php | 12 ++++++++
tests/cases/REST/NextcloudNews/TestV1_2.php | 29 +++++++++++++++++++
4 files changed, 45 insertions(+), 1 deletion(-)
diff --git a/CHANGELOG b/CHANGELOG
index ba7040e8..0e7cdc67 100644
--- a/CHANGELOG
+++ b/CHANGELOG
@@ -13,6 +13,10 @@ Bug fixes:
compatibility with RFC 7617
- Accept "t" and "f" as booleans in Tiny Tiny RSS
+Changes:
+- Administrator account requirements for Nextcloud News functionality are
+ now enforced
+
Version 0.8.5 (2020-10-27)
==========================
diff --git a/docs/en/030_Supported_Protocols/010_Nextcloud_News.md b/docs/en/030_Supported_Protocols/010_Nextcloud_News.md
index a2c34d04..17f5d2df 100644
--- a/docs/en/030_Supported_Protocols/010_Nextcloud_News.md
+++ b/docs/en/030_Supported_Protocols/010_Nextcloud_News.md
@@ -24,7 +24,6 @@ It allows organizing newsfeeds into single-level folders, and supports a wide ra
- When marking articles as starred the feed ID is ignored, as they are not needed to establish uniqueness
- The feed updater ignores the `userId` parameter: feeds in The Arsse are deduplicated, and have no owner
- The `/feeds/all` route lists only feeds which should be checked for updates, and it also returns all `userId` attributes as empty strings: feeds in The Arsse are deduplicated, and have no owner
-- The API's "updater" routes do not require administrator priviledges as The Arsse has no concept of user classes
- The "updater" console commands mentioned in the protocol specification are not implemented, as The Arsse does not implement the required Nextcloud subsystems
- The `lastLoginTimestamp` attribute of the user metadata is always the current time: The Arsse's implementation of the protocol is fully stateless
- Syntactically invalid JSON input will yield a `400 Bad Request` response instead of falling back to GET parameters
diff --git a/lib/REST/NextcloudNews/V1_2.php b/lib/REST/NextcloudNews/V1_2.php
index 2b14cbd5..984491ab 100644
--- a/lib/REST/NextcloudNews/V1_2.php
+++ b/lib/REST/NextcloudNews/V1_2.php
@@ -360,6 +360,9 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler {
// return list of feeds which should be refreshed
protected function feedListStale(array $url, array $data): ResponseInterface {
+ if (!$this->isAdmin()) {
+ return new EmptyResponse(403);
+ }
// list stale feeds which should be checked for updates
$feeds = Arsse::$db->feedListStale();
$out = [];
@@ -372,6 +375,9 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler {
// refresh a feed
protected function feedUpdate(array $url, array $data): ResponseInterface {
+ if (!$this->isAdmin()) {
+ return new EmptyResponse(403);
+ }
try {
Arsse::$db->feedUpdate($data['feedId']);
} catch (ExceptionInput $e) {
@@ -667,11 +673,17 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler {
}
protected function cleanupBefore(array $url, array $data): ResponseInterface {
+ if (!$this->isAdmin()) {
+ return new EmptyResponse(403);
+ }
Service::cleanupPre();
return new EmptyResponse(204);
}
protected function cleanupAfter(array $url, array $data): ResponseInterface {
+ if (!$this->isAdmin()) {
+ return new EmptyResponse(403);
+ }
Service::cleanupPost();
return new EmptyResponse(204);
}
diff --git a/tests/cases/REST/NextcloudNews/TestV1_2.php b/tests/cases/REST/NextcloudNews/TestV1_2.php
index 7b4ce328..3d5a342a 100644
--- a/tests/cases/REST/NextcloudNews/TestV1_2.php
+++ b/tests/cases/REST/NextcloudNews/TestV1_2.php
@@ -317,6 +317,7 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest {
// create a mock user manager
Arsse::$user = \Phake::mock(User::class);
Arsse::$user->id = "john.doe@example.com";
+ \Phake::when(Arsse::$user)->propertiesGet->thenReturn(['admin' => true]);
// create a mock database interface
Arsse::$db = \Phake::mock(Database::class);
$this->transaction = \Phake::mock(Transaction::class);
@@ -629,6 +630,13 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest {
$this->assertMessage($exp, $this->req("GET", "/feeds/all"));
}
+ public function testListStaleFeedsWithoutAuthority(): void {
+ \Phake::when(Arsse::$user)->propertiesGet->thenReturn(['admin' => false]);
+ $exp = new EmptyResponse(403);
+ $this->assertMessage($exp, $this->req("GET", "/feeds/all"));
+ \Phake::verify(Arsse::$db, \Phake::times(0))->feedListStale;
+ }
+
public function testUpdateAFeed(): void {
$in = [
['feedId' => 42], // valid
@@ -650,6 +658,13 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest {
$this->assertMessage($exp, $this->req("GET", "/feeds/update", json_encode($in[4])));
}
+ public function testUpdateAFeedWithoutAuthority(): void {
+ \Phake::when(Arsse::$user)->propertiesGet->thenReturn(['admin' => false]);
+ $exp = new EmptyResponse(403);
+ $this->assertMessage($exp, $this->req("GET", "/feeds/update", ['feedId' => 42]));
+ \Phake::verify(Arsse::$db, \Phake::times(0))->feedUpdate;
+ }
+
/** @dataProvider provideArticleQueries */
public function testListArticles(string $url, array $in, Context $c, $out, ResponseInterface $exp): void {
if ($out instanceof \Exception) {
@@ -849,6 +864,13 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest {
\Phake::verify(Arsse::$db)->feedCleanup();
}
+ public function testCleanUpBeforeUpdateWithoutAuthority(): void {
+ \Phake::when(Arsse::$user)->propertiesGet->thenReturn(['admin' => false]);
+ $exp = new EmptyResponse(403);
+ $this->assertMessage($exp, $this->req("GET", "/cleanup/before-update"));
+ \Phake::verify(Arsse::$db, \Phake::times(0))->feedCleanup;
+ }
+
public function testCleanUpAfterUpdate(): void {
\Phake::when(Arsse::$db)->articleCleanup()->thenReturn(true);
$exp = new EmptyResponse(204);
@@ -856,6 +878,13 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest {
\Phake::verify(Arsse::$db)->articleCleanup();
}
+ public function testCleanUpAfterUpdateWithoutAuthority(): void {
+ \Phake::when(Arsse::$user)->propertiesGet->thenReturn(['admin' => false]);
+ $exp = new EmptyResponse(403);
+ $this->assertMessage($exp, $this->req("GET", "/cleanup/after-update"));
+ \Phake::verify(Arsse::$db, \Phake::times(0))->feedCleanup;
+ }
+
public function testQueryTheUserStatus(): void {
$act = $this->req("GET", "/user");
$exp = new Response([
From 68422390dae37a7b4968ff8c7771b849e5c4ed2f Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Wed, 10 Feb 2021 11:24:01 -0500
Subject: [PATCH 177/366] Implement CLI for user metadata
---
CHANGELOG | 1 +
lib/CLI.php | 112 ++++++++++++++++++++++++++++++++++--
lib/User.php | 4 +-
tests/cases/CLI/TestCLI.php | 61 ++++++++++++++++++++
tests/lib/AbstractTest.php | 1 +
5 files changed, 172 insertions(+), 7 deletions(-)
diff --git a/CHANGELOG b/CHANGELOG
index 0e7cdc67..781625de 100644
--- a/CHANGELOG
+++ b/CHANGELOG
@@ -5,6 +5,7 @@ New features:
- Support for the Miniflux protocol (see manual for details)
- Support for API level 15 of Tiny Tiny RSS
- Support for feed icons in Fever
+- Command-line functionality for managing user metadata
Bug fixes:
- Use icons specified in Atom feeds when available
diff --git a/lib/CLI.php b/lib/CLI.php
index bc96f468..d40f683e 100644
--- a/lib/CLI.php
+++ b/lib/CLI.php
@@ -17,8 +17,11 @@ Usage:
arsse.php feed refresh
arsse.php conf save-defaults []
arsse.php user [list]
- arsse.php user add []
+ arsse.php user add [] [--admin]
arsse.php user remove
+ arsse.php user show
+ arsse.php user set
+ arsse.php user unset
arsse.php user set-pass []
[--oldpass=] [--fever]
arsse.php user unset-pass
@@ -63,11 +66,13 @@ Commands:
Prints a list of all existing users, one per line.
- user add []
+ user add [] [--admin]
Adds the user specified by , with the provided password
. If no password is specified, a random password will be
- generated and printed to standard output.
+ generated and printed to standard output. The --admin option will make
+ the user an administrator, which allows them to manage users via the
+ Miniflux protocol, among other things.
user remove
@@ -76,6 +81,22 @@ Commands:
which the user was subscribed will be retained and refreshed until the
configured retention time elapses.
+ user show
+
+ Displays the metadata of a user in a basic tabular format. See below for
+ details on the various properties displayed.
+
+ user set
+
+ Sets a user's metadata proprty to the supplied value. See below for
+ details on the various properties available.
+
+ user unset
+
+ Sets a user's metadata proprty to its default value. See below for
+ details on the various properties available. What the default value
+ for a property evaluates to depends on which protocol is used.
+
user set-pass []
Changes 's password to . If no password is specified,
@@ -128,6 +149,65 @@ Commands:
The --flat option can be used to omit folders from the export. Some OPML
implementations may not support folders, or arbitrary nesting; this option
may be used when planning to import into such software.
+
+User metadata:
+
+ User metadata is primary used by the Miniflux protocol, and most
+ properties have identical or similar names to those used by Miniflux.
+ Properties may also affect other protocols, or conversely may have no
+ effect even when using the Miniflux protocol; this is noted below when
+ appropriate.
+
+ Booleans accept any of the values true/false, 1/0, yes/no, on/off.
+
+ The following metadata properties exist for each user:
+
+ num
+ Integer. The numeric identifier of the user. This is assigned at user
+ creation and is read-only.
+ admin
+ Boolean. Whether the user is an administrator. Administrators may
+ manage other users via the Miniflux protocol, and also may trigger
+ feed updates manually via the Nextcloud News protocol.
+ lang
+ String. The preferred language of the user, as a BCP 47 language tag
+ e.g. "en-ca". Note that since The Arsse currently only includes
+ English text it is not used by The Arsse itself, but clients may
+ use this metadata in protocols which expose it.
+ tz
+ String. The time zone of the user, as a tzdata identifier e.g.
+ "America/Los_Angeles".
+ root_folder_name
+ String. The name of the root folder, in protocols which allow it to
+ be renamed.
+ sort_asc
+ Boolean. Whether the user prefers ascending sort order for articles.
+ Descending order is usually the default, but explicitly setting this
+ property false will also make a preference for descending order
+ explicit.
+ theme
+ String. The user's preferred theme. This is not used by The Arsse
+ itself, but clients may use this metadata in protocols which expose
+ it.
+ page_size
+ Integer. The user's preferred pge size when listing articles. This is
+ not used by The Arsse itself, but clients may use this metadata in
+ protocols which expose it.
+ shortcuts
+ Boolean. Whether to enable keyboard shortcuts. This is not used by
+ The Arsse itself, but clients may use this metadata in protocols which
+ expose it.
+ gestures
+ Boolean. Whether to enable touch gestures. This is not used by
+ The Arsse itself, but clients may use this metadata in protocols which
+ expose it.
+ reading_time
+ Boolean. Whether to calculate and display the estimated reading time
+ for articles. Currently The Arsse does not calculate reading time, so
+ changing this will likely have no effect.
+ stylesheet
+ String. A user CSS stylesheet. This is not used by The Arsse itself,
+ but clients may use this metadata in protocols which expose it.
USAGE_TEXT;
protected function usage($prog): string {
@@ -215,10 +295,14 @@ USAGE_TEXT;
}
protected function userManage($args): int {
- $cmd = $this->command(["add", "remove", "set-pass", "unset-pass", "list", "auth"], $args);
+ $cmd = $this->command(["add", "remove", "show", "set", "unset", "set-pass", "unset-pass", "list", "auth"], $args);
switch ($cmd) {
case "add":
- return $this->userAddOrSetPassword("add", $args[""], $args[""]);
+ $out = $this->userAddOrSetPassword("add", $args[""], $args[""]);
+ if ($args['--admin']) {
+ Arsse::$user->propertiesSet($args[""], ['admin' => true]);
+ }
+ return $out;
case "set-pass":
if ($args['--fever']) {
$passwd = Arsse::$obj->get(Fever::class)->register($args[""], $args[""]);
@@ -239,6 +323,12 @@ USAGE_TEXT;
return 0;
case "remove":
return (int) !Arsse::$user->remove($args[""]);
+ case "show":
+ return $this->userShowProperties($args[""]);
+ case "set":
+ return (int) !Arsse::$user->propertiesSet($args[""], [$args[""] => $args[""]]);
+ case "unset":
+ return (int) !Arsse::$user->propertiesSet($args[""], [$args[""] => null]);
case "auth":
return $this->userAuthenticate($args[""], $args[""], $args["--fever"]);
case "list":
@@ -275,4 +365,16 @@ USAGE_TEXT;
return 1;
}
}
+
+ protected function userShowProperties(string $user): int {
+ $data = Arsse::$user->propertiesGet($user);
+ $len = array_reduce(array_keys($data), function($carry, $item) {
+ return max($carry, strlen($item));
+ }, 0) + 2;
+ foreach ($data as $k => $v) {
+ echo str_pad($k, $len, " ");
+ echo var_export($v, true).\PHP_EOL;
+ }
+ return 0;
+ }
}
diff --git a/lib/User.php b/lib/User.php
index 04748962..4bf8e36b 100644
--- a/lib/User.php
+++ b/lib/User.php
@@ -18,14 +18,14 @@ class User {
'admin' => V::T_BOOL,
'lang' => V::T_STRING,
'tz' => V::T_STRING,
+ 'root_folder_name' => V::T_STRING,
'sort_asc' => V::T_BOOL,
'theme' => V::T_STRING,
'page_size' => V::T_INT, // greater than zero
'shortcuts' => V::T_BOOL,
'gestures' => V::T_BOOL,
- 'stylesheet' => V::T_STRING,
'reading_time' => V::T_BOOL,
- 'root_folder_name' => V::T_STRING,
+ 'stylesheet' => V::T_STRING,
];
public const PROPERTIES_LARGE = ["stylesheet"];
diff --git a/tests/cases/CLI/TestCLI.php b/tests/cases/CLI/TestCLI.php
index 30237757..dfb4d12e 100644
--- a/tests/cases/CLI/TestCLI.php
+++ b/tests/cases/CLI/TestCLI.php
@@ -156,6 +156,15 @@ class TestCLI extends \JKingWeb\Arsse\Test\AbstractTest {
];
}
+ public function testAddAUserAsAdministrator(): void {
+ Arsse::$user = $this->createMock(User::class);
+ Arsse::$user->method("add")->willReturn("random password");
+ Arsse::$user->method("propertiesSet")->willReturn([]);
+ Arsse::$user->expects($this->exactly(1))->method("add")->with("jane.doe@example.com", null);
+ Arsse::$user->expects($this->exactly(1))->method("propertiesSet")->with("jane.doe@example.com", ['admin' => true]);
+ $this->assertConsole($this->cli, "arsse.php user add jane.doe@example.com --admin", 0, "random password");
+ }
+
/** @dataProvider provideUserAuthentication */
public function testAuthenticateAUser(string $cmd, int $exitStatus, string $output): void {
// FIXME: Phake is somehow unable to mock the User class correctly, so we use PHPUnit's mocks instead
@@ -357,4 +366,56 @@ class TestCLI extends \JKingWeb\Arsse\Test\AbstractTest {
["arsse.php import jane.doe@example.com bad.opml --replace --flat", 10603, "bad.opml", "jane.doe@example.com", true, true],
];
}
+
+ public function testShowMetadataOfAUser(): void {
+ $data = [
+ 'num' => 42,
+ 'admin' => false,
+ 'lang' => "en-ca",
+ 'tz' => "America/Toronto",
+ 'root_folder_name' => null,
+ 'sort_asc' => true,
+ 'theme' => null,
+ 'page_size' => 50,
+ 'shortcuts' => true,
+ 'gestures' => null,
+ 'reading_time' => false,
+ 'stylesheet' => "body {color:gray}",
+ ];
+ $exp = implode(\PHP_EOL, [
+ "num 42",
+ "admin false",
+ "lang 'en-ca'",
+ "tz 'America/Toronto'",
+ "root_folder_name NULL",
+ "sort_asc true",
+ "theme NULL",
+ "page_size 50",
+ "shortcuts true",
+ "gestures NULL",
+ "reading_time false",
+ "stylesheet 'body {color:gray}'",
+ ]);
+ Arsse::$user = $this->createMock(User::class);
+ Arsse::$user->method("propertiesGet")->willReturn($data);
+ Arsse::$user->expects($this->once())->method("propertiesGet")->with("john.doe@example.com", true);
+ $this->assertConsole($this->cli, "arsse.php user show john.doe@example.com", 0, $exp);
+ }
+
+ /** @dataProvider provideMetadataChanges */
+ public function testSetMetadataOfAUser(string $cmd, string $user, array $in, array $out, int $exp): void {
+ Arsse::$user = $this->createMock(User::class);
+ Arsse::$user->method("propertiesSet")->willReturn($out);
+ Arsse::$user->expects($this->once())->method("propertiesSet")->with($user, $in);
+ $this->assertConsole($this->cli, $cmd, $exp, "");
+ }
+
+ public function provideMetadataChanges(): iterable {
+ return [
+ ["arsse.php user set john admin true", "john", ['admin' => "true"], ['admin' => "true"], 0],
+ ["arsse.php user set john bogus 1", "john", ['bogus' => "1"], [], 1],
+ ["arsse.php user unset john admin", "john", ['admin' => null], ['admin' => null], 0],
+ ["arsse.php user unset john bogus", "john", ['bogus' => null], [], 1],
+ ];
+ }
}
diff --git a/tests/lib/AbstractTest.php b/tests/lib/AbstractTest.php
index e096ca1e..f807e6bb 100644
--- a/tests/lib/AbstractTest.php
+++ b/tests/lib/AbstractTest.php
@@ -88,6 +88,7 @@ abstract class AbstractTest extends \PHPUnit\Framework\TestCase {
}, $params, array_keys($params)));
}
$url = URL::queryAppend($url, (string) $params);
+ $params = null;
}
$q = parse_url($url, \PHP_URL_QUERY);
if (strlen($q ?? "")) {
From 97d1de46f8c68d79c3f254b88bdce12333b15d9b Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Wed, 10 Feb 2021 11:24:16 -0500
Subject: [PATCH 178/366] Fill in upgrade notes
---
UPGRADING | 17 +++++++++++++++++
1 file changed, 17 insertions(+)
diff --git a/UPGRADING b/UPGRADING
index f18bf760..3c05ec4d 100644
--- a/UPGRADING
+++ b/UPGRADING
@@ -11,6 +11,23 @@ usually prudent:
`composer install -o --no-dev`
+Upgrading from 0.8.5 to 0.9.0
+=============================
+
+- The database schema has changed from rev6 to rev7; if upgrading the database
+ manually, apply the 6.sql file
+- Web server configuration has changed to accommodate Miniflux; the following
+ URL paths are affected:
+ - /v1/
+ - /version
+ - /healthcheck
+- Icons for existing feeds in Miniflux and Fever will only appear once the
+ feeds in question have been fetched after upgrade. This may take up to
+ twenty-four hours to occur
+- An administrator account is now required to refresh feeds via the
+ Nextcloud News protocol
+
+
Upgrading from 0.8.4 to 0.8.5
=============================
From e8ed716ae6034aa3174338ea55b21b00d3bb1d81 Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Wed, 10 Feb 2021 12:11:28 -0500
Subject: [PATCH 179/366] Fix errors in CLI documentation
---
lib/CLI.php | 16 ++++++++--------
1 file changed, 8 insertions(+), 8 deletions(-)
diff --git a/lib/CLI.php b/lib/CLI.php
index d40f683e..8e8c1e67 100644
--- a/lib/CLI.php
+++ b/lib/CLI.php
@@ -152,7 +152,7 @@ Commands:
User metadata:
- User metadata is primary used by the Miniflux protocol, and most
+ User metadata are primarily used by the Miniflux protocol, and most
properties have identical or similar names to those used by Miniflux.
Properties may also affect other protocols, or conversely may have no
effect even when using the Miniflux protocol; this is noted below when
@@ -173,7 +173,7 @@ User metadata:
String. The preferred language of the user, as a BCP 47 language tag
e.g. "en-ca". Note that since The Arsse currently only includes
English text it is not used by The Arsse itself, but clients may
- use this metadata in protocols which expose it.
+ use this metadatum in protocols which expose it.
tz
String. The time zone of the user, as a tzdata identifier e.g.
"America/Los_Angeles".
@@ -187,19 +187,19 @@ User metadata:
explicit.
theme
String. The user's preferred theme. This is not used by The Arsse
- itself, but clients may use this metadata in protocols which expose
+ itself, but clients may use this metadatum in protocols which expose
it.
page_size
- Integer. The user's preferred pge size when listing articles. This is
- not used by The Arsse itself, but clients may use this metadata in
+ Integer. The user's preferred page size when listing articles. This is
+ not used by The Arsse itself, but clients may use this metadatum in
protocols which expose it.
shortcuts
Boolean. Whether to enable keyboard shortcuts. This is not used by
- The Arsse itself, but clients may use this metadata in protocols which
+ The Arsse itself, but clients may use this metadatum in protocols which
expose it.
gestures
Boolean. Whether to enable touch gestures. This is not used by
- The Arsse itself, but clients may use this metadata in protocols which
+ The Arsse itself, but clients may use this metadatum in protocols which
expose it.
reading_time
Boolean. Whether to calculate and display the estimated reading time
@@ -207,7 +207,7 @@ User metadata:
changing this will likely have no effect.
stylesheet
String. A user CSS stylesheet. This is not used by The Arsse itself,
- but clients may use this metadata in protocols which expose it.
+ but clients may use this metadatum in protocols which expose it.
USAGE_TEXT;
protected function usage($prog): string {
From 3795b1ccd860a832f658a6ef4c90e3295ef36e42 Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Wed, 10 Feb 2021 12:46:28 -0500
Subject: [PATCH 180/366] Simplify CLI command processing
---
lib/CLI.php | 118 +++++++++++++++++++++++-----------------------------
1 file changed, 53 insertions(+), 65 deletions(-)
diff --git a/lib/CLI.php b/lib/CLI.php
index 8e8c1e67..53c50755 100644
--- a/lib/CLI.php
+++ b/lib/CLI.php
@@ -88,12 +88,12 @@ Commands:
user set
- Sets a user's metadata proprty to the supplied value. See below for
+ Sets a user's metadata property to the supplied value. See below for
details on the various properties available.
user unset
- Sets a user's metadata proprty to its default value. See below for
+ Sets a user's metadata property to its default value. See below for
details on the various properties available. What the default value
for a property evaluates to depends on which protocol is used.
@@ -215,16 +215,14 @@ USAGE_TEXT;
return str_replace("arsse.php", $prog, self::USAGE);
}
- protected function command(array $options, $args): string {
- foreach ($options as $cmd) {
- foreach (explode(" ", $cmd) as $part) {
- if (!$args[$part]) {
- continue 2;
- }
+ protected function command($args): string {
+ $out = [];
+ foreach ($args as $k => $v) {
+ if (preg_match("/^[a-z]/", $k) && $v === true) {
+ $out[] = $k;
}
- return $cmd;
}
- return "";
+ return implode(" ", $out);
}
/** @codeCoverageIgnore */
@@ -248,18 +246,18 @@ USAGE_TEXT;
'help' => false,
]);
try {
- $cmd = $this->command(["-h", "--help", "--version", "daemon", "feed refresh", "feed refresh-all", "conf save-defaults", "user", "export", "import"], $args);
- if ($cmd && !in_array($cmd, ["-h", "--help", "--version", "conf save-defaults"])) {
+ $cmd = $this->command($args);
+ if ($cmd && !in_array($cmd, ["", "conf save-defaults"])) {
// only certain commands don't require configuration to be loaded
$this->loadConf();
}
switch ($cmd) {
- case "-h":
- case "--help":
- echo $this->usage($argv0).\PHP_EOL;
- return 0;
- case "--version":
- echo Arsse::VERSION.\PHP_EOL;
+ case "":
+ if ($args['--version']) {
+ echo Arsse::VERSION.\PHP_EOL;
+ } elseif ($args['--help'] || $args['-h']) {
+ echo $this->usage($argv0).\PHP_EOL;
+ }
return 0;
case "daemon":
Arsse::$obj->get(Service::class)->watch(true);
@@ -272,8 +270,6 @@ USAGE_TEXT;
case "conf save-defaults":
$file = $this->resolveFile($args[''], "w");
return (int) !Arsse::$obj->get(Conf::class)->exportFile($file, true);
- case "user":
- return $this->userManage($args);
case "export":
$u = $args[''];
$file = $this->resolveFile($args[''], "w");
@@ -282,6 +278,43 @@ USAGE_TEXT;
$u = $args[''];
$file = $this->resolveFile($args[''], "r");
return (int) !Arsse::$obj->get(OPML::class)->importFile($file, $u, ($args['--flat'] || $args['-f']), ($args['--replace'] || $args['-r']));
+ case "user add":
+ $out = $this->userAddOrSetPassword("add", $args[""], $args[""]);
+ if ($args['--admin']) {
+ Arsse::$user->propertiesSet($args[""], ['admin' => true]);
+ }
+ return $out;
+ case "user set-pass":
+ if ($args['--fever']) {
+ $passwd = Arsse::$obj->get(Fever::class)->register($args[""], $args[""]);
+ if (is_null($args[""])) {
+ echo $passwd.\PHP_EOL;
+ }
+ return 0;
+ } else {
+ return $this->userAddOrSetPassword("passwordSet", $args[""], $args[""], $args["--oldpass"]);
+ }
+ // no break
+ case "user unset-pass":
+ if ($args['--fever']) {
+ Arsse::$obj->get(Fever::class)->unregister($args[""]);
+ } else {
+ Arsse::$user->passwordUnset($args[""], $args["--oldpass"]);
+ }
+ return 0;
+ case "user remove":
+ return (int) !Arsse::$user->remove($args[""]);
+ case "user show":
+ return $this->userShowProperties($args[""]);
+ case "user set":
+ return (int) !Arsse::$user->propertiesSet($args[""], [$args[""] => $args[""]]);
+ case "user unset":
+ return (int) !Arsse::$user->propertiesSet($args[""], [$args[""] => null]);
+ case "user auth":
+ return $this->userAuthenticate($args[""], $args[""], $args["--fever"]);
+ case "user list":
+ case "user":
+ return $this->userList();
}
} catch (AbstractException $e) {
$this->logError($e->getMessage());
@@ -294,51 +327,6 @@ USAGE_TEXT;
fwrite(STDERR, $msg.\PHP_EOL);
}
- protected function userManage($args): int {
- $cmd = $this->command(["add", "remove", "show", "set", "unset", "set-pass", "unset-pass", "list", "auth"], $args);
- switch ($cmd) {
- case "add":
- $out = $this->userAddOrSetPassword("add", $args[""], $args[""]);
- if ($args['--admin']) {
- Arsse::$user->propertiesSet($args[""], ['admin' => true]);
- }
- return $out;
- case "set-pass":
- if ($args['--fever']) {
- $passwd = Arsse::$obj->get(Fever::class)->register($args[""], $args[""]);
- if (is_null($args[""])) {
- echo $passwd.\PHP_EOL;
- }
- return 0;
- } else {
- return $this->userAddOrSetPassword("passwordSet", $args[""], $args[""], $args["--oldpass"]);
- }
- // no break
- case "unset-pass":
- if ($args['--fever']) {
- Arsse::$obj->get(Fever::class)->unregister($args[""]);
- } else {
- Arsse::$user->passwordUnset($args[""], $args["--oldpass"]);
- }
- return 0;
- case "remove":
- return (int) !Arsse::$user->remove($args[""]);
- case "show":
- return $this->userShowProperties($args[""]);
- case "set":
- return (int) !Arsse::$user->propertiesSet($args[""], [$args[""] => $args[""]]);
- case "unset":
- return (int) !Arsse::$user->propertiesSet($args[""], [$args[""] => null]);
- case "auth":
- return $this->userAuthenticate($args[""], $args[""], $args["--fever"]);
- case "list":
- case "":
- return $this->userList();
- default:
- throw new Exception("constantUnknown", $cmd); // @codeCoverageIgnore
- }
- }
-
protected function userAddOrSetPassword(string $method, string $user, string $password = null, string $oldpass = null): int {
$passwd = Arsse::$user->$method(...array_slice(func_get_args(), 1));
if (is_null($password)) {
From fa6d641634324d59f4b689df83f395ee08fb16c8 Mon Sep 17 00:00:00 2001
From: "J. King"
Date: Wed, 10 Feb 2021 21:40:51 -0500
Subject: [PATCH 181/366] Implement CLI for tokens
---
CHANGELOG | 1 +
lib/CLI.php | 44 +++++++++++++
lib/REST/Miniflux/Token.php | 31 +++++++++
lib/REST/Miniflux/V1.php | 18 ------
tests/cases/CLI/TestCLI.php | 51 +++++++++++++++
tests/cases/REST/Miniflux/PDO/TestToken.php | 13 ++++
tests/cases/REST/Miniflux/TestToken.php | 70 +++++++++++++++++++++
tests/cases/REST/Miniflux/TestV1.php | 30 ---------
tests/phpunit.dist.xml | 2 +
9 files changed, 212 insertions(+), 48 deletions(-)
create mode 100644 lib/REST/Miniflux/Token.php
create mode 100644 tests/cases/REST/Miniflux/PDO/TestToken.php
create mode 100644 tests/cases/REST/Miniflux/TestToken.php
diff --git a/CHANGELOG b/CHANGELOG
index 781625de..683f89d0 100644
--- a/CHANGELOG
+++ b/CHANGELOG
@@ -6,6 +6,7 @@ New features:
- Support for API level 15 of Tiny Tiny RSS
- Support for feed icons in Fever
- Command-line functionality for managing user metadata
+- Command-line functionality for managing Miniflux login tokens
Bug fixes:
- Use icons specified in Atom feeds when available
diff --git a/lib/CLI.php b/lib/CLI.php
index 53c50755..f892bddd 100644
--- a/lib/CLI.php
+++ b/lib/CLI.php
@@ -8,6 +8,7 @@ namespace JKingWeb\Arsse;
use JKingWeb\Arsse\REST\Fever\User as Fever;
use JKingWeb\Arsse\ImportExport\OPML;
+use JKingWeb\Arsse\REST\Miniflux\Token as Miniflux;
class CLI {
public const USAGE = <<
[--oldpass=] [--fever]
arsse.php user auth [--fever]
+ arsse.php token list
+ arsse.php token create []
+ arsse.php token revoke []
arsse.php import []
[-f | --flat] [-r | --replace]
arsse.php export []
@@ -125,6 +129,24 @@ Commands:
The --fever option may be used to test the user's Fever protocol password,
if any.
+ token list
+
+ Lists available tokens for in a simple tabular format. These
+ tokens act as an alternative means of authentication for the Miniflux
+ protocol and may be required by some clients. They do not expire.
+
+ token create []
+
+ Creates a new login token for