From b1282b6f6a7ca941016317707abbfbe057960f8f Mon Sep 17 00:00:00 2001 From: "J. King" Date: Fri, 25 Jan 2019 22:07:37 -0500 Subject: [PATCH 001/142] Upgrade to PHP 7.1 and PHPUnit 7. --- .gitignore | 1 + lib/Conf.php | 2 +- vendor-bin/csfixer/composer.lock | 184 ++++++++------- vendor-bin/phpunit/composer.json | 2 +- vendor-bin/phpunit/composer.lock | 380 ++++++++++++++++--------------- vendor-bin/robo/composer.lock | 314 ++++++++++++++++--------- 6 files changed, 503 insertions(+), 380 deletions(-) diff --git a/.gitignore b/.gitignore index 31a77e36..3e5e003e 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ /vendor/ /vendor-bin/*/vendor /documentation/ +/manual/ /tests/coverage/ /arsse.db* /config.php diff --git a/lib/Conf.php b/lib/Conf.php index bba5821d..15a8345d 100644 --- a/lib/Conf.php +++ b/lib/Conf.php @@ -286,7 +286,7 @@ class Conf { } switch (self::EXPECTED_TYPES[$key] ?? gettype($this->$key)) { case "integer": - return Value::normalize($value, Value::T_INT | $mode); + return Value::normalize($value, Value::T_INT | $mode); // @codeCoverageIgnore case "double": return Value::normalize($value, Value::T_FLOAT | $mode); case "string": diff --git a/vendor-bin/csfixer/composer.lock b/vendor-bin/csfixer/composer.lock index fe1d67e7..f522cb4e 100644 --- a/vendor-bin/csfixer/composer.lock +++ b/vendor-bin/csfixer/composer.lock @@ -114,30 +114,30 @@ }, { "name": "doctrine/annotations", - "version": "v1.4.0", + "version": "v1.6.0", "source": { "type": "git", "url": "https://github.com/doctrine/annotations.git", - "reference": "54cacc9b81758b14e3ce750f205a393d52339e97" + "reference": "c7f2050c68a9ab0bdb0f98567ec08d80ea7d24d5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/annotations/zipball/54cacc9b81758b14e3ce750f205a393d52339e97", - "reference": "54cacc9b81758b14e3ce750f205a393d52339e97", + "url": "https://api.github.com/repos/doctrine/annotations/zipball/c7f2050c68a9ab0bdb0f98567ec08d80ea7d24d5", + "reference": "c7f2050c68a9ab0bdb0f98567ec08d80ea7d24d5", "shasum": "" }, "require": { "doctrine/lexer": "1.*", - "php": "^5.6 || ^7.0" + "php": "^7.1" }, "require-dev": { "doctrine/cache": "1.*", - "phpunit/phpunit": "^5.7" + "phpunit/phpunit": "^6.4" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.4.x-dev" + "dev-master": "1.6.x-dev" } }, "autoload": { @@ -178,7 +178,7 @@ "docblock", "parser" ], - "time": "2017-02-24T16:22:25+00:00" + "time": "2017-12-06T07:11:42+00:00" }, { "name": "doctrine/lexer", @@ -475,21 +475,21 @@ }, { "name": "symfony/console", - "version": "v3.4.21", + "version": "v4.2.2", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "a700b874d3692bc8342199adfb6d3b99f62cc61a" + "reference": "b0a03c1bb0fcbe288629956cf2f1dd3f1dc97522" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/a700b874d3692bc8342199adfb6d3b99f62cc61a", - "reference": "a700b874d3692bc8342199adfb6d3b99f62cc61a", + "url": "https://api.github.com/repos/symfony/console/zipball/b0a03c1bb0fcbe288629956cf2f1dd3f1dc97522", + "reference": "b0a03c1bb0fcbe288629956cf2f1dd3f1dc97522", "shasum": "" }, "require": { - "php": "^5.5.9|>=7.0.8", - "symfony/debug": "~2.8|~3.0|~4.0", + "php": "^7.1.3", + "symfony/contracts": "^1.0", "symfony/polyfill-mbstring": "~1.0" }, "conflict": { @@ -498,11 +498,11 @@ }, "require-dev": { "psr/log": "~1.0", - "symfony/config": "~3.3|~4.0", + "symfony/config": "~3.4|~4.0", "symfony/dependency-injection": "~3.4|~4.0", - "symfony/event-dispatcher": "~2.8|~3.0|~4.0", + "symfony/event-dispatcher": "~3.4|~4.0", "symfony/lock": "~3.4|~4.0", - "symfony/process": "~3.3|~4.0" + "symfony/process": "~3.4|~4.0" }, "suggest": { "psr/log-implementation": "For using the console logger", @@ -513,7 +513,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "3.4-dev" + "dev-master": "4.2-dev" } }, "autoload": { @@ -540,44 +540,48 @@ ], "description": "Symfony Console Component", "homepage": "https://symfony.com", - "time": "2019-01-04T04:42:43+00:00" + "time": "2019-01-04T15:13:53+00:00" }, { - "name": "symfony/debug", - "version": "v3.4.21", + "name": "symfony/contracts", + "version": "v1.0.2", "source": { "type": "git", - "url": "https://github.com/symfony/debug.git", - "reference": "26d7f23b9bd0b93bee5583e4d6ca5cb1ab31b186" + "url": "https://github.com/symfony/contracts.git", + "reference": "1aa7ab2429c3d594dd70689604b5cf7421254cdf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/debug/zipball/26d7f23b9bd0b93bee5583e4d6ca5cb1ab31b186", - "reference": "26d7f23b9bd0b93bee5583e4d6ca5cb1ab31b186", + "url": "https://api.github.com/repos/symfony/contracts/zipball/1aa7ab2429c3d594dd70689604b5cf7421254cdf", + "reference": "1aa7ab2429c3d594dd70689604b5cf7421254cdf", "shasum": "" }, "require": { - "php": "^5.5.9|>=7.0.8", - "psr/log": "~1.0" - }, - "conflict": { - "symfony/http-kernel": ">=2.3,<2.3.24|~2.4.0|>=2.5,<2.5.9|>=2.6,<2.6.2" + "php": "^7.1.3" }, "require-dev": { - "symfony/http-kernel": "~2.8|~3.0|~4.0" + "psr/cache": "^1.0", + "psr/container": "^1.0" + }, + "suggest": { + "psr/cache": "When using the Cache contracts", + "psr/container": "When using the Service contracts", + "symfony/cache-contracts-implementation": "", + "symfony/service-contracts-implementation": "", + "symfony/translation-contracts-implementation": "" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "3.4-dev" + "dev-master": "1.0-dev" } }, "autoload": { "psr-4": { - "Symfony\\Component\\Debug\\": "" + "Symfony\\Contracts\\": "" }, "exclude-from-classmap": [ - "/Tests/" + "**/Tests/" ] }, "notification-url": "https://packagist.org/downloads/", @@ -586,44 +590,53 @@ ], "authors": [ { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" + "name": "Nicolas Grekas", + "email": "p@tchwork.com" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Symfony Debug Component", + "description": "A set of abstractions extracted out of the Symfony components", "homepage": "https://symfony.com", - "time": "2019-01-01T13:45:19+00:00" + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "time": "2018-12-05T08:06:11+00:00" }, { "name": "symfony/event-dispatcher", - "version": "v3.4.21", + "version": "v4.2.2", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher.git", - "reference": "d1cdd46c53c264a2bd42505bd0e8ce21423bd0e2" + "reference": "887de6d34c86cf0cb6cbf910afb170cdb743cb5e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/d1cdd46c53c264a2bd42505bd0e8ce21423bd0e2", - "reference": "d1cdd46c53c264a2bd42505bd0e8ce21423bd0e2", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/887de6d34c86cf0cb6cbf910afb170cdb743cb5e", + "reference": "887de6d34c86cf0cb6cbf910afb170cdb743cb5e", "shasum": "" }, "require": { - "php": "^5.5.9|>=7.0.8" + "php": "^7.1.3", + "symfony/contracts": "^1.0" }, "conflict": { - "symfony/dependency-injection": "<3.3" + "symfony/dependency-injection": "<3.4" }, "require-dev": { "psr/log": "~1.0", - "symfony/config": "~2.8|~3.0|~4.0", - "symfony/dependency-injection": "~3.3|~4.0", - "symfony/expression-language": "~2.8|~3.0|~4.0", - "symfony/stopwatch": "~2.8|~3.0|~4.0" + "symfony/config": "~3.4|~4.0", + "symfony/dependency-injection": "~3.4|~4.0", + "symfony/expression-language": "~3.4|~4.0", + "symfony/stopwatch": "~3.4|~4.0" }, "suggest": { "symfony/dependency-injection": "", @@ -632,7 +645,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "3.4-dev" + "dev-master": "4.2-dev" } }, "autoload": { @@ -659,30 +672,30 @@ ], "description": "Symfony EventDispatcher Component", "homepage": "https://symfony.com", - "time": "2019-01-01T18:08:36+00:00" + "time": "2019-01-05T16:37:49+00:00" }, { "name": "symfony/filesystem", - "version": "v3.4.21", + "version": "v4.2.2", "source": { "type": "git", "url": "https://github.com/symfony/filesystem.git", - "reference": "c24ce3d18ccc9bb9d7e1d6ce9330fcc6061cafde" + "reference": "c2ffd9a93f2d6c5be2f68a0aa7953cc229f871f8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/c24ce3d18ccc9bb9d7e1d6ce9330fcc6061cafde", - "reference": "c24ce3d18ccc9bb9d7e1d6ce9330fcc6061cafde", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/c2ffd9a93f2d6c5be2f68a0aa7953cc229f871f8", + "reference": "c2ffd9a93f2d6c5be2f68a0aa7953cc229f871f8", "shasum": "" }, "require": { - "php": "^5.5.9|>=7.0.8", + "php": "^7.1.3", "symfony/polyfill-ctype": "~1.8" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "3.4-dev" + "dev-master": "4.2-dev" } }, "autoload": { @@ -709,29 +722,29 @@ ], "description": "Symfony Filesystem Component", "homepage": "https://symfony.com", - "time": "2019-01-01T13:45:19+00:00" + "time": "2019-01-03T09:07:35+00:00" }, { "name": "symfony/finder", - "version": "v3.4.21", + "version": "v4.2.2", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "3f2a2ab6315dd7682d4c16dcae1e7b95c8b8555e" + "reference": "9094d69e8c6ee3fe186a0ec5a4f1401e506071ce" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/3f2a2ab6315dd7682d4c16dcae1e7b95c8b8555e", - "reference": "3f2a2ab6315dd7682d4c16dcae1e7b95c8b8555e", + "url": "https://api.github.com/repos/symfony/finder/zipball/9094d69e8c6ee3fe186a0ec5a4f1401e506071ce", + "reference": "9094d69e8c6ee3fe186a0ec5a4f1401e506071ce", "shasum": "" }, "require": { - "php": "^5.5.9|>=7.0.8" + "php": "^7.1.3" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "3.4-dev" + "dev-master": "4.2-dev" } }, "autoload": { @@ -758,29 +771,29 @@ ], "description": "Symfony Finder Component", "homepage": "https://symfony.com", - "time": "2019-01-01T13:45:19+00:00" + "time": "2019-01-03T09:07:35+00:00" }, { "name": "symfony/options-resolver", - "version": "v3.4.21", + "version": "v4.2.2", "source": { "type": "git", "url": "https://github.com/symfony/options-resolver.git", - "reference": "8a10e36ffd04c0c551051594952304d34ecece71" + "reference": "fbcb106aeee72f3450298bf73324d2cc00d083d1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/options-resolver/zipball/8a10e36ffd04c0c551051594952304d34ecece71", - "reference": "8a10e36ffd04c0c551051594952304d34ecece71", + "url": "https://api.github.com/repos/symfony/options-resolver/zipball/fbcb106aeee72f3450298bf73324d2cc00d083d1", + "reference": "fbcb106aeee72f3450298bf73324d2cc00d083d1", "shasum": "" }, "require": { - "php": "^5.5.9|>=7.0.8" + "php": "^7.1.3" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "3.4-dev" + "dev-master": "4.2-dev" } }, "autoload": { @@ -812,7 +825,7 @@ "configuration", "options" ], - "time": "2019-01-01T13:45:19+00:00" + "time": "2019-01-03T09:07:35+00:00" }, { "name": "symfony/polyfill-ctype", @@ -1047,25 +1060,25 @@ }, { "name": "symfony/process", - "version": "v3.4.21", + "version": "v4.2.2", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "0d41dd7d95ed179aed6a13393b0f4f97bfa2d25c" + "reference": "ea043ab5d8ed13b467a9087d81cb876aee7f689a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/0d41dd7d95ed179aed6a13393b0f4f97bfa2d25c", - "reference": "0d41dd7d95ed179aed6a13393b0f4f97bfa2d25c", + "url": "https://api.github.com/repos/symfony/process/zipball/ea043ab5d8ed13b467a9087d81cb876aee7f689a", + "reference": "ea043ab5d8ed13b467a9087d81cb876aee7f689a", "shasum": "" }, "require": { - "php": "^5.5.9|>=7.0.8" + "php": "^7.1.3" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "3.4-dev" + "dev-master": "4.2-dev" } }, "autoload": { @@ -1092,29 +1105,30 @@ ], "description": "Symfony Process Component", "homepage": "https://symfony.com", - "time": "2019-01-02T21:24:08+00:00" + "time": "2019-01-03T14:48:52+00:00" }, { "name": "symfony/stopwatch", - "version": "v3.4.21", + "version": "v4.2.2", "source": { "type": "git", "url": "https://github.com/symfony/stopwatch.git", - "reference": "af55d31cb58c5452d2c160655fa1968b872a8084" + "reference": "af62b35760fc92c8dbdce659b4eebdfe0e6a0472" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/stopwatch/zipball/af55d31cb58c5452d2c160655fa1968b872a8084", - "reference": "af55d31cb58c5452d2c160655fa1968b872a8084", + "url": "https://api.github.com/repos/symfony/stopwatch/zipball/af62b35760fc92c8dbdce659b4eebdfe0e6a0472", + "reference": "af62b35760fc92c8dbdce659b4eebdfe0e6a0472", "shasum": "" }, "require": { - "php": "^5.5.9|>=7.0.8" + "php": "^7.1.3", + "symfony/contracts": "^1.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "3.4-dev" + "dev-master": "4.2-dev" } }, "autoload": { @@ -1141,7 +1155,7 @@ ], "description": "Symfony Stopwatch Component", "homepage": "https://symfony.com", - "time": "2019-01-01T13:45:19+00:00" + "time": "2019-01-03T09:07:35+00:00" } ], "packages-dev": [], diff --git a/vendor-bin/phpunit/composer.json b/vendor-bin/phpunit/composer.json index 2fe20f7f..e0854a62 100644 --- a/vendor-bin/phpunit/composer.json +++ b/vendor-bin/phpunit/composer.json @@ -1,6 +1,6 @@ { "require": { - "phpunit/phpunit": "^6.5", + "phpunit/phpunit": "*", "phake/phake": "^3.0", "clue/arguments": "^2.0", "mikey179/vfsStream": "^1.6", diff --git a/vendor-bin/phpunit/composer.lock b/vendor-bin/phpunit/composer.lock index 53d62ca8..88d83a55 100644 --- a/vendor-bin/phpunit/composer.lock +++ b/vendor-bin/phpunit/composer.lock @@ -1,10 +1,10 @@ { "_readme": [ "This file locks the dependencies of your project to a known state", - "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "4252b3d7817c9a4a5f60ac81f28202e2", + "content-hash": "5c03bb6fb595eebc1bb3e5fe9ea7c4a0", "packages": [ { "name": "clue/arguments", @@ -58,32 +58,32 @@ }, { "name": "doctrine/instantiator", - "version": "1.0.5", + "version": "1.1.0", "source": { "type": "git", "url": "https://github.com/doctrine/instantiator.git", - "reference": "8e884e78f9f0eb1329e445619e04456e64d8051d" + "reference": "185b8868aa9bf7159f5f953ed5afb2d7fcdc3bda" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/instantiator/zipball/8e884e78f9f0eb1329e445619e04456e64d8051d", - "reference": "8e884e78f9f0eb1329e445619e04456e64d8051d", + "url": "https://api.github.com/repos/doctrine/instantiator/zipball/185b8868aa9bf7159f5f953ed5afb2d7fcdc3bda", + "reference": "185b8868aa9bf7159f5f953ed5afb2d7fcdc3bda", "shasum": "" }, "require": { - "php": ">=5.3,<8.0-DEV" + "php": "^7.1" }, "require-dev": { "athletic/athletic": "~0.1.8", "ext-pdo": "*", "ext-phar": "*", - "phpunit/phpunit": "~4.0", - "squizlabs/php_codesniffer": "~2.0" + "phpunit/phpunit": "^6.2.3", + "squizlabs/php_codesniffer": "^3.0.2" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.0.x-dev" + "dev-master": "1.2.x-dev" } }, "autoload": { @@ -108,7 +108,7 @@ "constructor", "instantiate" ], - "time": "2015-06-14T21:17:01+00:00" + "time": "2017-07-22T11:58:36+00:00" }, { "name": "mikey179/vfsStream", @@ -158,25 +158,28 @@ }, { "name": "myclabs/deep-copy", - "version": "1.7.0", + "version": "1.8.1", "source": { "type": "git", "url": "https://github.com/myclabs/DeepCopy.git", - "reference": "3b8a3a99ba1f6a3952ac2747d989303cbd6b7a3e" + "reference": "3e01bdad3e18354c3dce54466b7fbe33a9f9f7f8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/3b8a3a99ba1f6a3952ac2747d989303cbd6b7a3e", - "reference": "3b8a3a99ba1f6a3952ac2747d989303cbd6b7a3e", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/3e01bdad3e18354c3dce54466b7fbe33a9f9f7f8", + "reference": "3e01bdad3e18354c3dce54466b7fbe33a9f9f7f8", "shasum": "" }, "require": { - "php": "^5.6 || ^7.0" + "php": "^7.1" + }, + "replace": { + "myclabs/deep-copy": "self.version" }, "require-dev": { "doctrine/collections": "^1.0", "doctrine/common": "^2.6", - "phpunit/phpunit": "^4.1" + "phpunit/phpunit": "^7.1" }, "type": "library", "autoload": { @@ -199,7 +202,7 @@ "object", "object graph" ], - "time": "2017-10-19T19:58:43+00:00" + "time": "2018-06-11T23:09:50+00:00" }, { "name": "phake/phake", @@ -261,22 +264,22 @@ }, { "name": "phar-io/manifest", - "version": "1.0.1", + "version": "1.0.3", "source": { "type": "git", "url": "https://github.com/phar-io/manifest.git", - "reference": "2df402786ab5368a0169091f61a7c1e0eb6852d0" + "reference": "7761fcacf03b4d4f16e7ccb606d4879ca431fcf4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phar-io/manifest/zipball/2df402786ab5368a0169091f61a7c1e0eb6852d0", - "reference": "2df402786ab5368a0169091f61a7c1e0eb6852d0", + "url": "https://api.github.com/repos/phar-io/manifest/zipball/7761fcacf03b4d4f16e7ccb606d4879ca431fcf4", + "reference": "7761fcacf03b4d4f16e7ccb606d4879ca431fcf4", "shasum": "" }, "require": { "ext-dom": "*", "ext-phar": "*", - "phar-io/version": "^1.0.1", + "phar-io/version": "^2.0", "php": "^5.6 || ^7.0" }, "type": "library", @@ -312,20 +315,20 @@ } ], "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)", - "time": "2017-03-05T18:14:27+00:00" + "time": "2018-07-08T19:23:20+00:00" }, { "name": "phar-io/version", - "version": "1.0.1", + "version": "2.0.1", "source": { "type": "git", "url": "https://github.com/phar-io/version.git", - "reference": "a70c0ced4be299a63d32fa96d9281d03e94041df" + "reference": "45a2ec53a73c70ce41d55cedef9063630abaf1b6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phar-io/version/zipball/a70c0ced4be299a63d32fa96d9281d03e94041df", - "reference": "a70c0ced4be299a63d32fa96d9281d03e94041df", + "url": "https://api.github.com/repos/phar-io/version/zipball/45a2ec53a73c70ce41d55cedef9063630abaf1b6", + "reference": "45a2ec53a73c70ce41d55cedef9063630abaf1b6", "shasum": "" }, "require": { @@ -359,7 +362,7 @@ } ], "description": "Library for handling version information and constraints", - "time": "2017-03-05T17:38:23+00:00" + "time": "2018-07-08T19:19:57+00:00" }, { "name": "phpdocumentor/reflection-common", @@ -578,40 +581,40 @@ }, { "name": "phpunit/php-code-coverage", - "version": "5.3.2", + "version": "6.1.4", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-code-coverage.git", - "reference": "c89677919c5dd6d3b3852f230a663118762218ac" + "reference": "807e6013b00af69b6c5d9ceb4282d0393dbb9d8d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/c89677919c5dd6d3b3852f230a663118762218ac", - "reference": "c89677919c5dd6d3b3852f230a663118762218ac", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/807e6013b00af69b6c5d9ceb4282d0393dbb9d8d", + "reference": "807e6013b00af69b6c5d9ceb4282d0393dbb9d8d", "shasum": "" }, "require": { "ext-dom": "*", "ext-xmlwriter": "*", - "php": "^7.0", - "phpunit/php-file-iterator": "^1.4.2", + "php": "^7.1", + "phpunit/php-file-iterator": "^2.0", "phpunit/php-text-template": "^1.2.1", - "phpunit/php-token-stream": "^2.0.1", + "phpunit/php-token-stream": "^3.0", "sebastian/code-unit-reverse-lookup": "^1.0.1", - "sebastian/environment": "^3.0", + "sebastian/environment": "^3.1 || ^4.0", "sebastian/version": "^2.0.1", "theseer/tokenizer": "^1.1" }, "require-dev": { - "phpunit/phpunit": "^6.0" + "phpunit/phpunit": "^7.0" }, "suggest": { - "ext-xdebug": "^2.5.5" + "ext-xdebug": "^2.6.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "5.3.x-dev" + "dev-master": "6.1-dev" } }, "autoload": { @@ -637,29 +640,32 @@ "testing", "xunit" ], - "time": "2018-04-06T15:36:58+00:00" + "time": "2018-10-31T16:06:48+00:00" }, { "name": "phpunit/php-file-iterator", - "version": "1.4.5", + "version": "2.0.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-file-iterator.git", - "reference": "730b01bc3e867237eaac355e06a36b85dd93a8b4" + "reference": "050bedf145a257b1ff02746c31894800e5122946" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/730b01bc3e867237eaac355e06a36b85dd93a8b4", - "reference": "730b01bc3e867237eaac355e06a36b85dd93a8b4", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/050bedf145a257b1ff02746c31894800e5122946", + "reference": "050bedf145a257b1ff02746c31894800e5122946", "shasum": "" }, "require": { - "php": ">=5.3.3" + "php": "^7.1" + }, + "require-dev": { + "phpunit/phpunit": "^7.1" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.4.x-dev" + "dev-master": "2.0.x-dev" } }, "autoload": { @@ -674,7 +680,7 @@ "authors": [ { "name": "Sebastian Bergmann", - "email": "sb@sebastian-bergmann.de", + "email": "sebastian@phpunit.de", "role": "lead" } ], @@ -684,7 +690,7 @@ "filesystem", "iterator" ], - "time": "2017-11-27T13:52:08+00:00" + "time": "2018-09-13T20:33:42+00:00" }, { "name": "phpunit/php-text-template", @@ -729,28 +735,28 @@ }, { "name": "phpunit/php-timer", - "version": "1.0.9", + "version": "2.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-timer.git", - "reference": "3dcf38ca72b158baf0bc245e9184d3fdffa9c46f" + "reference": "8b8454ea6958c3dee38453d3bd571e023108c91f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/3dcf38ca72b158baf0bc245e9184d3fdffa9c46f", - "reference": "3dcf38ca72b158baf0bc245e9184d3fdffa9c46f", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/8b8454ea6958c3dee38453d3bd571e023108c91f", + "reference": "8b8454ea6958c3dee38453d3bd571e023108c91f", "shasum": "" }, "require": { - "php": "^5.3.3 || ^7.0" + "php": "^7.1" }, "require-dev": { - "phpunit/phpunit": "^4.8.35 || ^5.7 || ^6.0" + "phpunit/phpunit": "^7.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.0-dev" + "dev-master": "2.0-dev" } }, "autoload": { @@ -765,7 +771,7 @@ "authors": [ { "name": "Sebastian Bergmann", - "email": "sb@sebastian-bergmann.de", + "email": "sebastian@phpunit.de", "role": "lead" } ], @@ -774,33 +780,33 @@ "keywords": [ "timer" ], - "time": "2017-02-26T11:10:40+00:00" + "time": "2018-02-01T13:07:23+00:00" }, { "name": "phpunit/php-token-stream", - "version": "2.0.2", + "version": "3.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-token-stream.git", - "reference": "791198a2c6254db10131eecfe8c06670700904db" + "reference": "c99e3be9d3e85f60646f152f9002d46ed7770d18" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-token-stream/zipball/791198a2c6254db10131eecfe8c06670700904db", - "reference": "791198a2c6254db10131eecfe8c06670700904db", + "url": "https://api.github.com/repos/sebastianbergmann/php-token-stream/zipball/c99e3be9d3e85f60646f152f9002d46ed7770d18", + "reference": "c99e3be9d3e85f60646f152f9002d46ed7770d18", "shasum": "" }, "require": { "ext-tokenizer": "*", - "php": "^7.0" + "php": "^7.1" }, "require-dev": { - "phpunit/phpunit": "^6.2.4" + "phpunit/phpunit": "^7.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "2.0-dev" + "dev-master": "3.0-dev" } }, "autoload": { @@ -823,57 +829,57 @@ "keywords": [ "tokenizer" ], - "time": "2017-11-27T05:48:46+00:00" + "time": "2018-10-30T05:52:18+00:00" }, { "name": "phpunit/phpunit", - "version": "6.5.13", + "version": "7.5.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "0973426fb012359b2f18d3bd1e90ef1172839693" + "reference": "7c89093bd00f7d5ddf0ab81dee04f801416b4944" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/0973426fb012359b2f18d3bd1e90ef1172839693", - "reference": "0973426fb012359b2f18d3bd1e90ef1172839693", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/7c89093bd00f7d5ddf0ab81dee04f801416b4944", + "reference": "7c89093bd00f7d5ddf0ab81dee04f801416b4944", "shasum": "" }, "require": { + "doctrine/instantiator": "^1.1", "ext-dom": "*", "ext-json": "*", "ext-libxml": "*", "ext-mbstring": "*", "ext-xml": "*", - "myclabs/deep-copy": "^1.6.1", - "phar-io/manifest": "^1.0.1", - "phar-io/version": "^1.0", - "php": "^7.0", + "myclabs/deep-copy": "^1.7", + "phar-io/manifest": "^1.0.2", + "phar-io/version": "^2.0", + "php": "^7.1", "phpspec/prophecy": "^1.7", - "phpunit/php-code-coverage": "^5.3", - "phpunit/php-file-iterator": "^1.4.3", + "phpunit/php-code-coverage": "^6.0.7", + "phpunit/php-file-iterator": "^2.0.1", "phpunit/php-text-template": "^1.2.1", - "phpunit/php-timer": "^1.0.9", - "phpunit/phpunit-mock-objects": "^5.0.9", - "sebastian/comparator": "^2.1", - "sebastian/diff": "^2.0", - "sebastian/environment": "^3.1", + "phpunit/php-timer": "^2.0", + "sebastian/comparator": "^3.0", + "sebastian/diff": "^3.0", + "sebastian/environment": "^4.0", "sebastian/exporter": "^3.1", "sebastian/global-state": "^2.0", "sebastian/object-enumerator": "^3.0.3", - "sebastian/resource-operations": "^1.0", + "sebastian/resource-operations": "^2.0", "sebastian/version": "^2.0.1" }, "conflict": { - "phpdocumentor/reflection-docblock": "3.0.2", - "phpunit/dbunit": "<3.0" + "phpunit/phpunit-mock-objects": "*" }, "require-dev": { "ext-pdo": "*" }, "suggest": { + "ext-soap": "*", "ext-xdebug": "*", - "phpunit/php-invoker": "^1.1" + "phpunit/php-invoker": "^2.0" }, "bin": [ "phpunit" @@ -881,7 +887,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "6.5.x-dev" + "dev-master": "7.5-dev" } }, "autoload": { @@ -907,66 +913,7 @@ "testing", "xunit" ], - "time": "2018-09-08T15:10:43+00:00" - }, - { - "name": "phpunit/phpunit-mock-objects", - "version": "5.0.10", - "source": { - "type": "git", - "url": "https://github.com/sebastianbergmann/phpunit-mock-objects.git", - "reference": "cd1cf05c553ecfec36b170070573e540b67d3f1f" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit-mock-objects/zipball/cd1cf05c553ecfec36b170070573e540b67d3f1f", - "reference": "cd1cf05c553ecfec36b170070573e540b67d3f1f", - "shasum": "" - }, - "require": { - "doctrine/instantiator": "^1.0.5", - "php": "^7.0", - "phpunit/php-text-template": "^1.2.1", - "sebastian/exporter": "^3.1" - }, - "conflict": { - "phpunit/phpunit": "<6.0" - }, - "require-dev": { - "phpunit/phpunit": "^6.5.11" - }, - "suggest": { - "ext-soap": "*" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "5.0.x-dev" - } - }, - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "lead" - } - ], - "description": "Mock Object library for PHPUnit", - "homepage": "https://github.com/sebastianbergmann/phpunit-mock-objects/", - "keywords": [ - "mock", - "xunit" - ], - "time": "2018-08-09T05:50:03+00:00" + "time": "2019-01-15T08:19:08+00:00" }, { "name": "sebastian/code-unit-reverse-lookup", @@ -1015,30 +962,30 @@ }, { "name": "sebastian/comparator", - "version": "2.1.3", + "version": "3.0.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/comparator.git", - "reference": "34369daee48eafb2651bea869b4b15d75ccc35f9" + "reference": "5de4fc177adf9bce8df98d8d141a7559d7ccf6da" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/34369daee48eafb2651bea869b4b15d75ccc35f9", - "reference": "34369daee48eafb2651bea869b4b15d75ccc35f9", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/5de4fc177adf9bce8df98d8d141a7559d7ccf6da", + "reference": "5de4fc177adf9bce8df98d8d141a7559d7ccf6da", "shasum": "" }, "require": { - "php": "^7.0", - "sebastian/diff": "^2.0 || ^3.0", + "php": "^7.1", + "sebastian/diff": "^3.0", "sebastian/exporter": "^3.1" }, "require-dev": { - "phpunit/phpunit": "^6.4" + "phpunit/phpunit": "^7.1" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "2.1.x-dev" + "dev-master": "3.0-dev" } }, "autoload": { @@ -1075,32 +1022,33 @@ "compare", "equality" ], - "time": "2018-02-01T13:46:46+00:00" + "time": "2018-07-12T15:12:46+00:00" }, { "name": "sebastian/diff", - "version": "2.0.1", + "version": "3.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/diff.git", - "reference": "347c1d8b49c5c3ee30c7040ea6fc446790e6bddd" + "reference": "366541b989927187c4ca70490a35615d3fef2dce" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/347c1d8b49c5c3ee30c7040ea6fc446790e6bddd", - "reference": "347c1d8b49c5c3ee30c7040ea6fc446790e6bddd", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/366541b989927187c4ca70490a35615d3fef2dce", + "reference": "366541b989927187c4ca70490a35615d3fef2dce", "shasum": "" }, "require": { - "php": "^7.0" + "php": "^7.1" }, "require-dev": { - "phpunit/phpunit": "^6.2" + "phpunit/phpunit": "^7.0", + "symfony/process": "^2 || ^3.3 || ^4" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "2.0-dev" + "dev-master": "3.0-dev" } }, "autoload": { @@ -1125,34 +1073,37 @@ "description": "Diff implementation", "homepage": "https://github.com/sebastianbergmann/diff", "keywords": [ - "diff" + "diff", + "udiff", + "unidiff", + "unified diff" ], - "time": "2017-08-03T08:09:46+00:00" + "time": "2018-06-10T07:54:39+00:00" }, { "name": "sebastian/environment", - "version": "3.1.0", + "version": "4.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/environment.git", - "reference": "cd0871b3975fb7fc44d11314fd1ee20925fce4f5" + "reference": "febd209a219cea7b56ad799b30ebbea34b71eb8f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/cd0871b3975fb7fc44d11314fd1ee20925fce4f5", - "reference": "cd0871b3975fb7fc44d11314fd1ee20925fce4f5", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/febd209a219cea7b56ad799b30ebbea34b71eb8f", + "reference": "febd209a219cea7b56ad799b30ebbea34b71eb8f", "shasum": "" }, "require": { - "php": "^7.0" + "php": "^7.1" }, "require-dev": { - "phpunit/phpunit": "^6.1" + "phpunit/phpunit": "^7.4" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "3.1.x-dev" + "dev-master": "4.0-dev" } }, "autoload": { @@ -1177,7 +1128,7 @@ "environment", "hhvm" ], - "time": "2017-07-01T08:51:00+00:00" + "time": "2018-11-25T09:31:21+00:00" }, { "name": "sebastian/exporter", @@ -1444,25 +1395,25 @@ }, { "name": "sebastian/resource-operations", - "version": "1.0.0", + "version": "2.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/resource-operations.git", - "reference": "ce990bb21759f94aeafd30209e8cfcdfa8bc3f52" + "reference": "4d7a795d35b889bf80a0cc04e08d77cedfa917a9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/resource-operations/zipball/ce990bb21759f94aeafd30209e8cfcdfa8bc3f52", - "reference": "ce990bb21759f94aeafd30209e8cfcdfa8bc3f52", + "url": "https://api.github.com/repos/sebastianbergmann/resource-operations/zipball/4d7a795d35b889bf80a0cc04e08d77cedfa917a9", + "reference": "4d7a795d35b889bf80a0cc04e08d77cedfa917a9", "shasum": "" }, "require": { - "php": ">=5.6.0" + "php": "^7.1" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.0.x-dev" + "dev-master": "2.0-dev" } }, "autoload": { @@ -1482,7 +1433,7 @@ ], "description": "Provides a list of PHP built-in functions that operate on resources", "homepage": "https://www.github.com/sebastianbergmann/resource-operations", - "time": "2015-07-28T20:34:47+00:00" + "time": "2018-10-04T04:07:39+00:00" }, { "name": "sebastian/version", @@ -1527,6 +1478,64 @@ "homepage": "https://github.com/sebastianbergmann/version", "time": "2016-10-03T07:35:21+00:00" }, + { + "name": "symfony/polyfill-ctype", + "version": "v1.10.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-ctype.git", + "reference": "e3d826245268269cd66f8326bd8bc066687b4a19" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/e3d826245268269cd66f8326bd8bc066687b4a19", + "reference": "e3d826245268269cd66f8326bd8bc066687b4a19", + "shasum": "" + }, + "require": { + "php": ">=5.3.3" + }, + "suggest": { + "ext-ctype": "For best performance" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.9-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Polyfill\\Ctype\\": "" + }, + "files": [ + "bootstrap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + }, + { + "name": "Gert de Pagter", + "email": "backendtea@gmail.com" + } + ], + "description": "Symfony polyfill for ctype functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "ctype", + "polyfill", + "portable" + ], + "time": "2018-08-06T14:22:27+00:00" + }, { "name": "theseer/tokenizer", "version": "1.1.0", @@ -1569,20 +1578,21 @@ }, { "name": "webmozart/assert", - "version": "1.3.0", + "version": "1.4.0", "source": { "type": "git", "url": "https://github.com/webmozart/assert.git", - "reference": "0df1908962e7a3071564e857d86874dad1ef204a" + "reference": "83e253c8e0be5b0257b881e1827274667c5c17a9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/webmozart/assert/zipball/0df1908962e7a3071564e857d86874dad1ef204a", - "reference": "0df1908962e7a3071564e857d86874dad1ef204a", + "url": "https://api.github.com/repos/webmozart/assert/zipball/83e253c8e0be5b0257b881e1827274667c5c17a9", + "reference": "83e253c8e0be5b0257b881e1827274667c5c17a9", "shasum": "" }, "require": { - "php": "^5.3.3 || ^7.0" + "php": "^5.3.3 || ^7.0", + "symfony/polyfill-ctype": "^1.8" }, "require-dev": { "phpunit/phpunit": "^4.6", @@ -1615,7 +1625,7 @@ "check", "validate" ], - "time": "2018-01-29T19:49:41+00:00" + "time": "2018-12-25T11:19:39+00:00" }, { "name": "webmozart/glob", diff --git a/vendor-bin/robo/composer.lock b/vendor-bin/robo/composer.lock index 09a7ac7e..123dcebc 100644 --- a/vendor-bin/robo/composer.lock +++ b/vendor-bin/robo/composer.lock @@ -1,23 +1,23 @@ { "_readme": [ "This file locks the dependencies of your project to a known state", - "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], "content-hash": "87a37068875d67919f797af9dc08e108", "packages": [ { "name": "consolidation/annotated-command", - "version": "2.10.0", + "version": "2.11.0", "source": { "type": "git", "url": "https://github.com/consolidation/annotated-command.git", - "reference": "8e7d1a05230dc1159c751809e98b74f2b7f71873" + "reference": "edea407f57104ed518cc3c3b47d5b84403ee267a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/consolidation/annotated-command/zipball/8e7d1a05230dc1159c751809e98b74f2b7f71873", - "reference": "8e7d1a05230dc1159c751809e98b74f2b7f71873", + "url": "https://api.github.com/repos/consolidation/annotated-command/zipball/edea407f57104ed518cc3c3b47d5b84403ee267a", + "reference": "edea407f57104ed518cc3c3b47d5b84403ee267a", "shasum": "" }, "require": { @@ -29,13 +29,57 @@ "symfony/finder": "^2.5|^3|^4" }, "require-dev": { - "g1a/composer-test-scenarios": "^2", + "g1a/composer-test-scenarios": "^3", + "php-coveralls/php-coveralls": "^1", "phpunit/phpunit": "^6", - "satooshi/php-coveralls": "^2", "squizlabs/php_codesniffer": "^2.7" }, "type": "library", "extra": { + "scenarios": { + "symfony4": { + "require": { + "symfony/console": "^4.0" + }, + "config": { + "platform": { + "php": "7.1.3" + } + } + }, + "symfony2": { + "require": { + "symfony/console": "^2.8" + }, + "require-dev": { + "phpunit/phpunit": "^4.8.36" + }, + "remove": [ + "php-coveralls/php-coveralls" + ], + "config": { + "platform": { + "php": "5.4.8" + } + }, + "scenario-options": { + "create-lockfile": "false" + } + }, + "phpunit4": { + "require-dev": { + "phpunit/phpunit": "^4.8.36" + }, + "remove": [ + "php-coveralls/php-coveralls" + ], + "config": { + "platform": { + "php": "5.4.8" + } + } + } + }, "branch-alias": { "dev-master": "2.x-dev" } @@ -56,7 +100,7 @@ } ], "description": "Initialize Symfony Console commands from annotated command class methods.", - "time": "2018-11-15T01:46:18+00:00" + "time": "2018-12-29T04:43:17+00:00" }, { "name": "consolidation/config", @@ -114,31 +158,72 @@ }, { "name": "consolidation/log", - "version": "1.0.6", + "version": "1.1.1", "source": { "type": "git", "url": "https://github.com/consolidation/log.git", - "reference": "dfd8189a771fe047bf3cd669111b2de5f1c79395" + "reference": "b2e887325ee90abc96b0a8b7b474cd9e7c896e3a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/consolidation/log/zipball/dfd8189a771fe047bf3cd669111b2de5f1c79395", - "reference": "dfd8189a771fe047bf3cd669111b2de5f1c79395", + "url": "https://api.github.com/repos/consolidation/log/zipball/b2e887325ee90abc96b0a8b7b474cd9e7c896e3a", + "reference": "b2e887325ee90abc96b0a8b7b474cd9e7c896e3a", "shasum": "" }, "require": { - "php": ">=5.5.0", - "psr/log": "~1.0", + "php": ">=5.4.5", + "psr/log": "^1.0", "symfony/console": "^2.8|^3|^4" }, "require-dev": { - "g1a/composer-test-scenarios": "^1", - "phpunit/phpunit": "4.*", - "satooshi/php-coveralls": "^2", - "squizlabs/php_codesniffer": "2.*" + "g1a/composer-test-scenarios": "^3", + "php-coveralls/php-coveralls": "^1", + "phpunit/phpunit": "^6", + "squizlabs/php_codesniffer": "^2" }, "type": "library", "extra": { + "scenarios": { + "symfony4": { + "require": { + "symfony/console": "^4.0" + }, + "config": { + "platform": { + "php": "7.1.3" + } + } + }, + "symfony2": { + "require": { + "symfony/console": "^2.8" + }, + "require-dev": { + "phpunit/phpunit": "^4.8.36" + }, + "remove": [ + "php-coveralls/php-coveralls" + ], + "config": { + "platform": { + "php": "5.4.8" + } + } + }, + "phpunit4": { + "require-dev": { + "phpunit/phpunit": "^4.8.36" + }, + "remove": [ + "php-coveralls/php-coveralls" + ], + "config": { + "platform": { + "php": "5.4.8" + } + } + } + }, "branch-alias": { "dev-master": "1.x-dev" } @@ -159,7 +244,7 @@ } ], "description": "Improved Psr-3 / Psr\\Log logger based on Symfony Console components.", - "time": "2018-05-25T18:14:39+00:00" + "time": "2019-01-01T17:30:51+00:00" }, { "name": "consolidation/output-formatters", @@ -219,20 +304,20 @@ }, { "name": "consolidation/robo", - "version": "1.3.2", + "version": "1.4.3", "source": { "type": "git", "url": "https://github.com/consolidation/Robo.git", - "reference": "a9bd9ecf00751aa92754903c0d17612c4e840ce8" + "reference": "d0b6f516ec940add7abed4f1432d30cca5f8ae0c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/consolidation/Robo/zipball/a9bd9ecf00751aa92754903c0d17612c4e840ce8", - "reference": "a9bd9ecf00751aa92754903c0d17612c4e840ce8", + "url": "https://api.github.com/repos/consolidation/Robo/zipball/d0b6f516ec940add7abed4f1432d30cca5f8ae0c", + "reference": "d0b6f516ec940add7abed4f1432d30cca5f8ae0c", "shasum": "" }, "require": { - "consolidation/annotated-command": "^2.8.2", + "consolidation/annotated-command": "^2.10.2", "consolidation/config": "^1.0.10", "consolidation/log": "~1", "consolidation/output-formatters": "^3.1.13", @@ -304,7 +389,7 @@ } }, "branch-alias": { - "dev-master": "1.x-dev" + "dev-master": "2.x-dev" } }, "autoload": { @@ -323,7 +408,7 @@ } ], "description": "Modern task runner", - "time": "2018-11-22T05:43:44+00:00" + "time": "2019-01-02T21:33:28+00:00" }, { "name": "consolidation/self-update", @@ -627,16 +712,16 @@ }, { "name": "pear/archive_tar", - "version": "1.4.3", + "version": "1.4.5", "source": { "type": "git", "url": "https://github.com/pear/Archive_Tar.git", - "reference": "43455c960da70e655c6bdf8ea2bc8cc1a6034afb" + "reference": "ff716ca697c5e9e8593212cb785ffd03ee11b01f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/pear/Archive_Tar/zipball/43455c960da70e655c6bdf8ea2bc8cc1a6034afb", - "reference": "43455c960da70e655c6bdf8ea2bc8cc1a6034afb", + "url": "https://api.github.com/repos/pear/Archive_Tar/zipball/ff716ca697c5e9e8593212cb785ffd03ee11b01f", + "reference": "ff716ca697c5e9e8593212cb785ffd03ee11b01f", "shasum": "" }, "require": { @@ -647,8 +732,8 @@ "phpunit/phpunit": "*" }, "suggest": { - "ext-bz2": "bz2 compression support.", - "ext-xz": "lzma2 compression support.", + "ext-bz2": "Bz2 compression support.", + "ext-xz": "Lzma2 compression support.", "ext-zlib": "Gzip compression support." }, "type": "library", @@ -683,13 +768,13 @@ "email": "mrook@php.net" } ], - "description": "Tar file management class", + "description": "Tar file management class with compression support (gzip, bzip2, lzma2)", "homepage": "https://github.com/pear/Archive_Tar", "keywords": [ "archive", "tar" ], - "time": "2017-06-11T17:28:11+00:00" + "time": "2019-01-02T21:45:13+00:00" }, { "name": "pear/console_getopt", @@ -740,20 +825,20 @@ }, { "name": "pear/pear-core-minimal", - "version": "v1.10.6", + "version": "v1.10.7", "source": { "type": "git", "url": "https://github.com/pear/pear-core-minimal.git", - "reference": "052868b244d31f822796e7e9981f62557eb256d4" + "reference": "19a3e0fcd50492c4357372f623f55f1b144346da" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/pear/pear-core-minimal/zipball/052868b244d31f822796e7e9981f62557eb256d4", - "reference": "052868b244d31f822796e7e9981f62557eb256d4", + "url": "https://api.github.com/repos/pear/pear-core-minimal/zipball/19a3e0fcd50492c4357372f623f55f1b144346da", + "reference": "19a3e0fcd50492c4357372f623f55f1b144346da", "shasum": "" }, "require": { - "pear/console_getopt": "~1.3", + "pear/console_getopt": "~1.4", "pear/pear_exception": "~1.0" }, "replace": { @@ -780,7 +865,7 @@ } ], "description": "Minimal set of PEAR core files to be used as composer dependency", - "time": "2018-08-22T19:28:09+00:00" + "time": "2018-12-05T20:03:52+00:00" }, { "name": "pear/pear_exception", @@ -935,21 +1020,21 @@ }, { "name": "symfony/console", - "version": "v3.4.18", + "version": "v4.2.2", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "1d228fb4602047d7b26a0554e0d3efd567da5803" + "reference": "b0a03c1bb0fcbe288629956cf2f1dd3f1dc97522" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/1d228fb4602047d7b26a0554e0d3efd567da5803", - "reference": "1d228fb4602047d7b26a0554e0d3efd567da5803", + "url": "https://api.github.com/repos/symfony/console/zipball/b0a03c1bb0fcbe288629956cf2f1dd3f1dc97522", + "reference": "b0a03c1bb0fcbe288629956cf2f1dd3f1dc97522", "shasum": "" }, "require": { - "php": "^5.5.9|>=7.0.8", - "symfony/debug": "~2.8|~3.0|~4.0", + "php": "^7.1.3", + "symfony/contracts": "^1.0", "symfony/polyfill-mbstring": "~1.0" }, "conflict": { @@ -958,11 +1043,11 @@ }, "require-dev": { "psr/log": "~1.0", - "symfony/config": "~3.3|~4.0", + "symfony/config": "~3.4|~4.0", "symfony/dependency-injection": "~3.4|~4.0", - "symfony/event-dispatcher": "~2.8|~3.0|~4.0", + "symfony/event-dispatcher": "~3.4|~4.0", "symfony/lock": "~3.4|~4.0", - "symfony/process": "~3.3|~4.0" + "symfony/process": "~3.4|~4.0" }, "suggest": { "psr/log-implementation": "For using the console logger", @@ -973,7 +1058,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "3.4-dev" + "dev-master": "4.2-dev" } }, "autoload": { @@ -1000,44 +1085,48 @@ ], "description": "Symfony Console Component", "homepage": "https://symfony.com", - "time": "2018-10-30T16:50:50+00:00" + "time": "2019-01-04T15:13:53+00:00" }, { - "name": "symfony/debug", - "version": "v3.4.18", + "name": "symfony/contracts", + "version": "v1.0.2", "source": { "type": "git", - "url": "https://github.com/symfony/debug.git", - "reference": "fe9793af008b651c5441bdeab21ede8172dab097" + "url": "https://github.com/symfony/contracts.git", + "reference": "1aa7ab2429c3d594dd70689604b5cf7421254cdf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/debug/zipball/fe9793af008b651c5441bdeab21ede8172dab097", - "reference": "fe9793af008b651c5441bdeab21ede8172dab097", + "url": "https://api.github.com/repos/symfony/contracts/zipball/1aa7ab2429c3d594dd70689604b5cf7421254cdf", + "reference": "1aa7ab2429c3d594dd70689604b5cf7421254cdf", "shasum": "" }, "require": { - "php": "^5.5.9|>=7.0.8", - "psr/log": "~1.0" - }, - "conflict": { - "symfony/http-kernel": ">=2.3,<2.3.24|~2.4.0|>=2.5,<2.5.9|>=2.6,<2.6.2" + "php": "^7.1.3" }, "require-dev": { - "symfony/http-kernel": "~2.8|~3.0|~4.0" + "psr/cache": "^1.0", + "psr/container": "^1.0" + }, + "suggest": { + "psr/cache": "When using the Cache contracts", + "psr/container": "When using the Service contracts", + "symfony/cache-contracts-implementation": "", + "symfony/service-contracts-implementation": "", + "symfony/translation-contracts-implementation": "" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "3.4-dev" + "dev-master": "1.0-dev" } }, "autoload": { "psr-4": { - "Symfony\\Component\\Debug\\": "" + "Symfony\\Contracts\\": "" }, "exclude-from-classmap": [ - "/Tests/" + "**/Tests/" ] }, "notification-url": "https://packagist.org/downloads/", @@ -1046,44 +1135,53 @@ ], "authors": [ { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" + "name": "Nicolas Grekas", + "email": "p@tchwork.com" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Symfony Debug Component", + "description": "A set of abstractions extracted out of the Symfony components", "homepage": "https://symfony.com", - "time": "2018-10-31T09:06:03+00:00" + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "time": "2018-12-05T08:06:11+00:00" }, { "name": "symfony/event-dispatcher", - "version": "v3.4.18", + "version": "v4.2.2", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher.git", - "reference": "db9e829c8f34c3d35cf37fcd4cdb4293bc4a2f14" + "reference": "887de6d34c86cf0cb6cbf910afb170cdb743cb5e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/db9e829c8f34c3d35cf37fcd4cdb4293bc4a2f14", - "reference": "db9e829c8f34c3d35cf37fcd4cdb4293bc4a2f14", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/887de6d34c86cf0cb6cbf910afb170cdb743cb5e", + "reference": "887de6d34c86cf0cb6cbf910afb170cdb743cb5e", "shasum": "" }, "require": { - "php": "^5.5.9|>=7.0.8" + "php": "^7.1.3", + "symfony/contracts": "^1.0" }, "conflict": { - "symfony/dependency-injection": "<3.3" + "symfony/dependency-injection": "<3.4" }, "require-dev": { "psr/log": "~1.0", - "symfony/config": "~2.8|~3.0|~4.0", - "symfony/dependency-injection": "~3.3|~4.0", - "symfony/expression-language": "~2.8|~3.0|~4.0", - "symfony/stopwatch": "~2.8|~3.0|~4.0" + "symfony/config": "~3.4|~4.0", + "symfony/dependency-injection": "~3.4|~4.0", + "symfony/expression-language": "~3.4|~4.0", + "symfony/stopwatch": "~3.4|~4.0" }, "suggest": { "symfony/dependency-injection": "", @@ -1092,7 +1190,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "3.4-dev" + "dev-master": "4.2-dev" } }, "autoload": { @@ -1119,30 +1217,30 @@ ], "description": "Symfony EventDispatcher Component", "homepage": "https://symfony.com", - "time": "2018-10-30T16:50:50+00:00" + "time": "2019-01-05T16:37:49+00:00" }, { "name": "symfony/filesystem", - "version": "v3.4.18", + "version": "v4.2.2", "source": { "type": "git", "url": "https://github.com/symfony/filesystem.git", - "reference": "d69930fc337d767607267d57c20a7403d0a822a4" + "reference": "c2ffd9a93f2d6c5be2f68a0aa7953cc229f871f8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/d69930fc337d767607267d57c20a7403d0a822a4", - "reference": "d69930fc337d767607267d57c20a7403d0a822a4", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/c2ffd9a93f2d6c5be2f68a0aa7953cc229f871f8", + "reference": "c2ffd9a93f2d6c5be2f68a0aa7953cc229f871f8", "shasum": "" }, "require": { - "php": "^5.5.9|>=7.0.8", + "php": "^7.1.3", "symfony/polyfill-ctype": "~1.8" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "3.4-dev" + "dev-master": "4.2-dev" } }, "autoload": { @@ -1169,29 +1267,29 @@ ], "description": "Symfony Filesystem Component", "homepage": "https://symfony.com", - "time": "2018-10-02T12:28:39+00:00" + "time": "2019-01-03T09:07:35+00:00" }, { "name": "symfony/finder", - "version": "v3.4.18", + "version": "v4.2.2", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "54ba444dddc5bd5708a34bd095ea67c6eb54644d" + "reference": "9094d69e8c6ee3fe186a0ec5a4f1401e506071ce" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/54ba444dddc5bd5708a34bd095ea67c6eb54644d", - "reference": "54ba444dddc5bd5708a34bd095ea67c6eb54644d", + "url": "https://api.github.com/repos/symfony/finder/zipball/9094d69e8c6ee3fe186a0ec5a4f1401e506071ce", + "reference": "9094d69e8c6ee3fe186a0ec5a4f1401e506071ce", "shasum": "" }, "require": { - "php": "^5.5.9|>=7.0.8" + "php": "^7.1.3" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "3.4-dev" + "dev-master": "4.2-dev" } }, "autoload": { @@ -1218,7 +1316,7 @@ ], "description": "Symfony Finder Component", "homepage": "https://symfony.com", - "time": "2018-10-03T08:46:40+00:00" + "time": "2019-01-03T09:07:35+00:00" }, { "name": "symfony/polyfill-ctype", @@ -1339,16 +1437,16 @@ }, { "name": "symfony/process", - "version": "v3.4.18", + "version": "v3.4.21", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "35c2914a9f50519bd207164c353ae4d59182c2cb" + "reference": "0d41dd7d95ed179aed6a13393b0f4f97bfa2d25c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/35c2914a9f50519bd207164c353ae4d59182c2cb", - "reference": "35c2914a9f50519bd207164c353ae4d59182c2cb", + "url": "https://api.github.com/repos/symfony/process/zipball/0d41dd7d95ed179aed6a13393b0f4f97bfa2d25c", + "reference": "0d41dd7d95ed179aed6a13393b0f4f97bfa2d25c", "shasum": "" }, "require": { @@ -1384,24 +1482,24 @@ ], "description": "Symfony Process Component", "homepage": "https://symfony.com", - "time": "2018-10-14T17:33:21+00:00" + "time": "2019-01-02T21:24:08+00:00" }, { "name": "symfony/yaml", - "version": "v3.4.18", + "version": "v4.2.2", "source": { "type": "git", "url": "https://github.com/symfony/yaml.git", - "reference": "640b6c27fed4066d64b64d5903a86043f4a4de7f" + "reference": "d0aa6c0ea484087927b49fd513383a7d36190ca6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/640b6c27fed4066d64b64d5903a86043f4a4de7f", - "reference": "640b6c27fed4066d64b64d5903a86043f4a4de7f", + "url": "https://api.github.com/repos/symfony/yaml/zipball/d0aa6c0ea484087927b49fd513383a7d36190ca6", + "reference": "d0aa6c0ea484087927b49fd513383a7d36190ca6", "shasum": "" }, "require": { - "php": "^5.5.9|>=7.0.8", + "php": "^7.1.3", "symfony/polyfill-ctype": "~1.8" }, "conflict": { @@ -1416,7 +1514,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "3.4-dev" + "dev-master": "4.2-dev" } }, "autoload": { @@ -1443,7 +1541,7 @@ ], "description": "Symfony Yaml Component", "homepage": "https://symfony.com", - "time": "2018-10-02T16:33:53+00:00" + "time": "2019-01-03T09:07:35+00:00" } ], "packages-dev": [], From d3a385beef78668f63da1965092710ab170b6938 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Sun, 3 Feb 2019 12:25:07 -0500 Subject: [PATCH 002/142] Partial API documentation for the Database class --- lib/Database.php | 197 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 197 insertions(+) diff --git a/lib/Database.php b/lib/Database.php index c3ac4c06..af3a7e63 100644 --- a/lib/Database.php +++ b/lib/Database.php @@ -14,8 +14,11 @@ use JKingWeb\Arsse\Misc\Date; use JKingWeb\Arsse\Misc\ValueInfo; class Database { + /** The version number of the latest schema the interface is aware of */ const SCHEMA_VERSION = 4; + /** The maximum number of articles to mark in one query without chunking */ const LIMIT_ARTICLES = 50; + /** A map database driver short-names and their associated class names */ const DRIVER_NAMES = [ 'sqlite3' => \JKingWeb\Arsse\Db\SQLite3\Driver::class, 'postgresql' => \JKingWeb\Arsse\Db\PostgreSQL\Driver::class, @@ -25,6 +28,10 @@ class Database { /** @var Db\Driver */ public $db; + /** Constructs the database interface + * + * @param boolean $initialize Whether to attempt to upgrade the databse schema when constructing + */ public function __construct($initialize = true) { $driver = Arsse::$conf->dbDriver; $this->db = $driver::create(); @@ -34,10 +41,14 @@ 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']; } + /** Lists the available database drivers, as an associative array with + * fully-qualified class names as keys, and human-readable descriptions as values + */ public static function driverList(): array { $sep = \DIRECTORY_SEPARATOR; $path = __DIR__.$sep."Db".$sep; @@ -50,10 +61,12 @@ class Database { return $classes; } + /** Returns the current (actual) schema version of the database; compared against self::SCHEMA_VERSION to know when an upgrade is required */ public function driverSchemaVersion(): int { return $this->db->schemaVersion(); } + /** Attempts to update the database schema. If it is already up to date, false is returned */ public function driverSchemaUpdate(): bool { if ($this->db->schemaVersion() < self::SCHEMA_VERSION) { return $this->db->schemaUpdate(self::SCHEMA_VERSION); @@ -61,10 +74,18 @@ class Database { return false; } + /** Returns whether the database's character set is Unicode */ public function driverCharsetAcceptable(): bool { return $this->db->charsetAcceptable(); } + /** Computes the column and value text of an SQL "SET" clause, validating arbitrary input against a whitelist + * + * Returns an indexed array containing the clause text, an array of types, and another array of values + * + * @param array $props An associative array containing untrusted data; keys are column names + * @param array $valid An associative array containing a whitelist: keys are column names, and values are strings representing data types + */ protected function generateSet(array $props, array $valid): array { $out = [ [], // query clause @@ -83,6 +104,13 @@ class Database { return $out; } + /** Conputes the contents of an SQL "IN()" clause, producing one parameter placeholder for each input value + * + * Returns an indexed array containing the clause text, and an array of types + * + * @param array $values Arbitrary values + * @param string $type A single data type applied to each value + */ protected function generateIn(array $values, string $type): array { $out = [ "", // query clause @@ -100,14 +128,17 @@ class Database { return $out; } + /** Returns a Transaction object, which is rolled back unless explicitly committed */ public function begin(): Db\Transaction { return $this->db->begin(); } + /** Retrieve a value from the metadata table. If the key is not set null is returned */ public function metaGet(string $key) { return $this->db->prepare("SELECT value from arsse_meta where \"key\" = ?", "str")->run($key)->getValue(); } + /** Sets the given key in the metadata table to the given value. If the key already exists it is silently overwritten */ public function metaSet(string $key, $value, string $type = "str"): bool { $out = $this->db->prepare("UPDATE arsse_meta set value = ? where \"key\" = ?", $type, "str")->run($value, $key)->changes(); if (!$out) { @@ -116,10 +147,12 @@ class Database { return (bool) $out; } + /** Unsets the given key in the metadata table. Returns false if the key does not exist */ public function metaRemove(string $key): bool { return (bool) $this->db->prepare("DELETE from arsse_meta where \"key\" = ?", "str")->run($key)->changes(); } + /** 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]); @@ -127,6 +160,11 @@ class Database { return (bool) $this->db->prepare("SELECT count(*) from arsse_users where id = ?", "str")->run($user)->getValue(); } + /** 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 + */ public function userAdd(string $user, string $password): bool { if (!Arsse::$user->authorize($user, __FUNCTION__)) { throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); @@ -138,6 +176,7 @@ class Database { return true; } + /** 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]); @@ -148,6 +187,7 @@ class Database { return true; } + /** Returns a flat, indexed array of all users in the database */ public function userList(): array { $out = []; if (!Arsse::$user->authorize("", __FUNCTION__)) { @@ -159,6 +199,7 @@ class Database { return $out; } + /** 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]); @@ -168,6 +209,11 @@ class Database { return (string) $this->db->prepare("SELECT password from arsse_users where id = ?", "str")->run($user)->getValue(); } + /** 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 + */ public function userPasswordSet(string $user, string $password): bool { if (!Arsse::$user->authorize($user, __FUNCTION__)) { throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); @@ -179,6 +225,7 @@ class Database { return true; } + /** 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__)) { @@ -193,6 +240,14 @@ class Database { return $id; } + /** Explicitly removes a session from the database + * + * Sessions may also be invalidated as they expire, and then be automatically pruned. + * This function can be used to explicitly invalidate a session after a user logs out + * + * @param string $user The user who owns the session to be destroyed + * @param string $id The identifier of the session to destroy + */ public function sessionDestroy(string $user, string $id): bool { // If the user isn't authorized to perform this action then throw an exception. if (!Arsse::$user->authorize($user, __FUNCTION__)) { @@ -202,6 +257,10 @@ class Database { return (bool) $this->db->prepare("DELETE FROM arsse_sessions where id = ? and \"user\" = ?", "str", "str")->run($id, $user)->changes(); } + /** Resumes a session, returning available session data + * + * This also has the side effect of refreshing the session if it is near its timeout + */ public function sessionResume(string $id): array { $maxAge = Date::sub(Arsse::$conf->userSessionLifetime); $out = $this->db->prepare("SELECT id,created,expires,\"user\" from arsse_sessions where id = ? and expires > CURRENT_TIMESTAMP and created > ?", "str", "datetime")->run($id, $maxAge)->getRow(); @@ -217,11 +276,13 @@ class Database { return $out; } + /** Deletes expires sessions from the database, returning the number of deleted sessions */ public function sessionCleanup(): int { $maxAge = Date::sub(Arsse::$conf->userSessionLifetime); return $this->db->prepare("DELETE FROM arsse_sessions where expires < CURRENT_TIMESTAMP or created < ?", "datetime")->run($maxAge)->changes(); } + /** Checks if a given future timeout is less than half the session timeout interval */ protected function sessionExpiringSoon(\DateTimeInterface $expiry): bool { // calculate half the session timeout as a number of seconds $now = time(); @@ -231,6 +292,18 @@ class Database { return (($now + $diff) >= $expiry->getTimestamp()); } + /** Adds a folder for containing newsfeed subscriptions, returning an integer identifying the created folder + * + * The $data array may contain the following keys: + * + * - "name": A folder name, which must be a non-empty string not composed solely of whitespace; this key is required + * - "parent": An integer (or null) identifying a parent folder; this key is optional + * + * If a folder with the same name and parent already exists, this is an error + * + * @param string $user The user who will own the folder + * @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__)) { @@ -245,6 +318,20 @@ class Database { return $this->db->prepare("INSERT INTO arsse_folders(owner,parent,name) values(?,?,?)", "str", "int", "str")->run($user, $parent, $name)->lastId(); } + /** Returns a result set listing a user's folders + * + * Each record in the result set contains: + * + * - "id": The folder identifier, an integer + * - "name": The folder's name, a string + * - "parent": The integer identifier of the folder's parent, or null + * - "children": The number of child folders contained in the given folder + * - "feeds": The number of newsfeed subscriptions contained in the given folder, not including subscriptions in descendent folders + * + * @param string $uer The user whose folders are to be listed + * @param integer|null $parent Restricts the list to the descendents of the specified folder identifier + * @param boolean $recursive Whether to list all descendents, or only direct children + */ 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__)) { @@ -270,6 +357,13 @@ class Database { return $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues()); } + /** Deletes a folder from the database + * + * Any descendent folders are also deleted, as are all newsfeed subscriptions contained in the deleted folder tree + * + * @param string $user The user to whom the folder to be deleted belongs + * @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]); @@ -284,6 +378,7 @@ class Database { return true; } + /** 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]); @@ -298,6 +393,19 @@ class Database { return $props; } + /** Modifies the properties of a folder + * + * The $data array must contain one or more of the following keys: + * + * - "name": A new folder name, which must be a non-empty string not composed solely of whitespace + * - "parent": An integer (or null) identifying a parent folder + * + * If a folder with the new name and parent combination already exists, this is an error; it is also an error to move a folder to itself or one of its descendents + * + * @param string $user The user who owns the folder to be modified + * @param integer $id The identifier of the folder to be modified + * @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]); @@ -334,6 +442,14 @@ class Database { return (bool) $this->db->prepare("UPDATE arsse_folders set $setClause, modified = CURRENT_TIMESTAMP where owner = ? and id = ?", $setTypes, "str", "int")->run($setValues, $user, $id)->changes(); } + /** Ensures the specified folder exists and raises an exception otherwise + * + * Returns an associative array containing the id, name, and parent of the folder if it exists + * + * @param string $user The user who owns the folder to be validated + * @param integer|null $id The identifier of the folder to validate; null or zero represent the implied root folder + * @param boolean $subject Whether the folder is the subject rather than the object of the operation being performed; this only affects the semantics of the error message if validation fails + */ 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)) { @@ -351,6 +467,7 @@ class Database { return $f; } + /** Ensures an operation to rename and/or move a folder does not result in a conflict or circular dependence, and raises an exception otherwise */ protected function folderValidateMove(string $user, $id = null, $parent = null, string $name = null) { $errData = ["action" => $this->caller(), "field" => "parent", 'id' => $parent]; if (!$id) { @@ -403,6 +520,12 @@ class Database { return $parent; } + /** Ensures a prospective folder name is valid, and optionally ensure it is not a duplicate if renamed + * + * @param string $name The name to check + * @param boolean $checkDuplicates Whether to also check if the new name would cause a collision + * @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)) { @@ -424,6 +547,14 @@ class Database { } } + /** Adds a subscription to a newsfeed, and returns the numeric identifier of the added subscription + * + * @param string $user The user which will own the subscription + * @param string $url The URL of the newsfeed or discovery source + * @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 $discovery 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]); @@ -452,6 +583,13 @@ class Database { return $this->db->prepare('INSERT INTO arsse_subscriptions(owner,feed) values(?,?)', 'str', 'int')->run($user, $feedID)->lastId(); } + /** Lists a user's subscriptions, returning various data + * + * @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 + * @param boolean $recursive Whether to list subscriptions of descendent folders as well as the selected folder + * @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]); @@ -494,6 +632,7 @@ class Database { return $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues()); } + /** 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]); @@ -512,6 +651,13 @@ class Database { return (int) $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues())->getValue(); } + /** Deletes a subscription from the database + * + * This has the side effect of deleting all marks the user has set on articles + * belonging to the newsfeed, but may not delete the articles themselves, as + * other users may also be subscribed to the same newsfeed. There is also a + * 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]); @@ -526,6 +672,24 @@ 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 + */ public function subscriptionPropertiesGet(string $user, $id): array { if (!Arsse::$user->authorize($user, __FUNCTION__)) { throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); @@ -540,6 +704,19 @@ class Database { return $sub; } + /** Modifies the properties of a subscription + * + * The $data array must contain one or more of the following keys: + * + * - "title": The title of the newsfeed + * - "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) + * + * @param string $user The user whose subscription is to be modified + * @param integer|null $id the numeric identifier of the subscription to modfify + * @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]); @@ -580,6 +757,18 @@ class Database { return $out; } + /** 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 favicon from arsse_feeds join arsse_subscriptions on feed = arsse_feeds.id"); $q->setWhere("arsse_subscriptions.id = ?", "int", $id); @@ -592,6 +781,14 @@ class Database { return (string) $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues())->getValue(); } + /** 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 + * + * @param string $user The user who owns the subscription to be validated + * @param integer|null $id The identifier of the subscription to validate + * @param boolean $subject Whether the subscription is the subject rather than the object 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)) { throw new Db\ExceptionInput("typeViolation", ["action" => $this->caller(), "field" => "feed", 'type' => "int > 0"]); From 49cefaf5c8277960223be40c832bdef3bcb2b588 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Mon, 4 Feb 2019 13:05:48 -0500 Subject: [PATCH 003/142] Complete API documentation for the Database class --- lib/Database.php | 177 ++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 176 insertions(+), 1 deletion(-) diff --git a/lib/Database.php b/lib/Database.php index af3a7e63..49ebf523 100644 --- a/lib/Database.php +++ b/lib/Database.php @@ -800,11 +800,17 @@ class Database { return $out; } + /** Returns an indexed array of numeric identifiers for newsfeeds which should be refreshed */ public function feedListStale(): array { $feeds = $this->db->query("SELECT id from arsse_feeds where next_fetch <= CURRENT_TIMESTAMP")->getAll(); return array_column($feeds, 'id'); } + /** Attempts to refresh a newsfeed, returning an indication of success + * + * @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 + */ public function feedUpdate($feedID, bool $throwError = false): bool { // check to make sure the feed exists if (!ValueInfo::id($feedID)) { @@ -956,6 +962,10 @@ class Database { return true; } + /** Deletes orphaned newsfeeds from the database + * + * Newsfeeds are orphaned if no users are subscribed to them. Deleting a newsfeed also deletes its articles + */ public function feedCleanup(): bool { $tr = $this->begin(); // first unmark any feeds which are no longer orphaned @@ -973,6 +983,18 @@ class Database { return $out; } + /** Retrieves various identifiers for the latest $count articles in the given newsfeed. The identifiers are: + * + * - "id": The database record key for the article + * - "guid": The (theoretically) unique identifier for the article + * - "edited": The time at which the article was last edited, per the newsfeed + * - "url_title_hash": A cryptographic hash of the article URL and its title + * - "url_content_hash": A cryptographic hash of the article URL and its content + * - "title_content_hash": A cryptographic hash of the article title and its content + * + * @param integer $feedID The numeric identifier of the feed + * @param integer $count The number of records to return + */ public function feedMatchLatest(int $feedID, int $count): Db\Result { return $this->db->prepare( "SELECT id, edited, guid, url_title_hash, url_content_hash, title_content_hash FROM arsse_articles WHERE feed = ? ORDER BY modified desc, id desc limit ?", @@ -981,6 +1003,21 @@ class Database { )->run($feedID, $count); } + /** Retrieves various identifiers for articles in the given newsfeed which match the input identifiers. The output identifiers are: + * + * - "id": The database record key for the article + * - "guid": The (theoretically) unique identifier for the article + * - "edited": The time at which the article was last edited, per the newsfeed + * - "url_title_hash": A cryptographic hash of the article URL and its title + * - "url_content_hash": A cryptographic hash of the article URL and its content + * - "title_content_hash": A cryptographic hash of the article title and its content + * + * @param integer $feedID The numeric identifier of the feed + * @param array $ids An array of GUIDs of articles + * @param array $hashesUT An array of hashes of articles' URL and title + * @param array $hashesUC An array of hashes of articles' URL and content + * @param array $hashesTC An array of hashes of articles' title and content + */ public function feedMatchIds(int $feedID, array $ids = [], array $hashesUT = [], array $hashesUC = [], array $hashesTC = []): Db\Result { // compile SQL IN() clauses and necessary type bindings for the four identifier lists list($cId, $tId) = $this->generateIn($ids, "str"); @@ -998,6 +1035,14 @@ class Database { )->run($feedID, $ids, $hashesUT, $hashesUC, $hashesTC); } + /** Computes an SQL query to find and retrieve data about articles in the database + * + * If an empty column list is supplied, a count of articles matching the context is queried instead + * + * @param string $user The user whose articles are to be queried + * @param Context $context The search context + * @param array $cols The columns to request in the result set + */ protected function articleQuery(string $user, Context $context, array $cols = ["id"]): Query { $greatest = $this->db->sqlToken("greatest"); // prepare the output column list @@ -1022,7 +1067,6 @@ class Database { 'subscription_title' => "coalesce(arsse_subscriptions.title, arsse_feeds.title)", 'media_url' => "arsse_enclosures.url", 'media_type' => "arsse_enclosures.type", - ]; if (!$cols) { // if no columns are specified return a count @@ -1160,6 +1204,7 @@ class Database { return $q; } + /** Chunk a context with more than the maximum number of articles or editions into an array of contexts */ protected function contextChunk(Context $context): array { $exception = ""; if ($context->editions()) { @@ -1184,6 +1229,15 @@ class Database { } } + + /** Lists articles in the database which match a given query context + * + * If an empty column list is supplied, a count of articles is returned instead + * + * @param string $user The user whose articles are to be listed + * @param Context $context The search context + * @param array $cols The columns to return in the result set, any of: id, edition, url, title, author, content, guid, fingerprint, subscription, feed, starred, unread, note, published_date, edited_date, modified_date, marked_date, subscription_title, media_url, media_type + */ public function articleList(string $user, Context $context = null, array $fields = ["id"]): Db\Result { if (!Arsse::$user->authorize($user, __FUNCTION__)) { throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); @@ -1207,6 +1261,11 @@ class Database { } } + /** Returns a count of articles which match the given query context + * + * @param string $user The user whose articles are to be counted + * @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]); @@ -1227,6 +1286,18 @@ class Database { } } + /** Applies one or multiple modifications to all articles matching the given query context + * + * The $data array enumerates the modifications to perform and must contain one or more of the following keys: + * + * - "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 + * - "note": A string containing a freeform plain-text note for the article + * + * @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 + */ 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]); @@ -1316,6 +1387,14 @@ class Database { } } + /** Returns statistics about the articles starred by the given user + * + * The associative array returned has the following keys: + * + * - "total": The count of all starred articles + * - "unread": The count of starred articles which are unread + * - "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]); @@ -1332,6 +1411,12 @@ class Database { )->run($user)->getRow(); } + /** Returns an indexed array listing the labels assigned to an article + * + * @param string $user The user whose labels are to be listed + * @param integer $id The numeric identifier of the article whose labels are to be listed + * @param boolean $byName Whether to return the label names instead of the numeric label identifiers + */ 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]); @@ -1344,6 +1429,7 @@ class Database { return $out; } + /** 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]); @@ -1358,6 +1444,7 @@ class Database { } } + /** Deletes from the database articles which are beyond the configured clean-up threshold */ public function articleCleanup(): bool { $query = $this->db->prepare( "WITH target_feed(id,subs) as (". @@ -1404,6 +1491,13 @@ class Database { return true; } + /** Ensures the specified article exists and raises an exception otherwise + * + * Returns an associative array containing the id and latest edition of the article if it exists + * + * @param string $user The user who owns the article to be validated + * @param integer|null $id The identifier of the article to validate + */ protected function articleValidateId(string $user, $id): array { if (!ValueInfo::id($id)) { throw new Db\ExceptionInput("typeViolation", ["action" => $this->caller(), "field" => "article", 'type' => "int > 0"]); // @codeCoverageIgnore @@ -1426,6 +1520,13 @@ class Database { return $out; } + /** Ensures the specified article edition exists and raises an exception otherwise + * + * Returns an associative array containing the edition id, article id, and latest edition of the edition if it exists + * + * @param string $user The user who owns the edition to be validated + * @param integer|null $id The identifier of the edition to validate + */ protected function articleValidateEdition(string $user, int $id): array { if (!ValueInfo::id($id)) { throw new Db\ExceptionInput("typeViolation", ["action" => $this->caller(), "field" => "edition", 'type' => "int > 0"]); // @codeCoverageIgnore @@ -1450,6 +1551,7 @@ class Database { return array_map("intval", $out); } + /** 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]); @@ -1465,6 +1567,7 @@ class Database { return (int) $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues())->getValue(); } + /** Returns a map between all the given edition identifiers and their associated article identifiers */ public function editionArticle(int ...$edition): array { $out = []; $context = (new Context)->editions($edition); @@ -1484,6 +1587,13 @@ class Database { } } + /** Creates a label, and returns its numeric identifier + * + * Labels are discrete objects in the database and can be associated with multiple articles; an article may in turn be associated with multiple labels + * + * @param string $user The user who will own the created label + * @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__)) { @@ -1496,6 +1606,18 @@ class Database { return $this->db->prepare("INSERT INTO arsse_labels(owner,name) values(?,?)", "str", "str")->run($user, $name)->lastId(); } + /** Lists a user's article labels + * + * The following keys are included in each record: + * + * - "id": The label's numeric identifier + * - "name" The label's textual name + * - "articles": The count of articles which have the label assigned to them + * - "read": How many of the total articles assigned to the label are read + * + * @param string $user The user whose labels are to be listed + * @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__)) { @@ -1518,6 +1640,14 @@ class Database { )->run($user, !$includeEmpty); } + /** Deletes a label from the database + * + * Any articles associated with the label remains untouched + * + * @param string $user The owner of the label to remove + * @param integer|string $id The numeric identifier or name of the label + * @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]); @@ -1532,6 +1662,19 @@ class Database { return true; } + /** Retrieves the properties of a label + * + * The following keys are included in the output array: + * + * - "id": The label's numeric identifier + * - "name" The label's textual name + * - "articles": The count of articles which have the label assigned to them + * - "read": How many of the total articles assigned to the label are read + * + * @param string $user The owner of the label to remove + * @param integer|string $id The numeric identifier or name of the label + * @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]); @@ -1558,6 +1701,13 @@ class Database { return $out; } + /** Sets the properties of a label + * + * @param string $user The owner of the label to query + * @param integer|string $id The numeric identifier or name of the label + * @param array $data An associative array defining the label's properties; currently only "name" is understood + * @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]); @@ -1583,6 +1733,12 @@ class Database { return $out; } + /** Returns an indexed array of article identifiers assigned to a label + * + * @param string $user The owner of the label to query + * @param integer|string $id The numeric identifier or name of the label + * @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]); @@ -1603,6 +1759,14 @@ class Database { } } + /** Makes or breaks associations between a given label and articles matching the given query context + * + * @param string $user The owner of the label + * @param integer|string $id The numeric identifier or name of the label + * @param Context $context The query context matching the desired articles + * @param boolean $remove Whether to remove (true) rather than add (true) an association with the articles matching the context + * @param boolean $byName Whether to interpret the $id parameter as the label's name (true) or identifier (false) + */ public function labelArticlesSet(string $user, $id, Context $context = null, bool $remove = false, bool $byName = false): int { if (!Arsse::$user->authorize($user, __FUNCTION__)) { throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); @@ -1643,6 +1807,16 @@ class Database { return $out; } + /** Ensures the specified label identifier or name is valid (and optionally whether it exists) and raises an exception otherwise + * + * Returns an associative array containing the id, name of the label if it exists + * + * @param string $user The user who owns the label to be validated + * @param integer|string $id The numeric identifier or name of the label to validate + * @param boolean $byName Whether to interpret the $id parameter as the label's name (true) or identifier (false) + * @param boolean $checkDb Whether to check whether the label exists (true) or only if the identifier or name is syntactically valid (false) + * @param boolean $subject Whether the label is the subject rather than the object 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 we're not referring to a label by name and the ID is invalid, throw an exception @@ -1666,6 +1840,7 @@ 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)) { From b0d545836796c364603616439d607080eb5ba0d6 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Mon, 4 Feb 2019 13:18:33 -0500 Subject: [PATCH 004/142] Clarify some prospective protocols --- lib/REST.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/REST.php b/lib/REST.php index 1bc395f3..39899c19 100644 --- a/lib/REST.php +++ b/lib/REST.php @@ -40,17 +40,17 @@ class REST { ], // Other candidates: // Google Reader http://feedhq.readthedocs.io/en/latest/api/index.html - // Fever https://feedafever.com/api + // Fever https://web.archive.org/web/20161217042229/https://feedafever.com/api // Feedbin v2 https://github.com/feedbin/feedbin-api // CommaFeed https://www.commafeed.com/api/ // 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://github.com/miniflux/miniflux/blob/master/docs/json-rpc-api.markdown + // 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 // Proprietary (centralized) entities: - // NewsBlur http://www.newsblur.com/api // Feedly https://developer.feedly.com/ ]; const DEFAULT_PORTS = [ From 17f3a2f0599003880f8a7ac7d6b0195560d32dd7 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Wed, 13 Feb 2019 12:37:41 -0500 Subject: [PATCH 005/142] Start on an API overview for the Database class --- lib/Database.php | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/lib/Database.php b/lib/Database.php index 49ebf523..dfa9e7c4 100644 --- a/lib/Database.php +++ b/lib/Database.php @@ -13,6 +13,27 @@ use JKingWeb\Arsse\Misc\Context; use JKingWeb\Arsse\Misc\Date; use JKingWeb\Arsse\Misc\ValueInfo; +/** The high-level interface with the database + * + * The database stores information on the following things: + * + * - Users + * - Subscriptions to feeds, which belong to users + * - Folders, which belong to users and contain subscriptions + * - Feeds to which users are subscribed + * - 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 + * - Sessions, used by some protocols to identify users across periods of time + * - Metadata, used internally by the server + * + * The various methods of this class perform operations on these things, with + * each public method prefixed with the thing it concerns e.g. userRemove() + * 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. + */ class Database { /** The version number of the latest schema the interface is aware of */ const SCHEMA_VERSION = 4; From 4316c700a891d00abaeb82e7ba87cde5b11166bf Mon Sep 17 00:00:00 2001 From: "J. King" Date: Tue, 19 Feb 2019 08:46:17 -0500 Subject: [PATCH 006/142] Nginx should send the normalized URL to the application --- dist/nginx-fcgi.conf | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dist/nginx-fcgi.conf b/dist/nginx-fcgi.conf index fb378259..2890fc9a 100644 --- a/dist/nginx-fcgi.conf +++ b/dist/nginx-fcgi.conf @@ -8,6 +8,6 @@ fastcgi_param SCRIPT_FILENAME /usr/share/arsse/arsse.php; fastcgi_param REQUEST_METHOD $request_method; fastcgi_param CONTENT_TYPE $content_type; fastcgi_param CONTENT_LENGTH $content_length; -fastcgi_param REQUEST_URI $request_uri; +fastcgi_param REQUEST_URI $uri; fastcgi_param HTTPS $https if_not_empty; -fastcgi_param REMOTE_USER $remote_user; \ No newline at end of file +fastcgi_param REMOTE_USER $remote_user; From b55d0b374fb0ef4313ded83039f2aa23a63c29ed Mon Sep 17 00:00:00 2001 From: "J. King" Date: Thu, 21 Feb 2019 15:10:32 -0500 Subject: [PATCH 007/142] API documentation for database driver interface --- lib/Db/Driver.php | 60 +++++++++++++++++++++++++++++++++++++---------- 1 file changed, 47 insertions(+), 13 deletions(-) diff --git a/lib/Db/Driver.php b/lib/Db/Driver.php index 64eca653..d3486db4 100644 --- a/lib/Db/Driver.php +++ b/lib/Db/Driver.php @@ -13,32 +13,66 @@ interface Driver { const TR_PEND_COMMIT = -1; const TR_PEND_ROLLBACK = -2; + /** Creates and returns an instance of the class; this is so that either a native or PDO driver may be returned depending on what is available on the server */ public static function create(): Driver; - // returns a human-friendly name for the driver (for display in installer, for example) + + /** Returns a human-friendly name for the driver */ public static function driverName(): string; - // returns the version of the scheme of the opened database; if uninitialized should return 0 + + /** Returns the version of the schema of the opened database; if uninitialized should return 0 + * + * Normally the version is stored under the 'schema_version' key in the arsse_meta table, but another method may be used if appropriate + */ public function schemaVersion(): int; - // returns the schema set to be used for database set-up + + /** Returns the schema set to be used for database set-up */ public static function schemaID(): string; - // return a Transaction object + + /** Returns a Transaction object */ public function begin(bool $lock = false): Transaction; - // manually begin a real or synthetic transactions, with real or synthetic nesting + + /** Manually begins a real or synthetic transactions, with real or synthetic nesting, and returns its numeric ID + * + * If the database backend does not implement savepoints, IDs must still be tracked as if it does + */ public function savepointCreate(): int; - // manually commit either the latest or all pending nested transactions + + /** Manually commits either the latest or a specified nested transaction */ public function savepointRelease(int $index = null): bool; - // manually rollback either the latest or all pending nested transactions + + /** Manually rolls back either the latest or a specified nested transaction */ public function savepointUndo(int $index = null): bool; - // attempt to perform an in-place upgrade of the database schema; this may be a no-op which always throws an exception + + /** Performs an in-place upgrade of the database schema + * + * The driver may choose not to implement in-place upgrading, in which case an exception should be thrown + */ public function schemaUpdate(int $to): bool; - // execute one or more unsanitized SQL queries and return an indication of success + + /** Executes one or more queries without parameters, returning only an indication of success */ public function exec(string $query): bool; - // perform a single unsanitized query and return a result set + + /** Executes a single query without parameters, and returns a result set */ public function query(string $query): Result; - // ready a prepared statement for later execution + + /** Readies a prepared statement for later execution */ public function prepare(string $query, ...$paramType): Statement; + + /** Readies a prepared statement for later execution */ public function prepareArray(string $query, array $paramTypes): Statement; - // report whether the database character set is correct/acceptable + + /** Reports whether the database character set is correct/acceptable + * + * The backend must be able to accept and provide UTF-8 text; information may be stored in any encoding capable of representing the entire range of Unicode + */ public function charsetAcceptable(): bool; - // return an implementation-dependent form of a reference SQL function or operator + + /** Returns an implementation-dependent form of a reference SQL function or operator + * + * The tokens the implementation must understand are: + * + * - "greatest": the GREATEST function implemented by PostgreSQL and MySQL + * - "nocase": the name of a general-purpose case-insensitive collation sequence + */ public function sqlToken(string $token): string; } From 908e1fa3105d6adedfed91584bc6beb2e13bb41d Mon Sep 17 00:00:00 2001 From: "J. King" Date: Thu, 21 Feb 2019 15:10:32 -0500 Subject: [PATCH 008/142] API documentation for database driver interface --- lib/Db/Driver.php | 60 +++++++++++++++++++++++++++++++++++++---------- 1 file changed, 47 insertions(+), 13 deletions(-) diff --git a/lib/Db/Driver.php b/lib/Db/Driver.php index 64eca653..d3486db4 100644 --- a/lib/Db/Driver.php +++ b/lib/Db/Driver.php @@ -13,32 +13,66 @@ interface Driver { const TR_PEND_COMMIT = -1; const TR_PEND_ROLLBACK = -2; + /** Creates and returns an instance of the class; this is so that either a native or PDO driver may be returned depending on what is available on the server */ public static function create(): Driver; - // returns a human-friendly name for the driver (for display in installer, for example) + + /** Returns a human-friendly name for the driver */ public static function driverName(): string; - // returns the version of the scheme of the opened database; if uninitialized should return 0 + + /** Returns the version of the schema of the opened database; if uninitialized should return 0 + * + * Normally the version is stored under the 'schema_version' key in the arsse_meta table, but another method may be used if appropriate + */ public function schemaVersion(): int; - // returns the schema set to be used for database set-up + + /** Returns the schema set to be used for database set-up */ public static function schemaID(): string; - // return a Transaction object + + /** Returns a Transaction object */ public function begin(bool $lock = false): Transaction; - // manually begin a real or synthetic transactions, with real or synthetic nesting + + /** Manually begins a real or synthetic transactions, with real or synthetic nesting, and returns its numeric ID + * + * If the database backend does not implement savepoints, IDs must still be tracked as if it does + */ public function savepointCreate(): int; - // manually commit either the latest or all pending nested transactions + + /** Manually commits either the latest or a specified nested transaction */ public function savepointRelease(int $index = null): bool; - // manually rollback either the latest or all pending nested transactions + + /** Manually rolls back either the latest or a specified nested transaction */ public function savepointUndo(int $index = null): bool; - // attempt to perform an in-place upgrade of the database schema; this may be a no-op which always throws an exception + + /** Performs an in-place upgrade of the database schema + * + * The driver may choose not to implement in-place upgrading, in which case an exception should be thrown + */ public function schemaUpdate(int $to): bool; - // execute one or more unsanitized SQL queries and return an indication of success + + /** Executes one or more queries without parameters, returning only an indication of success */ public function exec(string $query): bool; - // perform a single unsanitized query and return a result set + + /** Executes a single query without parameters, and returns a result set */ public function query(string $query): Result; - // ready a prepared statement for later execution + + /** Readies a prepared statement for later execution */ public function prepare(string $query, ...$paramType): Statement; + + /** Readies a prepared statement for later execution */ public function prepareArray(string $query, array $paramTypes): Statement; - // report whether the database character set is correct/acceptable + + /** Reports whether the database character set is correct/acceptable + * + * The backend must be able to accept and provide UTF-8 text; information may be stored in any encoding capable of representing the entire range of Unicode + */ public function charsetAcceptable(): bool; - // return an implementation-dependent form of a reference SQL function or operator + + /** Returns an implementation-dependent form of a reference SQL function or operator + * + * The tokens the implementation must understand are: + * + * - "greatest": the GREATEST function implemented by PostgreSQL and MySQL + * - "nocase": the name of a general-purpose case-insensitive collation sequence + */ public function sqlToken(string $token): string; } From ad8057a40b74b09808d0166e5b778d5978a92d3e Mon Sep 17 00:00:00 2001 From: "J. King" Date: Fri, 22 Feb 2019 11:13:13 -0500 Subject: [PATCH 009/142] Driver changes to support basic text searching --- lib/Db/Driver.php | 4 ++++ lib/Db/MySQL/Driver.php | 4 ++++ lib/Db/PostgreSQL/Driver.php | 6 ++++++ lib/Db/SQLite3/Driver.php | 4 ++++ 4 files changed, 18 insertions(+) diff --git a/lib/Db/Driver.php b/lib/Db/Driver.php index d3486db4..81241e52 100644 --- a/lib/Db/Driver.php +++ b/lib/Db/Driver.php @@ -73,6 +73,10 @@ 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 */ public function sqlToken(string $token): string; + + /** Indicates whether the implementation is capable of full-text searching */ + public function fulltextEnabled(): bool; } diff --git a/lib/Db/MySQL/Driver.php b/lib/Db/MySQL/Driver.php index 8a4fe445..ac3fa795 100644 --- a/lib/Db/MySQL/Driver.php +++ b/lib/Db/MySQL/Driver.php @@ -212,4 +212,8 @@ class Driver extends \JKingWeb\Arsse\Db\AbstractDriver { public function prepareArray(string $query, array $paramTypes): \JKingWeb\Arsse\Db\Statement { return new Statement($this->db, $query, $paramTypes, $this->packetSize); } + + public function fulltextEnabled(): bool { + return false; + } } diff --git a/lib/Db/PostgreSQL/Driver.php b/lib/Db/PostgreSQL/Driver.php index 513ce998..e138afa9 100644 --- a/lib/Db/PostgreSQL/Driver.php +++ b/lib/Db/PostgreSQL/Driver.php @@ -120,6 +120,8 @@ class Driver extends \JKingWeb\Arsse\Db\AbstractDriver { switch (strtolower($token)) { case "nocase": return '"und-x-icu"'; + case "like": + return "ilike"; default: return $token; } @@ -219,4 +221,8 @@ class Driver extends \JKingWeb\Arsse\Db\AbstractDriver { public function prepareArray(string $query, array $paramTypes): \JKingWeb\Arsse\Db\Statement { return new Statement($this->db, $query, $paramTypes); } + + public function fulltextEnabled(): bool { + return false; + } } diff --git a/lib/Db/SQLite3/Driver.php b/lib/Db/SQLite3/Driver.php index f7e47fb9..e979d7c1 100644 --- a/lib/Db/SQLite3/Driver.php +++ b/lib/Db/SQLite3/Driver.php @@ -179,4 +179,8 @@ class Driver extends \JKingWeb\Arsse\Db\AbstractDriver { $this->exec((!$rollback) ? "COMMIT" : "ROLLBACK"); return true; } + + public function fulltextEnabled(): bool { + return false; + } } From f9fde2370888cc38b003da0ba0c609e527d0e28f Mon Sep 17 00:00:00 2001 From: "J. King" Date: Fri, 22 Feb 2019 11:13:42 -0500 Subject: [PATCH 010/142] Context changes to support basic text searching --- lib/Misc/Context.php | 26 +++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/lib/Misc/Context.php b/lib/Misc/Context.php index 93e4ac43..fa6241a9 100644 --- a/lib/Misc/Context.php +++ b/lib/Misc/Context.php @@ -34,6 +34,7 @@ class Context { public $labelName; public $labelled = null; public $annotated = null; + public $searchTerms = []; protected $props = []; @@ -52,7 +53,7 @@ class Context { } } - protected function cleanArray(array $spec): array { + protected function cleanIdArray(array $spec): array { $spec = array_values($spec); for ($a = 0; $a < sizeof($spec); $a++) { if (ValueInfo::id($spec[$a])) { @@ -64,6 +65,18 @@ class Context { return array_values(array_filter($spec)); } + protected function cleanStringArray(array $spec): array { + $spec = array_values($spec); + for ($a = 0; $a < sizeof($spec); $a++) { + if (strlen($str = ValueInfo::normalize($spec[$a], ValueInfo::T_STRING))) { + $spec[$a] = $str; + } else { + unset($spec[$a]); + } + } + return array_values($spec); + } + public function reverse(bool $spec = null) { return $this->act(__FUNCTION__, func_num_args(), $spec); } @@ -142,14 +155,14 @@ class Context { public function editions(array $spec = null) { if (isset($spec)) { - $spec = $this->cleanArray($spec); + $spec = $this->cleanIdArray($spec); } return $this->act(__FUNCTION__, func_num_args(), $spec); } public function articles(array $spec = null) { if (isset($spec)) { - $spec = $this->cleanArray($spec); + $spec = $this->cleanIdArray($spec); } return $this->act(__FUNCTION__, func_num_args(), $spec); } @@ -169,4 +182,11 @@ class Context { public function annotated(bool $spec = null) { return $this->act(__FUNCTION__, func_num_args(), $spec); } + + public function searchTerms(array $spec = null) { + if (isset($spec)) { + $spec = $this->cleanStringArray($spec); + } + return $this->act(__FUNCTION__, func_num_args(), $spec); + } } From ace94e3ef85e76cffb1f2e37e703532f443e3df1 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Fri, 22 Feb 2019 12:34:06 -0500 Subject: [PATCH 011/142] Fix context, and context tests --- lib/Misc/Context.php | 7 ++++--- tests/cases/Misc/TestContext.php | 15 ++++++++++++++- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/lib/Misc/Context.php b/lib/Misc/Context.php index fa6241a9..87ada397 100644 --- a/lib/Misc/Context.php +++ b/lib/Misc/Context.php @@ -34,7 +34,7 @@ class Context { public $labelName; public $labelled = null; public $annotated = null; - public $searchTerms = []; + public $searchTerms = null; protected $props = []; @@ -67,8 +67,9 @@ class Context { protected function cleanStringArray(array $spec): array { $spec = array_values($spec); - for ($a = 0; $a < sizeof($spec); $a++) { - if (strlen($str = ValueInfo::normalize($spec[$a], ValueInfo::T_STRING))) { + $stop = sizeof($spec); + for ($a = 0; $a < $stop; $a++) { + if (strlen($str = ValueInfo::normalize($spec[$a], ValueInfo::T_STRING | ValueInfo::M_DROP) ?? "")) { $spec[$a] = $str; } else { unset($spec[$a]); diff --git a/tests/cases/Misc/TestContext.php b/tests/cases/Misc/TestContext.php index 07d6adb0..b767d115 100644 --- a/tests/cases/Misc/TestContext.php +++ b/tests/cases/Misc/TestContext.php @@ -7,6 +7,7 @@ declare(strict_types=1); namespace JKingWeb\Arsse\TestCase\Misc; use JKingWeb\Arsse\Misc\Context; +use JKingWeb\Arsse\Misc\ValueInfo; /** @covers \JKingWeb\Arsse\Misc\Context */ class TestContext extends \JKingWeb\Arsse\Test\AbstractTest { @@ -48,6 +49,7 @@ class TestContext extends \JKingWeb\Arsse\Test\AbstractTest { 'labelName' => "Rush", 'labelled' => true, 'annotated' => true, + 'searchTerms' => ["foo", "bar"], ]; $times = ['modifiedSince','notModifiedSince','markedSince','notMarkedSince']; $c = new Context; @@ -70,7 +72,7 @@ class TestContext extends \JKingWeb\Arsse\Test\AbstractTest { } } - public function testCleanArrayValues() { + public function testCleanIdArrayValues() { $methods = ["articles", "editions"]; $in = [1, "2", 3.5, 3.0, "ook", 0, -20, true, false, null, new \DateTime(), -1.0]; $out = [1,2, 3]; @@ -79,4 +81,15 @@ class TestContext extends \JKingWeb\Arsse\Test\AbstractTest { $this->assertSame($out, $c->$method($in)->$method, "Context method $method did not return the expected results"); } } + + public function testCleanStringArrayValues() { + $methods = ["searchTerms"]; + $now = new \DateTime; + $in = [1, 3.0, "ook", 0, true, false, null, $now, ""]; + $out = ["1", "3", "ook", "0", valueInfo::normalize($now, ValueInfo::T_STRING)]; + $c = new Context; + foreach ($methods as $method) { + $this->assertSame($out, $c->$method($in)->$method, "Context method $method did not return the expected results"); + } + } } From 570a9b171cba166fb487d16d6eaa1c619eac97e3 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Fri, 22 Feb 2019 18:49:57 -0500 Subject: [PATCH 012/142] Revert fulltext detection in driver --- lib/Db/Driver.php | 3 --- lib/Db/MySQL/Driver.php | 4 ---- lib/Db/PostgreSQL/Driver.php | 4 ---- lib/Db/SQLite3/Driver.php | 4 ---- 4 files changed, 15 deletions(-) diff --git a/lib/Db/Driver.php b/lib/Db/Driver.php index 81241e52..959a5500 100644 --- a/lib/Db/Driver.php +++ b/lib/Db/Driver.php @@ -76,7 +76,4 @@ interface Driver { * - "like": the case-insensitive LIKE operator */ public function sqlToken(string $token): string; - - /** Indicates whether the implementation is capable of full-text searching */ - public function fulltextEnabled(): bool; } diff --git a/lib/Db/MySQL/Driver.php b/lib/Db/MySQL/Driver.php index ac3fa795..8a4fe445 100644 --- a/lib/Db/MySQL/Driver.php +++ b/lib/Db/MySQL/Driver.php @@ -212,8 +212,4 @@ class Driver extends \JKingWeb\Arsse\Db\AbstractDriver { public function prepareArray(string $query, array $paramTypes): \JKingWeb\Arsse\Db\Statement { return new Statement($this->db, $query, $paramTypes, $this->packetSize); } - - public function fulltextEnabled(): bool { - return false; - } } diff --git a/lib/Db/PostgreSQL/Driver.php b/lib/Db/PostgreSQL/Driver.php index e138afa9..08c439d3 100644 --- a/lib/Db/PostgreSQL/Driver.php +++ b/lib/Db/PostgreSQL/Driver.php @@ -221,8 +221,4 @@ class Driver extends \JKingWeb\Arsse\Db\AbstractDriver { public function prepareArray(string $query, array $paramTypes): \JKingWeb\Arsse\Db\Statement { return new Statement($this->db, $query, $paramTypes); } - - public function fulltextEnabled(): bool { - return false; - } } diff --git a/lib/Db/SQLite3/Driver.php b/lib/Db/SQLite3/Driver.php index e979d7c1..f7e47fb9 100644 --- a/lib/Db/SQLite3/Driver.php +++ b/lib/Db/SQLite3/Driver.php @@ -179,8 +179,4 @@ class Driver extends \JKingWeb\Arsse\Db\AbstractDriver { $this->exec((!$rollback) ? "COMMIT" : "ROLLBACK"); return true; } - - public function fulltextEnabled(): bool { - return false; - } } From bc3182a961959485867c154459d9f9554f716cf5 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Fri, 22 Feb 2019 18:50:39 -0500 Subject: [PATCH 013/142] Basic substring searching --- lib/Database.php | 42 +++++++++++++++++++++++++- tests/cases/Database/SeriesArticle.php | 19 ++++++++++-- 2 files changed, 57 insertions(+), 4 deletions(-) diff --git a/lib/Database.php b/lib/Database.php index dfa9e7c4..30562d97 100644 --- a/lib/Database.php +++ b/lib/Database.php @@ -39,6 +39,8 @@ class Database { const SCHEMA_VERSION = 4; /** The maximum number of articles to mark in one query without chunking */ const LIMIT_ARTICLES = 50; + /** The maximum number of search terms allowed; this is a hard limit */ + const LIMIT_TERMS = 100; /** A map database driver short-names and their associated class names */ const DRIVER_NAMES = [ 'sqlite3' => \JKingWeb\Arsse\Db\SQLite3\Driver::class, @@ -149,6 +151,35 @@ class Database { return $out; } + /** Computes basic LIKE-based text search constraints for use in a WHERE clause + * + * Returns an indexed array containing the clause text, an array of types, and another array of values + * + * The clause is structured such that all terms must be present across any of the columns + * + * @param string[] $terms The terms to search for + * @param string[] $cols The columns to match against; these are -not- sanitized, so much -not- come directly from user input + */ + protected function generateSearch(array $terms, array $cols): array { + $clause = []; + $types = []; + $values = []; + $like = $this->db->sqlToken("like"); + foreach($terms as $term) { + $term = str_replace(["%", "_", "^"], ["^%", "^_", "^^"], $term); + $term = "%$term%"; + $spec = []; + foreach ($cols as $col) { + $spec[] = "$col $like ? escape '^'"; + $types[] = "str"; + $values[] = $term; + } + $clause[] = "(".implode(" or ", $spec).")"; + } + $clause = "(".implode(" and ", $clause).")"; + return [$clause, $types, $values]; + } + /** Returns a Transaction object, which is rolled back unless explicitly committed */ public function begin(): Db\Transaction { return $this->db->begin(); @@ -1160,7 +1191,7 @@ class Database { list($inParams, $inTypes) = $this->generateIn($context->editions, "int"); $q->setWhere("latest_editions.edition in ($inParams)", $inTypes, $context->editions); } elseif ($context->articles()) { - // if multiple specific articles have been requested, prepare a CTE to list them and their articles + // if multiple specific articles have been requested, filter against the list if (!$context->articles) { throw new Db\ExceptionInput("tooShort", ['field' => "articles", 'action' => __FUNCTION__, 'min' => 1]); // must have at least one array element } elseif (sizeof($context->articles) > self::LIMIT_ARTICLES) { @@ -1221,6 +1252,15 @@ class Database { $comp = ($context->annotated) ? "<>" : "="; $q->setWhere("coalesce(arsse_marks.note,'') $comp ''"); } + // filter based on search terms + if ($context->searchTerms()) { + if (!$context->searchTerms) { + throw new Db\ExceptionInput("tooShort", ['field' => "searchTerms", 'action' => __FUNCTION__, 'min' => 1]); // must have at least one array element + } elseif (sizeof($context->searchTerms) > self::LIMIT_TERMS) { + throw new Db\ExceptionInput("tooLong", ['field' => "searchTerms", 'action' => __FUNCTION__, 'max' => self::LIMIT_TERMS]); + } + $q->setWhere(...$this->generateSearch($context->searchTerms, ["arsse_articles.title", "arsse_articles.content"])); + } // return the query return $q; } diff --git a/tests/cases/Database/SeriesArticle.php b/tests/cases/Database/SeriesArticle.php index c3c4425e..d19f85bc 100644 --- a/tests/cases/Database/SeriesArticle.php +++ b/tests/cases/Database/SeriesArticle.php @@ -111,9 +111,9 @@ trait SeriesArticle { 'modified' => "datetime", ], 'rows' => [ - [1,1,null,null,null,null,null,null,null,"","","","2000-01-01T00:00:00Z"], - [2,1,null,null,null,null,null,null,null,"","","","2010-01-01T00:00:00Z"], - [3,2,null,null,null,null,null,null,null,"","","","2000-01-01T00:00:00Z"], + [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,null,null,null,null,null,"","","","2010-01-01T00:00:00Z"], [5,3,null,null,null,null,null,null,null,"","","","2000-01-01T00:00:00Z"], [6,3,null,null,null,null,null,null,null,"","","","2010-01-01T00:00:00Z"], @@ -494,6 +494,9 @@ trait SeriesArticle { // get specific starred articles $compareIds([1], (new Context)->articles([1,2,3])->starred(true)); $compareIds([2,3], (new Context)->articles([1,2,3])->starred(false)); + // get items that match search terms + $compareIds([1,2,3], (new Context)->searchTerms(["Article"])); + $compareIds([1], (new Context)->searchTerms(["one", "first"])); } public function testListArticlesOfAMissingFolder() { @@ -985,4 +988,14 @@ trait SeriesArticle { $this->assertException("notAuthorized", "User", "ExceptionAuthz"); Arsse::$db->articleCategoriesGet($this->user, 19); } + + public function testSearchTooFewTerms() { + $this->assertException("tooShort", "Db", "ExceptionInput"); + Arsse::$db->articleList($this->user, (new Context)->searchTerms([])); + } + + public function testSearchTooManyTerms() { + $this->assertException("tooLong", "Db", "ExceptionInput"); + Arsse::$db->articleList($this->user, (new Context)->searchTerms(range(1, 105))); + } } From 2df7c25b663d8919a386eedf7c81a55c88e4afbf Mon Sep 17 00:00:00 2001 From: "J. King" Date: Sat, 23 Feb 2019 20:14:52 -0500 Subject: [PATCH 014/142] Add ability to search note text --- lib/Database.php | 21 +++++++++++++++------ lib/Misc/Context.php | 8 ++++++++ tests/cases/Database/SeriesArticle.php | 15 +++++++++++++++ tests/cases/Db/BaseDriver.php | 1 + tests/cases/Misc/TestContext.php | 3 ++- 5 files changed, 41 insertions(+), 7 deletions(-) diff --git a/lib/Database.php b/lib/Database.php index 30562d97..0310c5a9 100644 --- a/lib/Database.php +++ b/lib/Database.php @@ -1184,18 +1184,18 @@ class Database { if ($context->editions()) { // if multiple specific editions have been requested, filter against the list if (!$context->editions) { - throw new Db\ExceptionInput("tooShort", ['field' => "editions", 'action' => __FUNCTION__, 'min' => 1]); // must have at least one array element + throw new Db\ExceptionInput("tooShort", ['field' => "editions", 'action' => $this->caller(), 'min' => 1]); // must have at least one array element } elseif (sizeof($context->editions) > self::LIMIT_ARTICLES) { - throw new Db\ExceptionInput("tooLong", ['field' => "editions", 'action' => __FUNCTION__, 'max' => self::LIMIT_ARTICLES]); // @codeCoverageIgnore + throw new Db\ExceptionInput("tooLong", ['field' => "editions", 'action' => $this->caller(), 'max' => self::LIMIT_ARTICLES]); // @codeCoverageIgnore } list($inParams, $inTypes) = $this->generateIn($context->editions, "int"); $q->setWhere("latest_editions.edition in ($inParams)", $inTypes, $context->editions); } elseif ($context->articles()) { // if multiple specific articles have been requested, filter against the list if (!$context->articles) { - throw new Db\ExceptionInput("tooShort", ['field' => "articles", 'action' => __FUNCTION__, 'min' => 1]); // must have at least one array element + throw new Db\ExceptionInput("tooShort", ['field' => "articles", 'action' => $this->caller(), 'min' => 1]); // must have at least one array element } elseif (sizeof($context->articles) > self::LIMIT_ARTICLES) { - throw new Db\ExceptionInput("tooLong", ['field' => "articles", 'action' => __FUNCTION__, 'max' => self::LIMIT_ARTICLES]); // @codeCoverageIgnore + throw new Db\ExceptionInput("tooLong", ['field' => "articles", 'action' => $this->caller(), 'max' => self::LIMIT_ARTICLES]); // @codeCoverageIgnore } list($inParams, $inTypes) = $this->generateIn($context->articles, "int"); $q->setWhere("arsse_articles.id in ($inParams)", $inTypes, $context->articles); @@ -1255,12 +1255,21 @@ class Database { // filter based on search terms if ($context->searchTerms()) { if (!$context->searchTerms) { - throw new Db\ExceptionInput("tooShort", ['field' => "searchTerms", 'action' => __FUNCTION__, 'min' => 1]); // must have at least one array element + throw new Db\ExceptionInput("tooShort", ['field' => "searchTerms", 'action' => $this->caller(), 'min' => 1]); // must have at least one array element } elseif (sizeof($context->searchTerms) > self::LIMIT_TERMS) { - throw new Db\ExceptionInput("tooLong", ['field' => "searchTerms", 'action' => __FUNCTION__, 'max' => self::LIMIT_TERMS]); + throw new Db\ExceptionInput("tooLong", ['field' => "searchTerms", 'action' => $this->caller(), 'max' => self::LIMIT_TERMS]); } $q->setWhere(...$this->generateSearch($context->searchTerms, ["arsse_articles.title", "arsse_articles.content"])); } + // filter based on search terms in note + if ($context->annotationTerms()) { + if (!$context->annotationTerms) { + throw new Db\ExceptionInput("tooShort", ['field' => "annotationTerms", 'action' => $this->caller(), 'min' => 1]); // must have at least one array element + } elseif (sizeof($context->annotationTerms) > self::LIMIT_TERMS) { + throw new Db\ExceptionInput("tooLong", ['field' => "annotationTerms", 'action' => $this->caller(), 'max' => self::LIMIT_TERMS]); + } + $q->setWhere(...$this->generateSearch($context->annotationTerms, ["arsse_marks.note"])); + } // return the query return $q; } diff --git a/lib/Misc/Context.php b/lib/Misc/Context.php index 87ada397..1dd1a171 100644 --- a/lib/Misc/Context.php +++ b/lib/Misc/Context.php @@ -34,6 +34,7 @@ class Context { public $labelName; public $labelled = null; public $annotated = null; + public $annotationTerms = null; public $searchTerms = null; protected $props = []; @@ -184,6 +185,13 @@ class Context { return $this->act(__FUNCTION__, func_num_args(), $spec); } + public function annotationTerms(array $spec = null) { + if (isset($spec)) { + $spec = $this->cleanStringArray($spec); + } + return $this->act(__FUNCTION__, func_num_args(), $spec); + } + public function searchTerms(array $spec = null) { if (isset($spec)) { $spec = $this->cleanStringArray($spec); diff --git a/tests/cases/Database/SeriesArticle.php b/tests/cases/Database/SeriesArticle.php index d19f85bc..80114c4d 100644 --- a/tests/cases/Database/SeriesArticle.php +++ b/tests/cases/Database/SeriesArticle.php @@ -497,6 +497,11 @@ trait SeriesArticle { // get items that match search terms $compareIds([1,2,3], (new Context)->searchTerms(["Article"])); $compareIds([1], (new Context)->searchTerms(["one", "first"])); + // get items that match search terms in note + $compareIds([2], (new Context)->annotationTerms(["some"])); + $compareIds([2], (new Context)->annotationTerms(["some", "note"])); + $compareIds([2], (new Context)->annotationTerms(["some note"])); + $compareIds([], (new Context)->annotationTerms(["some", "sauce"])); } public function testListArticlesOfAMissingFolder() { @@ -998,4 +1003,14 @@ trait SeriesArticle { $this->assertException("tooLong", "Db", "ExceptionInput"); Arsse::$db->articleList($this->user, (new Context)->searchTerms(range(1, 105))); } + + public function testSearchTooFewTermsInNote() { + $this->assertException("tooShort", "Db", "ExceptionInput"); + Arsse::$db->articleList($this->user, (new Context)->annotationTerms([])); + } + + public function testSearchTooManyTermsInNote() { + $this->assertException("tooLong", "Db", "ExceptionInput"); + Arsse::$db->articleList($this->user, (new Context)->annotationTerms(range(1, 105))); + } } diff --git a/tests/cases/Db/BaseDriver.php b/tests/cases/Db/BaseDriver.php index 682c6881..4967e84c 100644 --- a/tests/cases/Db/BaseDriver.php +++ b/tests/cases/Db/BaseDriver.php @@ -94,6 +94,7 @@ abstract class BaseDriver extends \JKingWeb\Arsse\Test\AbstractTest { public function testTranslateAToken() { $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")); } diff --git a/tests/cases/Misc/TestContext.php b/tests/cases/Misc/TestContext.php index b767d115..902a6bad 100644 --- a/tests/cases/Misc/TestContext.php +++ b/tests/cases/Misc/TestContext.php @@ -50,6 +50,7 @@ class TestContext extends \JKingWeb\Arsse\Test\AbstractTest { 'labelled' => true, 'annotated' => true, 'searchTerms' => ["foo", "bar"], + 'annotationTerms' => ["foo", "bar"], ]; $times = ['modifiedSince','notModifiedSince','markedSince','notMarkedSince']; $c = new Context; @@ -83,7 +84,7 @@ class TestContext extends \JKingWeb\Arsse\Test\AbstractTest { } public function testCleanStringArrayValues() { - $methods = ["searchTerms"]; + $methods = ["searchTerms", "annotationTerms"]; $now = new \DateTime; $in = [1, 3.0, "ook", 0, true, false, null, $now, ""]; $out = ["1", "3", "ook", "0", valueInfo::normalize($now, ValueInfo::T_STRING)]; From f4a74eec5d3680c017cbe935488869e748e787bb Mon Sep 17 00:00:00 2001 From: "J. King" Date: Mon, 25 Feb 2019 10:46:43 -0500 Subject: [PATCH 015/142] Add all the other context options allowed by the TTRSS search syntax --- lib/Misc/Context.php | 26 ++++++++++++++++++++++++++ tests/cases/Misc/TestContext.php | 9 ++++++--- 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/lib/Misc/Context.php b/lib/Misc/Context.php index 1dd1a171..9263fa1d 100644 --- a/lib/Misc/Context.php +++ b/lib/Misc/Context.php @@ -10,6 +10,7 @@ use JKingWeb\Arsse\Misc\Date; use JKingWeb\Arsse\Misc\ValueInfo; class Context { + public $not = null; public $reverse = false; public $limit = 0; public $offset = 0; @@ -36,9 +37,16 @@ class Context { public $annotated = null; public $annotationTerms = null; public $searchTerms = null; + public $titleTerms = null; + public $authorTerms = null; protected $props = []; + public function __clone() { + // clone the negation context, if any + $this->not = $this->not ? clone $this->not : null; + } + protected function act(string $prop, int $set, $value) { if ($set) { if (is_null($value)) { @@ -198,4 +206,22 @@ class Context { } return $this->act(__FUNCTION__, func_num_args(), $spec); } + + public function titleTerms(array $spec = null) { + if (isset($spec)) { + $spec = $this->cleanStringArray($spec); + } + return $this->act(__FUNCTION__, func_num_args(), $spec); + } + + public function authorTerms(array $spec = null) { + if (isset($spec)) { + $spec = $this->cleanStringArray($spec); + } + return $this->act(__FUNCTION__, func_num_args(), $spec); + } + + public function not(self $spec = null) { + return $this->act(__FUNCTION__, func_num_args(), $spec); + } } diff --git a/tests/cases/Misc/TestContext.php b/tests/cases/Misc/TestContext.php index 902a6bad..12a99693 100644 --- a/tests/cases/Misc/TestContext.php +++ b/tests/cases/Misc/TestContext.php @@ -14,7 +14,7 @@ class TestContext extends \JKingWeb\Arsse\Test\AbstractTest { public function testVerifyInitialState() { $c = new Context; foreach ((new \ReflectionObject($c))->getMethods(\ReflectionMethod::IS_PUBLIC) as $m) { - if ($m->isConstructor() || $m->isStatic()) { + if ($m->isStatic() || strpos($m->name, "__") === 0) { continue; } $method = $m->name; @@ -51,11 +51,14 @@ class TestContext extends \JKingWeb\Arsse\Test\AbstractTest { 'annotated' => true, 'searchTerms' => ["foo", "bar"], 'annotationTerms' => ["foo", "bar"], + 'titleTerms' => ["foo", "bar"], + 'authorTerms' => ["foo", "bar"], + 'not' => (new Context)->subscription(5), ]; $times = ['modifiedSince','notModifiedSince','markedSince','notMarkedSince']; $c = new Context; foreach ((new \ReflectionObject($c))->getMethods(\ReflectionMethod::IS_PUBLIC) as $m) { - if ($m->isConstructor() || $m->isStatic()) { + if ($m->isStatic() || strpos($m->name, "__") === 0) { continue; } $method = $m->name; @@ -84,7 +87,7 @@ class TestContext extends \JKingWeb\Arsse\Test\AbstractTest { } public function testCleanStringArrayValues() { - $methods = ["searchTerms", "annotationTerms"]; + $methods = ["searchTerms", "annotationTerms", "titleTerms", "authorTerms"]; $now = new \DateTime; $in = [1, 3.0, "ook", 0, true, false, null, $now, ""]; $out = ["1", "3", "ook", "0", valueInfo::normalize($now, ValueInfo::T_STRING)]; From 14c02d56ac36f1aaea8a7b2835da7fab112af12c Mon Sep 17 00:00:00 2001 From: "J. King" Date: Mon, 25 Feb 2019 16:26:38 -0500 Subject: [PATCH 016/142] Implement new context options other than not(). Context handling has also been re-organized to simplify later implementation of the not() option --- lib/Database.php | 193 +++++++++++-------------- tests/cases/Database/Base.php | 4 +- tests/cases/Database/SeriesArticle.php | 166 ++++++++++----------- 3 files changed, 162 insertions(+), 201 deletions(-) diff --git a/lib/Database.php b/lib/Database.php index 0310c5a9..353e7b93 100644 --- a/lib/Database.php +++ b/lib/Database.php @@ -39,8 +39,6 @@ class Database { const SCHEMA_VERSION = 4; /** The maximum number of articles to mark in one query without chunking */ const LIMIT_ARTICLES = 50; - /** The maximum number of search terms allowed; this is a hard limit */ - const LIMIT_TERMS = 100; /** A map database driver short-names and their associated class names */ const DRIVER_NAMES = [ 'sqlite3' => \JKingWeb\Arsse\Db\SQLite3\Driver::class, @@ -129,7 +127,7 @@ class Database { /** Conputes the contents of an SQL "IN()" clause, producing one parameter placeholder for each input value * - * Returns an indexed array containing the clause text, and an array of types + * Returns an indexed array containing the clause text, an array of types, and the array of values * * @param array $values Arbitrary values * @param string $type A single data type applied to each value @@ -138,6 +136,7 @@ class Database { $out = [ "", // query clause [], // binding types + $values, // binding values ]; if (sizeof($values)) { // the query clause is just a series of question marks separated by commas @@ -1096,8 +1095,32 @@ class Database { * @param array $cols The columns to request in the result set */ protected function articleQuery(string $user, Context $context, array $cols = ["id"]): Query { + // validate input + if ($context->subscription()) { + $this->subscriptionValidateId($user, $context->subscription); + } + if ($context->folder()) { + $this->folderValidateId($user, $context->folder); + } + if ($context->folderShallow()) { + $this->folderValidateId($user, $context->folderShallow); + } + if ($context->edition()) { + $this->articleValidateEdition($user, $context->edition); + } + if ($context->article()) { + $this->articleValidateId($user, $context->article); + } + if ($context->label()) { + $this->labelValidateId($user, $context->label, false); + } + if ($context->labelName()) { + // dereference the label name to an ID + $context->label((int) $this->labelValidateId($user, $context->labelName, true)['id']); + $context->labelName(null); + } + // prepare the output column list; the column definitions are also used later $greatest = $this->db->sqlToken("greatest"); - // prepare the output column list $colDefs = [ 'id' => "arsse_articles.id", 'edition' => "latest_editions.edition", @@ -1107,6 +1130,7 @@ class Database { 'content' => "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", 'starred' => "coalesce(arsse_marks.starred,0)", @@ -1148,127 +1172,82 @@ class Database { ["str"], [$user] ); + $q->setLimit($context->limit, $context->offset); $q->setCTE("latest_editions(article,edition)", "SELECT article,max(id) from arsse_editions group by article", [], [], "join latest_editions on arsse_articles.id = latest_editions.article"); if ($cols) { // if there are no output columns requested we're getting a count and should not group, but otherwise we should $q->setGroup("arsse_articles.id", "arsse_marks.note", "arsse_enclosures.url", "arsse_enclosures.type", "arsse_subscriptions.title", "arsse_feeds.title", "arsse_subscriptions.id", "arsse_marks.modified", "arsse_label_members.modified", "arsse_marks.read", "arsse_marks.starred", "latest_editions.edition"); } - $q->setLimit($context->limit, $context->offset); - if ($context->subscription()) { - // if a subscription is specified, make sure it exists - $this->subscriptionValidateId($user, $context->subscription); - // filter for the subscription - $q->setWhere("arsse_subscriptions.id = ?", "int", $context->subscription); - } elseif ($context->folder()) { - // if a folder is specified, make sure it exists - $this->folderValidateId($user, $context->folder); - // if it does exist, 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 select id from arsse_folders join folders on parent = folder", "int", $context->folder); - // limit subscriptions to the listed folders - $q->setWhere("arsse_subscriptions.folder in (select folder from folders)"); - } elseif ($context->folderShallow()) { - // if a shallow folder is specified, make sure it exists - $this->folderValidateId($user, $context->folderShallow); - // if it does exist, filter for that folder only - $q->setWhere("coalesce(arsse_subscriptions.folder,0) = ?", "int", $context->folderShallow); - } - if ($context->edition()) { - // if an edition is specified, first validate it, then filter for it - $this->articleValidateEdition($user, $context->edition); - $q->setWhere("latest_editions.edition = ?", "int", $context->edition); - } elseif ($context->article()) { - // if an article is specified, first validate it, then filter for it - $this->articleValidateId($user, $context->article); - $q->setWhere("arsse_articles.id = ?", "int", $context->article); - } - if ($context->editions()) { - // if multiple specific editions have been requested, filter against the list - if (!$context->editions) { - throw new Db\ExceptionInput("tooShort", ['field' => "editions", 'action' => $this->caller(), 'min' => 1]); // must have at least one array element - } elseif (sizeof($context->editions) > self::LIMIT_ARTICLES) { - throw new Db\ExceptionInput("tooLong", ['field' => "editions", 'action' => $this->caller(), 'max' => self::LIMIT_ARTICLES]); // @codeCoverageIgnore + // handle the simple context options + foreach ([ + // each context array consists of a column identifier (see $colDefs above), a comparison operator, a data type, and an upper bound if the value is an array + "edition" => ["edition", "=", "int", 1], + "editions" => ["edition", "in", "int", self::LIMIT_ARTICLES], + "article" => ["id", "=", "int", 1], + "articles" => ["id", "in", "int", self::LIMIT_ARTICLES], + "oldestArticle" => ["id", ">=", "int", 1], + "latestArticle" => ["id", "<=", "int", 1], + "oldestEdition" => ["edition", ">=", "int", 1], + "latestEdition" => ["edition", "<=", "int", 1], + "modifiedSince" => ["modified_date", ">=", "datetime", 1], + "notModifiedSince" => ["modified_date", "<=", "datetime", 1], + "markedSince" => ["marked_date", ">=", "datetime", 1], + "notMarkedSince" => ["marked_date", "<=", "datetime", 1], + "folderShallow" => ["folder", "=", "int", 1], + "subscription" => ["subscription", "=", "int", 1], + "unread" => ["unread", "=", "bool", 1], + "starred" => ["starred", "=", "bool", 1], + ] as $m => list($col, $op, $type, $max)) { + if (!$context->$m()) { + // context is not being used + continue; + } elseif (is_array($context->$m)) { + if (!$context->$m) { + throw new Db\ExceptionInput("tooShort", ['field' => $m, 'action' => $this->caller(), 'min' => 1]); // must have at least one array element + } elseif (sizeof($context->$m) > $max) { + throw new Db\ExceptionInput("tooLong", ['field' => $m, 'action' => $this->caller(), 'max' => $max]); // @codeCoverageIgnore + } + list($clause, $types, $values) = $this->generateIn($context->$m, $type); + $q->setWhere("{$colDefs[$col]} $op ($clause)", $types, $values); + } else { + $q->setWhere("{$colDefs[$col]} $op ?", $type, $context->$m); } - list($inParams, $inTypes) = $this->generateIn($context->editions, "int"); - $q->setWhere("latest_editions.edition in ($inParams)", $inTypes, $context->editions); - } elseif ($context->articles()) { - // if multiple specific articles have been requested, filter against the list - if (!$context->articles) { - throw new Db\ExceptionInput("tooShort", ['field' => "articles", 'action' => $this->caller(), 'min' => 1]); // must have at least one array element - } elseif (sizeof($context->articles) > self::LIMIT_ARTICLES) { - throw new Db\ExceptionInput("tooLong", ['field' => "articles", 'action' => $this->caller(), 'max' => self::LIMIT_ARTICLES]); // @codeCoverageIgnore - } - list($inParams, $inTypes) = $this->generateIn($context->articles, "int"); - $q->setWhere("arsse_articles.id in ($inParams)", $inTypes, $context->articles); } - // filter based on label by ID or name + // handle complex context options if ($context->labelled()) { // any label (true) or no label (false) $isOrIsNot = (!$context->labelled ? "is" : "is not"); $q->setWhere("arsse_labels.id $isOrIsNot null"); - } elseif ($context->label() || $context->labelName()) { - // specific label ID or name - if ($context->label()) { - $id = $this->labelValidateId($user, $context->label, false)['id']; - } else { - $id = $this->labelValidateId($user, $context->labelName, true)['id']; - } - $q->setWhere("arsse_labels.id = ?", "int", $id); } - // filter based on article or edition offset - if ($context->oldestArticle()) { - $q->setWhere("arsse_articles.id >= ?", "int", $context->oldestArticle); + if ($context->label()) { + // label ID (label names are dereferenced during input validation above) + $q->setWhere("arsse_labels.id = ?", "int", $context->label); } - if ($context->latestArticle()) { - $q->setWhere("arsse_articles.id <= ?", "int", $context->latestArticle); - } - if ($context->oldestEdition()) { - $q->setWhere("latest_editions.edition >= ?", "int", $context->oldestEdition); - } - if ($context->latestEdition()) { - $q->setWhere("latest_editions.edition <= ?", "int", $context->latestEdition); - } - // filter based on time at which an article was changed by feed updates (modified), or by user action (marked) - if ($context->modifiedSince()) { - $q->setWhere("arsse_articles.modified >= ?", "datetime", $context->modifiedSince); - } - if ($context->notModifiedSince()) { - $q->setWhere("arsse_articles.modified <= ?", "datetime", $context->notModifiedSince); - } - if ($context->markedSince()) { - $q->setWhere($colDefs['marked_date']." >= ?", "datetime", $context->markedSince); - } - if ($context->notMarkedSince()) { - $q->setWhere($colDefs['marked_date']." <= ?", "datetime", $context->notMarkedSince); - } - // filter for un/read and un/starred status if specified - if ($context->unread()) { - $q->setWhere("coalesce(arsse_marks.read,0) = ?", "bool", !$context->unread); - } - if ($context->starred()) { - $q->setWhere("coalesce(arsse_marks.starred,0) = ?", "bool", $context->starred); - } - // filter based on whether the article has a note if ($context->annotated()) { $comp = ($context->annotated) ? "<>" : "="; $q->setWhere("coalesce(arsse_marks.note,'') $comp ''"); } - // filter based on search terms - if ($context->searchTerms()) { - if (!$context->searchTerms) { - throw new Db\ExceptionInput("tooShort", ['field' => "searchTerms", 'action' => $this->caller(), 'min' => 1]); // must have at least one array element - } elseif (sizeof($context->searchTerms) > self::LIMIT_TERMS) { - throw new Db\ExceptionInput("tooLong", ['field' => "searchTerms", 'action' => $this->caller(), 'max' => self::LIMIT_TERMS]); - } - $q->setWhere(...$this->generateSearch($context->searchTerms, ["arsse_articles.title", "arsse_articles.content"])); + if ($context->folder()) { + // add a common table expression to list the folder and its children so that we select from the entire subtree + $q->setCTE("folders(folder)", "SELECT ? union select id from arsse_folders join folders on parent = folder", "int", $context->folder); + // limit subscriptions to the listed folders + $q->setWhere("arsse_subscriptions.folder in (select folder from folders)"); } - // filter based on search terms in note - if ($context->annotationTerms()) { - if (!$context->annotationTerms) { - throw new Db\ExceptionInput("tooShort", ['field' => "annotationTerms", 'action' => $this->caller(), 'min' => 1]); // must have at least one array element - } elseif (sizeof($context->annotationTerms) > self::LIMIT_TERMS) { - throw new Db\ExceptionInput("tooLong", ['field' => "annotationTerms", 'action' => $this->caller(), 'max' => self::LIMIT_TERMS]); + // handle text-matching context options + foreach ([ + "titleTerms" => [10, ["arsse_articles.title"]], + "searchTerms" => [20, ["arsse_articles.title", "arsse_articles.content"]], + "authorTerms" => [10, ["arsse_articles.author"]], + "annotationTerms" => [20, ["arsse_marks.note"]], + ] as $m => list($max, $cols)) { + if (!$context->$m()) { + continue; + } elseif (!$context->$m) { + throw new Db\ExceptionInput("tooShort", ['field' => $m, 'action' => $this->caller(), 'min' => 1]); // must have at least one array element + } elseif (sizeof($context->$m) > $max) { + throw new Db\ExceptionInput("tooLong", ['field' => $m, 'action' => $this->caller(), 'max' => $max]); } - $q->setWhere(...$this->generateSearch($context->annotationTerms, ["arsse_marks.note"])); + $q->setWhere(...$this->generateSearch($context->$m, $cols)); } // return the query return $q; @@ -1306,7 +1285,7 @@ class Database { * * @param string $user The user whose articles are to be listed * @param Context $context The search context - * @param array $cols The columns to return in the result set, any of: id, edition, url, title, author, content, guid, fingerprint, subscription, feed, starred, unread, note, published_date, edited_date, modified_date, marked_date, subscription_title, media_url, media_type + * @param array $cols The columns to return in the result set, any of: id, edition, url, title, author, content, guid, fingerprint, folder, subscription, feed, starred, unread, note, published_date, edited_date, modified_date, marked_date, subscription_title, media_url, media_type */ public function articleList(string $user, Context $context = null, array $fields = ["id"]): Db\Result { if (!Arsse::$user->authorize($user, __FUNCTION__)) { diff --git a/tests/cases/Database/Base.php b/tests/cases/Database/Base.php index b40056e2..219d4c02 100644 --- a/tests/cases/Database/Base.php +++ b/tests/cases/Database/Base.php @@ -66,7 +66,7 @@ abstract class Base extends \JKingWeb\Arsse\Test\AbstractTest { public function setUp() { // get the name of the test's test series - $this->series = $this->findTraitofTest($this->getName()); + $this->series = $this->findTraitofTest($this->getName(false)); static::clearData(); static::setConf(); if (strlen(static::$failureReason)) { @@ -88,7 +88,7 @@ abstract class Base extends \JKingWeb\Arsse\Test\AbstractTest { public function tearDown() { // call the series-specific teardown method - $this->series = $this->findTraitofTest($this->getName()); + $this->series = $this->findTraitofTest($this->getName(false)); $tearDown = "tearDown".$this->series; $this->$tearDown(); // clean up diff --git a/tests/cases/Database/SeriesArticle.php b/tests/cases/Database/SeriesArticle.php index 80114c4d..85300993 100644 --- a/tests/cases/Database/SeriesArticle.php +++ b/tests/cases/Database/SeriesArticle.php @@ -114,10 +114,10 @@ trait SeriesArticle { [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,null,null,null,null,null,"","","","2010-01-01T00:00:00Z"], - [5,3,null,null,null,null,null,null,null,"","","","2000-01-01T00:00:00Z"], - [6,3,null,null,null,null,null,null,null,"","","","2010-01-01T00:00:00Z"], - [7,4,null,null,null,null,null,null,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"], @@ -414,94 +414,76 @@ trait SeriesArticle { $this->assertEquals($exp, Arsse::$db->editionArticle(...range(1, 1001))); } - public function testListArticlesCheckingContext() { - $compareIds = function(array $exp, Context $c) { - $ids = array_column($ids = Arsse::$db->articleList("john.doe@example.com", $c)->getAll(), "id"); - sort($ids); - sort($exp); - $this->assertEquals($exp, $ids); - }; - // get all items for user - $exp = [1,2,3,4,5,6,7,8,19,20]; - $compareIds($exp, new Context); - $compareIds($exp, (new Context)->articles(range(1, Database::LIMIT_ARTICLES * 3))); - // get items from a folder tree - $compareIds([5,6,7,8], (new Context)->folder(1)); - // get items from a leaf folder - $compareIds([7,8], (new Context)->folder(6)); - // get items from a non-leaf folder without descending - $compareIds([1,2,3,4], (new Context)->folderShallow(0)); - $compareIds([5,6], (new Context)->folderShallow(1)); - // get items from a single subscription - $exp = [19,20]; - $compareIds($exp, (new Context)->subscription(5)); - // get un/read items from a single subscription - $compareIds([20], (new Context)->subscription(5)->unread(true)); - $compareIds([19], (new Context)->subscription(5)->unread(false)); - // get starred articles - $compareIds([1,20], (new Context)->starred(true)); - $compareIds([2,3,4,5,6,7,8,19], (new Context)->starred(false)); - $compareIds([1], (new Context)->starred(true)->unread(false)); - $compareIds([], (new Context)->starred(true)->unread(false)->subscription(5)); - // get items relative to edition - $compareIds([19], (new Context)->subscription(5)->latestEdition(999)); - $compareIds([19], (new Context)->subscription(5)->latestEdition(19)); - $compareIds([20], (new Context)->subscription(5)->oldestEdition(999)); - $compareIds([20], (new Context)->subscription(5)->oldestEdition(1001)); - // get items relative to article ID - $compareIds([1,2,3], (new Context)->latestArticle(3)); - $compareIds([19,20], (new Context)->oldestArticle(19)); - // get items relative to (feed) modification date - $exp = [2,4,6,8,20]; - $compareIds($exp, (new Context)->modifiedSince("2005-01-01T00:00:00Z")); - $compareIds($exp, (new Context)->modifiedSince("2010-01-01T00:00:00Z")); - $exp = [1,3,5,7,19]; - $compareIds($exp, (new Context)->notModifiedSince("2005-01-01T00:00:00Z")); - $compareIds($exp, (new Context)->notModifiedSince("2000-01-01T00:00:00Z")); - // get items relative to (user) modification date (both marks and labels apply) - $compareIds([8,19], (new Context)->markedSince("2014-01-01T00:00:00Z")); - $compareIds([2,4,6,8,19,20], (new Context)->markedSince("2010-01-01T00:00:00Z")); - $compareIds([1,2,3,4,5,6,7,20], (new Context)->notMarkedSince("2014-01-01T00:00:00Z")); - $compareIds([1,3,5,7], (new Context)->notMarkedSince("2005-01-01T00:00:00Z")); - // paged results - $compareIds([1], (new Context)->limit(1)); - $compareIds([2], (new Context)->limit(1)->oldestEdition(1+1)); - $compareIds([3], (new Context)->limit(1)->oldestEdition(2+1)); - $compareIds([4,5], (new Context)->limit(2)->oldestEdition(3+1)); - // reversed results - $compareIds([20], (new Context)->reverse(true)->limit(1)); - $compareIds([19], (new Context)->reverse(true)->limit(1)->latestEdition(1001-1)); - $compareIds([8], (new Context)->reverse(true)->limit(1)->latestEdition(19-1)); - $compareIds([7,6], (new Context)->reverse(true)->limit(2)->latestEdition(8-1)); - // get articles by label ID - $compareIds([1,19], (new Context)->label(1)); - $compareIds([1,5,20], (new Context)->label(2)); - // get articles by label name - $compareIds([1,19], (new Context)->labelName("Interesting")); - $compareIds([1,5,20], (new Context)->labelName("Fascinating")); - // get articles with any or no label - $compareIds([1,5,8,19,20], (new Context)->labelled(true)); - $compareIds([2,3,4,6,7], (new Context)->labelled(false)); - // get a specific article or edition - $compareIds([20], (new Context)->article(20)); - $compareIds([20], (new Context)->edition(1001)); - // get multiple specific articles or editions - $compareIds([1,20], (new Context)->articles([1,20,50])); - $compareIds([1,20], (new Context)->editions([1,1001,50])); - // get articles base on whether or not they have notes - $compareIds([1,3,4,5,6,7,8,19,20], (new Context)->annotated(false)); - $compareIds([2], (new Context)->annotated(true)); - // get specific starred articles - $compareIds([1], (new Context)->articles([1,2,3])->starred(true)); - $compareIds([2,3], (new Context)->articles([1,2,3])->starred(false)); - // get items that match search terms - $compareIds([1,2,3], (new Context)->searchTerms(["Article"])); - $compareIds([1], (new Context)->searchTerms(["one", "first"])); - // get items that match search terms in note - $compareIds([2], (new Context)->annotationTerms(["some"])); - $compareIds([2], (new Context)->annotationTerms(["some", "note"])); - $compareIds([2], (new Context)->annotationTerms(["some note"])); - $compareIds([], (new Context)->annotationTerms(["some", "sauce"])); + /** @dataProvider provideContextMatches */ + public function testListArticlesCheckingContext(Context $c, array $exp) { + $ids = array_column($ids = Arsse::$db->articleList("john.doe@example.com", $c)->getAll(), "id"); + sort($ids); + sort($exp); + $this->assertEquals($exp, $ids); + } + + public function provideContextMatches() { + return [ + "Blank context" => [new Context, [1,2,3,4,5,6,7,8,19,20]], + "Folder tree" => [(new Context)->folder(1), [5,6,7,8]], + "Leaf folder" => [(new Context)->folder(6), [7,8]], + "Root folder only" => [(new Context)->folderShallow(0), [1,2,3,4]], + "Shallow folder" => [(new Context)->folderShallow(1), [5,6]], + "Subscription" => [(new Context)->subscription(5), [19,20]], + "Unread" => [(new Context)->subscription(5)->unread(true), [20]], + "Read" => [(new Context)->subscription(5)->unread(false), [19]], + "Starred" => [(new Context)->starred(true), [1,20]], + "Unstarred" => [(new Context)->starred(false), [2,3,4,5,6,7,8,19]], + "Starred and Read" => [(new Context)->starred(true)->unread(false), [1]], + "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]], + "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]], + "Not after edition 19" => [(new Context)->subscription(5)->latestEdition(19), [19]], + "Not before edition 999" => [(new Context)->subscription(5)->oldestEdition(999), [20]], + "Not before edition 1001" => [(new Context)->subscription(5)->oldestEdition(1001), [20]], + "Not after article 3" => [(new Context)->latestArticle(3), [1,2,3]], + "Not before article 19" => [(new Context)->oldestArticle(19), [19,20]], + "Modified by author since 2005" => [(new Context)->modifiedSince("2005-01-01T00:00:00Z"), [2,4,6,8,20]], + "Modified by author since 2010" => [(new Context)->modifiedSince("2010-01-01T00:00:00Z"), [2,4,6,8,20]], + "Not modified by author since 2005" => [(new Context)->notModifiedSince("2005-01-01T00:00:00Z"), [1,3,5,7,19]], + "Not modified by author since 2000" => [(new Context)->notModifiedSince("2000-01-01T00:00:00Z"), [1,3,5,7,19]], + "Marked or labelled since 2014" => [(new Context)->markedSince("2014-01-01T00:00:00Z"), [8,19]], + "Marked or labelled since 2010" => [(new Context)->markedSince("2010-01-01T00:00:00Z"), [2,4,6,8,19,20]], + "Not marked or labelled since 2014" => [(new Context)->notMarkedSince("2014-01-01T00:00:00Z"), [1,2,3,4,5,6,7,20]], + "Not marked or labelled since 2005" => [(new Context)->notMarkedSince("2005-01-01T00:00:00Z"), [1,3,5,7]], + "Paged results" => [(new Context)->limit(2)->oldestEdition(4), [4,5]], + "Reversed paged results" => [(new Context)->limit(2)->latestEdition(7)->reverse(true), [7,6]], + "With label ID 1" => [(new Context)->label(1), [1,19]], + "With label ID 2" => [(new Context)->label(2), [1,5,20]], + "With label 'Interesting'" => [(new Context)->labelName("Interesting"), [1,19]], + "With label 'Fascinating'" => [(new Context)->labelName("Fascinating"), [1,5,20]], + "Article ID 20" => [(new Context)->article(20), [20]], + "Edition ID 1001" => [(new Context)->edition(1001), [20]], + "Multiple articles" => [(new Context)->articles([1,20,50]), [1,20]], + "Multiple starred articles" => [(new Context)->articles([1,2,3])->starred(true), [1]], + "Multiple unstarred articles" => [(new Context)->articles([1,2,3])->starred(false), [2,3]], + "Multiple articles" => [(new Context)->articles([1,20,50]), [1,20]], + "Multiple editions" => [(new Context)->editions([1,1001,50]), [1,20]], + "150 articles" => [(new Context)->articles(range(1, Database::LIMIT_ARTICLES * 3)), [1,2,3,4,5,6,7,8,19,20]], + "Search title or content 1" => [(new Context)->searchTerms(["Article"]), [1,2,3]], + "Search title or content 2" => [(new Context)->searchTerms(["one", "first"]), [1]], + "Search title or content 3" => [(new Context)->searchTerms(["one first"]), []], + "Search title 1" => [(new Context)->titleTerms(["two"]), [2]], + "Search title 2" => [(new Context)->titleTerms(["title two"]), [2]], + "Search title 3" => [(new Context)->titleTerms(["two", "title"]), [2]], + "Search title 4" => [(new Context)->titleTerms(["two title"]), []], + "Search note 1" => [(new Context)->annotationTerms(["some"]), [2]], + "Search note 2" => [(new Context)->annotationTerms(["some Note"]), [2]], + "Search note 3" => [(new Context)->annotationTerms(["note", "some"]), [2]], + "Search note 4" => [(new Context)->annotationTerms(["some", "sauce"]), []], + "Search author 1" => [(new Context)->authorTerms(["doe"]), [4,5,6,7]], + "Search author 2" => [(new Context)->authorTerms(["jane doe"]), [6,7]], + "Search author 3" => [(new Context)->authorTerms(["doe", "jane"]), [6,7]], + "Search author 4" => [(new Context)->authorTerms(["doe jane"]), []], + ]; } public function testListArticlesOfAMissingFolder() { From b950ac066f153fc4db108c0da62a872183000d9d Mon Sep 17 00:00:00 2001 From: "J. King" Date: Mon, 25 Feb 2019 22:41:12 -0500 Subject: [PATCH 017/142] Restrict options in not-context and hopefully make it easier to use --- lib/Context/Context.php | 101 +++++++++++++++++ .../ExclusionContext.php} | 102 ++---------------- lib/Database.php | 28 ++++- lib/Misc/Query.php | 26 ++++- lib/REST/NextCloudNews/V1_2.php | 2 +- lib/REST/TinyTinyRSS/API.php | 2 +- tests/cases/Database/SeriesArticle.php | 2 +- tests/cases/Database/SeriesLabel.php | 2 +- tests/cases/Misc/TestContext.php | 13 ++- tests/cases/REST/NextCloudNews/TestV1_2.php | 2 +- tests/cases/REST/TinyTinyRSS/TestAPI.php | 2 +- 11 files changed, 170 insertions(+), 112 deletions(-) create mode 100644 lib/Context/Context.php rename lib/{Misc/Context.php => Context/ExclusionContext.php} (56%) diff --git a/lib/Context/Context.php b/lib/Context/Context.php new file mode 100644 index 00000000..d9977735 --- /dev/null +++ b/lib/Context/Context.php @@ -0,0 +1,101 @@ +not = new ExclusionContext; + } + + public function __clone() { + // clone the exclusion context as well + $this->not = clone $this->not; + } + + public function reverse(bool $spec = null) { + return $this->act(__FUNCTION__, func_num_args(), $spec); + } + + public function limit(int $spec = null) { + return $this->act(__FUNCTION__, func_num_args(), $spec); + } + + public function offset(int $spec = null) { + return $this->act(__FUNCTION__, func_num_args(), $spec); + } + + public function unread(bool $spec = null) { + return $this->act(__FUNCTION__, func_num_args(), $spec); + } + + public function starred(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); + } + + public function annotated(bool $spec = null) { + return $this->act(__FUNCTION__, func_num_args(), $spec); + } + + public function latestArticle(int $spec = null) { + return $this->act(__FUNCTION__, func_num_args(), $spec); + } + + public function oldestArticle(int $spec = null) { + return $this->act(__FUNCTION__, func_num_args(), $spec); + } + + public function latestEdition(int $spec = null) { + return $this->act(__FUNCTION__, func_num_args(), $spec); + } + + public function oldestEdition(int $spec = null) { + return $this->act(__FUNCTION__, func_num_args(), $spec); + } + + public function modifiedSince($spec = null) { + $spec = Date::normalize($spec); + return $this->act(__FUNCTION__, func_num_args(), $spec); + } + + public function notModifiedSince($spec = null) { + $spec = Date::normalize($spec); + return $this->act(__FUNCTION__, func_num_args(), $spec); + } + + public function markedSince($spec = null) { + $spec = Date::normalize($spec); + return $this->act(__FUNCTION__, func_num_args(), $spec); + } + + public function notMarkedSince($spec = null) { + $spec = Date::normalize($spec); + return $this->act(__FUNCTION__, func_num_args(), $spec); + } +} diff --git a/lib/Misc/Context.php b/lib/Context/ExclusionContext.php similarity index 56% rename from lib/Misc/Context.php rename to lib/Context/ExclusionContext.php index 9263fa1d..5a2a9cf6 100644 --- a/lib/Misc/Context.php +++ b/lib/Context/ExclusionContext.php @@ -4,49 +4,27 @@ * See LICENSE and AUTHORS files for details */ declare(strict_types=1); -namespace JKingWeb\Arsse\Misc; +namespace JKingWeb\Arsse\Context; -use JKingWeb\Arsse\Misc\Date; use JKingWeb\Arsse\Misc\ValueInfo; -class Context { - public $not = null; - public $reverse = false; - public $limit = 0; - public $offset = 0; +class ExclusionContext { public $folder; public $folderShallow; public $subscription; - public $oldestArticle; - public $latestArticle; - public $oldestEdition; - public $latestEdition; - public $unread = null; - public $starred = null; - public $modifiedSince; - public $notModifiedSince; - public $markedSince; - public $notMarkedSince; public $edition; public $article; public $editions; public $articles; public $label; public $labelName; - public $labelled = null; - public $annotated = null; - public $annotationTerms = null; - public $searchTerms = null; - public $titleTerms = null; - public $authorTerms = null; + public $annotationTerms; + public $searchTerms; + public $titleTerms; + public $authorTerms; protected $props = []; - public function __clone() { - // clone the negation context, if any - $this->not = $this->not ? clone $this->not : null; - } - protected function act(string $prop, int $set, $value) { if ($set) { if (is_null($value)) { @@ -87,18 +65,6 @@ class Context { return array_values($spec); } - public function reverse(bool $spec = null) { - return $this->act(__FUNCTION__, func_num_args(), $spec); - } - - public function limit(int $spec = null) { - return $this->act(__FUNCTION__, func_num_args(), $spec); - } - - public function offset(int $spec = null) { - return $this->act(__FUNCTION__, func_num_args(), $spec); - } - public function folder(int $spec = null) { return $this->act(__FUNCTION__, func_num_args(), $spec); } @@ -111,50 +77,6 @@ class Context { return $this->act(__FUNCTION__, func_num_args(), $spec); } - public function latestArticle(int $spec = null) { - return $this->act(__FUNCTION__, func_num_args(), $spec); - } - - public function oldestArticle(int $spec = null) { - return $this->act(__FUNCTION__, func_num_args(), $spec); - } - - public function latestEdition(int $spec = null) { - return $this->act(__FUNCTION__, func_num_args(), $spec); - } - - public function oldestEdition(int $spec = null) { - return $this->act(__FUNCTION__, func_num_args(), $spec); - } - - public function unread(bool $spec = null) { - return $this->act(__FUNCTION__, func_num_args(), $spec); - } - - public function starred(bool $spec = null) { - return $this->act(__FUNCTION__, func_num_args(), $spec); - } - - public function modifiedSince($spec = null) { - $spec = Date::normalize($spec); - return $this->act(__FUNCTION__, func_num_args(), $spec); - } - - public function notModifiedSince($spec = null) { - $spec = Date::normalize($spec); - return $this->act(__FUNCTION__, func_num_args(), $spec); - } - - public function markedSince($spec = null) { - $spec = Date::normalize($spec); - return $this->act(__FUNCTION__, func_num_args(), $spec); - } - - public function notMarkedSince($spec = null) { - $spec = Date::normalize($spec); - return $this->act(__FUNCTION__, func_num_args(), $spec); - } - public function edition(int $spec = null) { return $this->act(__FUNCTION__, func_num_args(), $spec); } @@ -185,14 +107,6 @@ class Context { return $this->act(__FUNCTION__, func_num_args(), $spec); } - public function labelled(bool $spec = null) { - return $this->act(__FUNCTION__, func_num_args(), $spec); - } - - public function annotated(bool $spec = null) { - return $this->act(__FUNCTION__, func_num_args(), $spec); - } - public function annotationTerms(array $spec = null) { if (isset($spec)) { $spec = $this->cleanStringArray($spec); @@ -220,8 +134,4 @@ class Context { } return $this->act(__FUNCTION__, func_num_args(), $spec); } - - public function not(self $spec = null) { - return $this->act(__FUNCTION__, func_num_args(), $spec); - } } diff --git a/lib/Database.php b/lib/Database.php index 353e7b93..6f7a9b42 100644 --- a/lib/Database.php +++ b/lib/Database.php @@ -9,7 +9,8 @@ namespace JKingWeb\Arsse; use JKingWeb\DrUUID\UUID; use JKingWeb\Arsse\Db\Statement; use JKingWeb\Arsse\Misc\Query; -use JKingWeb\Arsse\Misc\Context; +use JKingWeb\Arsse\Context\Context; +use JKingWeb\Arsse\Context\ExclusionContext; use JKingWeb\Arsse\Misc\Date; use JKingWeb\Arsse\Misc\ValueInfo; @@ -1178,8 +1179,9 @@ class Database { // if there are no output columns requested we're getting a count and should not group, but otherwise we should $q->setGroup("arsse_articles.id", "arsse_marks.note", "arsse_enclosures.url", "arsse_enclosures.type", "arsse_subscriptions.title", "arsse_feeds.title", "arsse_subscriptions.id", "arsse_marks.modified", "arsse_label_members.modified", "arsse_marks.read", "arsse_marks.starred", "latest_editions.edition"); } + $excContext = new ExclusionContext; // handle the simple context options - foreach ([ + $options = [ // each context array consists of a column identifier (see $colDefs above), a comparison operator, a data type, and an upper bound if the value is an array "edition" => ["edition", "=", "int", 1], "editions" => ["edition", "in", "int", self::LIMIT_ARTICLES], @@ -1197,7 +1199,8 @@ class Database { "subscription" => ["subscription", "=", "int", 1], "unread" => ["unread", "=", "bool", 1], "starred" => ["starred", "=", "bool", 1], - ] as $m => list($col, $op, $type, $max)) { + ]; + foreach ($options as $m => list($col, $op, $type, $max)) { if (!$context->$m()) { // context is not being used continue; @@ -1213,6 +1216,25 @@ class Database { $q->setWhere("{$colDefs[$col]} $op ?", $type, $context->$m); } } + if ($context->not != $excContext) { + // further handle exclusionary options if specified + foreach ($options as $m => list($col, $op, $type, $max)) { + if (!method_exists($context->not, $m) || !$context->not->$m()) { + // context option is not being used + continue; + } elseif (is_array($context->not->$m)) { + if (!$context->not->$m) { + // for exclusions we don't care if the array is empty + } elseif (sizeof($context->not->$m) > $max) { + throw new Db\ExceptionInput("tooLong", ['field' => $m, 'action' => $this->caller(), 'max' => $max]); // @codeCoverageIgnore + } + list($clause, $types, $values) = $this->generateIn($context->$m, $type); + $q->setWhereNot("{$colDefs[$col]} $op ($clause)", $types, $values); + } else { + $q->setWhereNot("{$colDefs[$col]} $op ?", $type, $context->$m); + } + } + } // handle complex context options if ($context->labelled()) { // any label (true) or no label (false) diff --git a/lib/Misc/Query.php b/lib/Misc/Query.php index d7a2c7f7..458b7ed6 100644 --- a/lib/Misc/Query.php +++ b/lib/Misc/Query.php @@ -20,6 +20,9 @@ class Query { protected $qWhere = []; // WHERE clause components protected $tWhere = []; // WHERE clause type bindings protected $vWhere = []; // WHERE clause binding values + protected $qWhereNot = []; // WHERE NOT clause components + protected $tWhereNot = []; // WHERE NOT clause type bindings + protected $vWhereNot = []; // WHERE NOT clause binding values protected $group = []; // GROUP BY clause components protected $order = []; // ORDER BY clause components protected $limit = 0; @@ -69,6 +72,15 @@ class Query { return true; } + public function setWhereNot(string $where, $types = null, $values = null): bool { + $this->qWhereNot[] = $where; + if (!is_null($types)) { + $this->tWhereNot[] = $types; + $this->vWhereNot[] = $values; + } + return true; + } + public function setGroup(string ...$column): bool { foreach ($column as $col) { $this->group[] = $col; @@ -94,7 +106,7 @@ class Query { public function pushCTE(string $tableSpec, string $join = ''): bool { // this function takes the query body and converts it to a common table expression, putting it at the bottom of the existing CTE stack // all WHERE, ORDER BY, and LIMIT parts belong to the new CTE and are removed from the main query - $this->setCTE($tableSpec, $this->buildQueryBody(), [$this->tBody, $this->tWhere], [$this->vBody, $this->vWhere]); + $this->setCTE($tableSpec, $this->buildQueryBody(), [$this->tBody, $this->tWhere, $this->tWhereNot], [$this->vBody, $this->vWhere, $this->vWhereNot]); $this->jCTE = []; $this->tBody = []; $this->vBody = []; @@ -129,11 +141,11 @@ class Query { } public function getTypes(): array { - return [$this->tCTE, $this->tBody, $this->tJoin, $this->tWhere]; + return [$this->tCTE, $this->tBody, $this->tJoin, $this->tWhere, $this->tWhereNot]; } public function getValues(): array { - return [$this->vCTE, $this->vBody, $this->vJoin, $this->vWhere]; + return [$this->vCTE, $this->vBody, $this->vJoin, $this->vWhere, $this->vWhereNot]; } public function getJoinTypes(): array { @@ -173,8 +185,12 @@ class Query { $out .= " ".implode(" ", $this->qJoin); } // add any WHERE terms - if (sizeof($this->qWhere)) { - $out .= " WHERE ".implode(" AND ", $this->qWhere); + if (sizeof($this->qWhere) || sizeof($this->qWhereNot)) { + $where = implode(" AND ", $this->qWhere); + $whereNot = implode(" OR ", $this->qWhereNot); + $whereNot = strlen($whereNot) ? "NOT ($whereNot)" : ""; + $where = implode(" AND ", array_filter([$where, $whereNot])); + $out .= " WHERE $where"; } // add any GROUP BY terms if (sizeof($this->group)) { diff --git a/lib/REST/NextCloudNews/V1_2.php b/lib/REST/NextCloudNews/V1_2.php index 84beda3e..7f4301c8 100644 --- a/lib/REST/NextCloudNews/V1_2.php +++ b/lib/REST/NextCloudNews/V1_2.php @@ -10,7 +10,7 @@ use JKingWeb\Arsse\Arsse; use JKingWeb\Arsse\Database; use JKingWeb\Arsse\User; use JKingWeb\Arsse\Service; -use JKingWeb\Arsse\Misc\Context; +use JKingWeb\Arsse\Context\Context; use JKingWeb\Arsse\Misc\ValueInfo; use JKingWeb\Arsse\AbstractException; use JKingWeb\Arsse\Db\ExceptionInput; diff --git a/lib/REST/TinyTinyRSS/API.php b/lib/REST/TinyTinyRSS/API.php index 4ddea6fa..f126324c 100644 --- a/lib/REST/TinyTinyRSS/API.php +++ b/lib/REST/TinyTinyRSS/API.php @@ -12,7 +12,7 @@ use JKingWeb\Arsse\Database; use JKingWeb\Arsse\User; use JKingWeb\Arsse\Service; use JKingWeb\Arsse\Misc\Date; -use JKingWeb\Arsse\Misc\Context; +use JKingWeb\Arsse\Context\Context; use JKingWeb\Arsse\Misc\ValueInfo; use JKingWeb\Arsse\AbstractException; use JKingWeb\Arsse\ExceptionType; diff --git a/tests/cases/Database/SeriesArticle.php b/tests/cases/Database/SeriesArticle.php index 85300993..37a53114 100644 --- a/tests/cases/Database/SeriesArticle.php +++ b/tests/cases/Database/SeriesArticle.php @@ -8,7 +8,7 @@ namespace JKingWeb\Arsse\TestCase\Database; use JKingWeb\Arsse\Database; use JKingWeb\Arsse\Arsse; -use JKingWeb\Arsse\Misc\Context; +use JKingWeb\Arsse\Context\Context; use JKingWeb\Arsse\Misc\Date; use Phake; diff --git a/tests/cases/Database/SeriesLabel.php b/tests/cases/Database/SeriesLabel.php index 8347ce53..e6fc426e 100644 --- a/tests/cases/Database/SeriesLabel.php +++ b/tests/cases/Database/SeriesLabel.php @@ -7,7 +7,7 @@ declare(strict_types=1); namespace JKingWeb\Arsse\TestCase\Database; use JKingWeb\Arsse\Arsse; -use JKingWeb\Arsse\Misc\Context; +use JKingWeb\Arsse\Context\Context; use JKingWeb\Arsse\Misc\Date; use Phake; diff --git a/tests/cases/Misc/TestContext.php b/tests/cases/Misc/TestContext.php index 12a99693..db088b4f 100644 --- a/tests/cases/Misc/TestContext.php +++ b/tests/cases/Misc/TestContext.php @@ -6,10 +6,10 @@ declare(strict_types=1); namespace JKingWeb\Arsse\TestCase\Misc; -use JKingWeb\Arsse\Misc\Context; +use JKingWeb\Arsse\Context\Context; use JKingWeb\Arsse\Misc\ValueInfo; -/** @covers \JKingWeb\Arsse\Misc\Context */ +/** @covers \JKingWeb\Arsse\Context\Context */ class TestContext extends \JKingWeb\Arsse\Test\AbstractTest { public function testVerifyInitialState() { $c = new Context; @@ -96,4 +96,13 @@ class TestContext extends \JKingWeb\Arsse\Test\AbstractTest { $this->assertSame($out, $c->$method($in)->$method, "Context method $method did not return the expected results"); } } + + public function testCloneAContext() { + $c1 = new Context; + $c2 = clone $c1; + $this->assertEquals($c1, $c2); + $this->assertEquals($c1->not, $c2->not); + $this->assertNotSame($c1, $c2); + $this->assertNotSame($c1->not, $c2->not); + } } diff --git a/tests/cases/REST/NextCloudNews/TestV1_2.php b/tests/cases/REST/NextCloudNews/TestV1_2.php index f35e21e5..664db4e1 100644 --- a/tests/cases/REST/NextCloudNews/TestV1_2.php +++ b/tests/cases/REST/NextCloudNews/TestV1_2.php @@ -13,7 +13,7 @@ use JKingWeb\Arsse\Database; use JKingWeb\Arsse\Service; use JKingWeb\Arsse\Test\Result; use JKingWeb\Arsse\Misc\Date; -use JKingWeb\Arsse\Misc\Context; +use JKingWeb\Arsse\Context\Context; use JKingWeb\Arsse\Db\ExceptionInput; use JKingWeb\Arsse\Db\Transaction; use JKingWeb\Arsse\REST\NextCloudNews\V1_2; diff --git a/tests/cases/REST/TinyTinyRSS/TestAPI.php b/tests/cases/REST/TinyTinyRSS/TestAPI.php index bf35a303..4b497a7f 100644 --- a/tests/cases/REST/TinyTinyRSS/TestAPI.php +++ b/tests/cases/REST/TinyTinyRSS/TestAPI.php @@ -14,7 +14,7 @@ use JKingWeb\Arsse\Service; use JKingWeb\Arsse\REST\Request; use JKingWeb\Arsse\Test\Result; use JKingWeb\Arsse\Misc\Date; -use JKingWeb\Arsse\Misc\Context; +use JKingWeb\Arsse\Context\Context; use JKingWeb\Arsse\Db\ExceptionInput; use JKingWeb\Arsse\Db\Transaction; use JKingWeb\Arsse\REST\TinyTinyRSS\API; From 18d52ea402785a6ca3eaa3f629a5f7bb1b7695f3 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Mon, 25 Feb 2019 23:37:14 -0500 Subject: [PATCH 018/142] Make exclusion contexts return their parent on change --- lib/Context/Context.php | 6 +++++- lib/Context/ExclusionContext.php | 20 +++++++++++++++++++- tests/cases/Misc/TestContext.php | 2 ++ 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/lib/Context/Context.php b/lib/Context/Context.php index d9977735..df45dc6d 100644 --- a/lib/Context/Context.php +++ b/lib/Context/Context.php @@ -27,7 +27,7 @@ class Context extends ExclusionContext { public $notMarkedSince; public function __construct() { - $this->not = new ExclusionContext; + $this->not = new ExclusionContext($this); } public function __clone() { @@ -35,6 +35,10 @@ class Context extends ExclusionContext { $this->not = clone $this->not; } + public function __destruct() { + unset($this->not); + } + public function reverse(bool $spec = null) { return $this->act(__FUNCTION__, func_num_args(), $spec); } diff --git a/lib/Context/ExclusionContext.php b/lib/Context/ExclusionContext.php index 5a2a9cf6..6a662f28 100644 --- a/lib/Context/ExclusionContext.php +++ b/lib/Context/ExclusionContext.php @@ -24,6 +24,24 @@ class ExclusionContext { public $authorTerms; protected $props = []; + protected $parent; + + public function __construct(self $c = null) { + $this->parent = $c; + } + + public function __clone() { + if ($this->parent) { + $p = debug_backtrace(\DEBUG_BACKTRACE_IGNORE_ARGS | \DEBUG_BACKTRACE_PROVIDE_OBJECT, 2)[1]['object'] ?? null; + if ($p instanceof self) { + $this->parent = $p; + } + } + } + + public function __destruct() { + unset($this->parent); + } protected function act(string $prop, int $set, $value) { if ($set) { @@ -34,7 +52,7 @@ class ExclusionContext { $this->props[$prop] = true; $this->$prop = $value; } - return $this; + return $this->parent ?? $this; } else { return isset($this->props[$prop]); } diff --git a/tests/cases/Misc/TestContext.php b/tests/cases/Misc/TestContext.php index db088b4f..d134c0fc 100644 --- a/tests/cases/Misc/TestContext.php +++ b/tests/cases/Misc/TestContext.php @@ -104,5 +104,7 @@ class TestContext extends \JKingWeb\Arsse\Test\AbstractTest { $this->assertEquals($c1->not, $c2->not); $this->assertNotSame($c1, $c2); $this->assertNotSame($c1->not, $c2->not); + $this->assertSame($c1, $c1->not->article(null)); + $this->assertSame($c2, $c2->not->article(null)); } } From 70443a52640450caed1e6dff81da35a57f3daf9c Mon Sep 17 00:00:00 2001 From: "J. King" Date: Mon, 25 Feb 2019 23:59:48 -0500 Subject: [PATCH 019/142] Make parent re-association on context clone more restrictive --- lib/Context/Context.php | 1 + lib/Context/ExclusionContext.php | 6 +++--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/lib/Context/Context.php b/lib/Context/Context.php index df45dc6d..4ae2e87a 100644 --- a/lib/Context/Context.php +++ b/lib/Context/Context.php @@ -9,6 +9,7 @@ namespace JKingWeb\Arsse\Context; use JKingWeb\Arsse\Misc\Date; class Context extends ExclusionContext { + /** @var ExclusionContext */ public $not; public $reverse = false; public $limit = 0; diff --git a/lib/Context/ExclusionContext.php b/lib/Context/ExclusionContext.php index 6a662f28..b2954e39 100644 --- a/lib/Context/ExclusionContext.php +++ b/lib/Context/ExclusionContext.php @@ -32,9 +32,9 @@ class ExclusionContext { public function __clone() { if ($this->parent) { - $p = debug_backtrace(\DEBUG_BACKTRACE_IGNORE_ARGS | \DEBUG_BACKTRACE_PROVIDE_OBJECT, 2)[1]['object'] ?? null; - if ($p instanceof self) { - $this->parent = $p; + $t = debug_backtrace(\DEBUG_BACKTRACE_IGNORE_ARGS | \DEBUG_BACKTRACE_PROVIDE_OBJECT, 2)[1]; + if (($t['object'] ?? null) instanceof self && $t['function'] === "__clone") { + $this->parent = $t['object']; } } } From 0dc82f64d5ae2764a2e8f4b95708790b27680345 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Tue, 26 Feb 2019 11:11:42 -0500 Subject: [PATCH 020/142] Allow ranges in exclusion contexts --- lib/Context/Context.php | 46 -------------- lib/Context/ExclusionContext.php | 45 ++++++++++++++ lib/Database.php | 85 +++++++++++++++----------- lib/Misc/Query.php | 3 + tests/cases/Database/SeriesArticle.php | 80 +++++++++++++----------- 5 files changed, 142 insertions(+), 117 deletions(-) diff --git a/lib/Context/Context.php b/lib/Context/Context.php index 4ae2e87a..922c535d 100644 --- a/lib/Context/Context.php +++ b/lib/Context/Context.php @@ -6,8 +6,6 @@ declare(strict_types=1); namespace JKingWeb\Arsse\Context; -use JKingWeb\Arsse\Misc\Date; - class Context extends ExclusionContext { /** @var ExclusionContext */ public $not; @@ -18,14 +16,6 @@ class Context extends ExclusionContext { public $starred; public $labelled; public $annotated; - public $oldestArticle; - public $latestArticle; - public $oldestEdition; - public $latestEdition; - public $modifiedSince; - public $notModifiedSince; - public $markedSince; - public $notMarkedSince; public function __construct() { $this->not = new ExclusionContext($this); @@ -67,40 +57,4 @@ class Context extends ExclusionContext { public function annotated(bool $spec = null) { return $this->act(__FUNCTION__, func_num_args(), $spec); } - - public function latestArticle(int $spec = null) { - return $this->act(__FUNCTION__, func_num_args(), $spec); - } - - public function oldestArticle(int $spec = null) { - return $this->act(__FUNCTION__, func_num_args(), $spec); - } - - public function latestEdition(int $spec = null) { - return $this->act(__FUNCTION__, func_num_args(), $spec); - } - - public function oldestEdition(int $spec = null) { - return $this->act(__FUNCTION__, func_num_args(), $spec); - } - - public function modifiedSince($spec = null) { - $spec = Date::normalize($spec); - return $this->act(__FUNCTION__, func_num_args(), $spec); - } - - public function notModifiedSince($spec = null) { - $spec = Date::normalize($spec); - return $this->act(__FUNCTION__, func_num_args(), $spec); - } - - public function markedSince($spec = null) { - $spec = Date::normalize($spec); - return $this->act(__FUNCTION__, func_num_args(), $spec); - } - - public function notMarkedSince($spec = null) { - $spec = Date::normalize($spec); - return $this->act(__FUNCTION__, func_num_args(), $spec); - } } diff --git a/lib/Context/ExclusionContext.php b/lib/Context/ExclusionContext.php index b2954e39..cfec246d 100644 --- a/lib/Context/ExclusionContext.php +++ b/lib/Context/ExclusionContext.php @@ -7,6 +7,7 @@ declare(strict_types=1); namespace JKingWeb\Arsse\Context; use JKingWeb\Arsse\Misc\ValueInfo; +use JKingWeb\Arsse\Misc\Date; class ExclusionContext { public $folder; @@ -22,6 +23,14 @@ class ExclusionContext { public $searchTerms; public $titleTerms; public $authorTerms; + public $oldestArticle; + public $latestArticle; + public $oldestEdition; + public $latestEdition; + public $modifiedSince; + public $notModifiedSince; + public $markedSince; + public $notMarkedSince; protected $props = []; protected $parent; @@ -152,4 +161,40 @@ class ExclusionContext { } return $this->act(__FUNCTION__, func_num_args(), $spec); } + + public function latestArticle(int $spec = null) { + return $this->act(__FUNCTION__, func_num_args(), $spec); + } + + public function oldestArticle(int $spec = null) { + return $this->act(__FUNCTION__, func_num_args(), $spec); + } + + public function latestEdition(int $spec = null) { + return $this->act(__FUNCTION__, func_num_args(), $spec); + } + + public function oldestEdition(int $spec = null) { + return $this->act(__FUNCTION__, func_num_args(), $spec); + } + + public function modifiedSince($spec = null) { + $spec = Date::normalize($spec); + return $this->act(__FUNCTION__, func_num_args(), $spec); + } + + public function notModifiedSince($spec = null) { + $spec = Date::normalize($spec); + return $this->act(__FUNCTION__, func_num_args(), $spec); + } + + public function markedSince($spec = null) { + $spec = Date::normalize($spec); + return $this->act(__FUNCTION__, func_num_args(), $spec); + } + + public function notMarkedSince($spec = null) { + $spec = Date::normalize($spec); + return $this->act(__FUNCTION__, func_num_args(), $spec); + } } diff --git a/lib/Database.php b/lib/Database.php index 6f7a9b42..13f49bff 100644 --- a/lib/Database.php +++ b/lib/Database.php @@ -1179,32 +1179,34 @@ class Database { // if there are no output columns requested we're getting a count and should not group, but otherwise we should $q->setGroup("arsse_articles.id", "arsse_marks.note", "arsse_enclosures.url", "arsse_enclosures.type", "arsse_subscriptions.title", "arsse_feeds.title", "arsse_subscriptions.id", "arsse_marks.modified", "arsse_label_members.modified", "arsse_marks.read", "arsse_marks.starred", "latest_editions.edition"); } - $excContext = new ExclusionContext; // handle the simple context options $options = [ - // each context array consists of a column identifier (see $colDefs above), a comparison operator, a data type, and an upper bound if the value is an array - "edition" => ["edition", "=", "int", 1], - "editions" => ["edition", "in", "int", self::LIMIT_ARTICLES], - "article" => ["id", "=", "int", 1], - "articles" => ["id", "in", "int", self::LIMIT_ARTICLES], - "oldestArticle" => ["id", ">=", "int", 1], - "latestArticle" => ["id", "<=", "int", 1], - "oldestEdition" => ["edition", ">=", "int", 1], - "latestEdition" => ["edition", "<=", "int", 1], - "modifiedSince" => ["modified_date", ">=", "datetime", 1], - "notModifiedSince" => ["modified_date", "<=", "datetime", 1], - "markedSince" => ["marked_date", ">=", "datetime", 1], - "notMarkedSince" => ["marked_date", "<=", "datetime", 1], - "folderShallow" => ["folder", "=", "int", 1], - "subscription" => ["subscription", "=", "int", 1], - "unread" => ["unread", "=", "bool", 1], - "starred" => ["starred", "=", "bool", 1], + // each context array consists of a column identifier (see $colDefs above), a comparison operator, a data type, an option to pair with for BETWEEN evaluation, and an upper bound if the value is an array + "edition" => ["edition", "=", "int", "", 1], + "editions" => ["edition", "in", "int", "", self::LIMIT_ARTICLES], + "article" => ["id", "=", "int", "", 1], + "articles" => ["id", "in", "int", "", self::LIMIT_ARTICLES], + "oldestArticle" => ["id", ">=", "int", "latestArticle", 1], + "latestArticle" => ["id", "<=", "int", "oldestArticle", 1], + "oldestEdition" => ["edition", ">=", "int", "latestEdition", 1], + "latestEdition" => ["edition", "<=", "int", "oldestEdition", 1], + "modifiedSince" => ["modified_date", ">=", "datetime", "notModifiedSince", 1], + "notModifiedSince" => ["modified_date", "<=", "datetime", "modifiedSince", 1], + "markedSince" => ["marked_date", ">=", "datetime", "notMarkedSince", 1], + "notMarkedSince" => ["marked_date", "<=", "datetime", "markedSince", 1], + "folderShallow" => ["folder", "=", "int", "", 1], + "subscription" => ["subscription", "=", "int", "", 1], + "unread" => ["unread", "=", "bool", "", 1], + "starred" => ["starred", "=", "bool", "", 1], ]; - foreach ($options as $m => list($col, $op, $type, $max)) { + $optionsSeen = []; + foreach ($options as $m => list($col, $op, $type, $pair, $max)) { + if (!$context->$m()) { // context is not being used continue; } elseif (is_array($context->$m)) { + // context option is an array of values if (!$context->$m) { throw new Db\ExceptionInput("tooShort", ['field' => $m, 'action' => $this->caller(), 'min' => 1]); // must have at least one array element } elseif (sizeof($context->$m) > $max) { @@ -1212,27 +1214,42 @@ class Database { } list($clause, $types, $values) = $this->generateIn($context->$m, $type); $q->setWhere("{$colDefs[$col]} $op ($clause)", $types, $values); + } elseif ($pair && $context->$pair()) { + // option is paired with another which is also being used + if ($op === ">=") { + $q->setWhere("{$colDefs[$col]} BETWEEN ? AND ?", [$type, $type], [$context->$m, $context->$pair]); + } else { + // option has already been paired + continue; + } } else { $q->setWhere("{$colDefs[$col]} $op ?", $type, $context->$m); } } - if ($context->not != $excContext) { - // further handle exclusionary options if specified - foreach ($options as $m => list($col, $op, $type, $max)) { - if (!method_exists($context->not, $m) || !$context->not->$m()) { - // context option is not being used + // further handle exclusionary options if specified + foreach ($options as $m => list($col, $op, $type, $pair, $max)) { + if (!method_exists($context->not, $m) || !$context->not->$m()) { + // context option is not being used + continue; + } elseif (is_array($context->not->$m)) { + if (!$context->not->$m) { + // for exclusions we don't care if the array is empty continue; - } elseif (is_array($context->not->$m)) { - if (!$context->not->$m) { - // for exclusions we don't care if the array is empty - } elseif (sizeof($context->not->$m) > $max) { - throw new Db\ExceptionInput("tooLong", ['field' => $m, 'action' => $this->caller(), 'max' => $max]); // @codeCoverageIgnore - } - list($clause, $types, $values) = $this->generateIn($context->$m, $type); - $q->setWhereNot("{$colDefs[$col]} $op ($clause)", $types, $values); - } else { - $q->setWhereNot("{$colDefs[$col]} $op ?", $type, $context->$m); + } elseif (sizeof($context->not->$m) > $max) { + throw new Db\ExceptionInput("tooLong", ['field' => "$m (not)", 'action' => $this->caller(), 'max' => $max]); } + list($clause, $types, $values) = $this->generateIn($context->not->$m, $type); + $q->setWhereNot("{$colDefs[$col]} $op ($clause)", $types, $values); + } elseif ($pair && $context->not->$pair()) { + // option is paired with another which is also being used + if ($op === ">=") { + $q->setWhereNot("{$colDefs[$col]} BETWEEN ? AND ?", [$type, $type], [$context->not->$m, $context->not->$pair]); + } else { + // option has already been paired + continue; + } + } else { + $q->setWhereNot("{$colDefs[$col]} $op ?", $type, $context->not->$m); } } // handle complex context options diff --git a/lib/Misc/Query.php b/lib/Misc/Query.php index 458b7ed6..5a1b0b89 100644 --- a/lib/Misc/Query.php +++ b/lib/Misc/Query.php @@ -113,6 +113,9 @@ class Query { $this->qWhere = []; $this->tWhere = []; $this->vWhere = []; + $this->qWhereNot = []; + $this->tWhereNot = []; + $this->vWhereNot = []; $this->qJoin = []; $this->tJoin = []; $this->vJoin = []; diff --git a/tests/cases/Database/SeriesArticle.php b/tests/cases/Database/SeriesArticle.php index 37a53114..85b4c22b 100644 --- a/tests/cases/Database/SeriesArticle.php +++ b/tests/cases/Database/SeriesArticle.php @@ -377,43 +377,6 @@ trait SeriesArticle { unset($this->data, $this->matches, $this->fields, $this->checkTables, $this->user); } - public function testRetrieveArticleIdsForEditions() { - $exp = [ - 1 => 1, - 2 => 2, - 3 => 3, - 4 => 4, - 5 => 5, - 6 => 6, - 7 => 7, - 8 => 8, - 9 => 9, - 10 => 10, - 11 => 11, - 12 => 12, - 13 => 13, - 14 => 14, - 15 => 15, - 16 => 16, - 17 => 17, - 18 => 18, - 19 => 19, - 20 => 20, - 101 => 101, - 102 => 102, - 103 => 103, - 104 => 104, - 105 => 105, - 202 => 102, - 203 => 103, - 204 => 104, - 205 => 105, - 305 => 105, - 1001 => 20, - ]; - $this->assertEquals($exp, Arsse::$db->editionArticle(...range(1, 1001))); - } - /** @dataProvider provideContextMatches */ public function testListArticlesCheckingContext(Context $c, array $exp) { $ids = array_column($ids = Arsse::$db->articleList("john.doe@example.com", $c)->getAll(), "id"); @@ -454,6 +417,8 @@ trait SeriesArticle { "Marked or labelled since 2010" => [(new Context)->markedSince("2010-01-01T00:00:00Z"), [2,4,6,8,19,20]], "Not marked or labelled since 2014" => [(new Context)->notMarkedSince("2014-01-01T00:00:00Z"), [1,2,3,4,5,6,7,20]], "Not marked or labelled since 2005" => [(new Context)->notMarkedSince("2005-01-01T00:00:00Z"), [1,3,5,7]], + "Marked or labelled between 2000 and 2015" => [(new Context)->markedSince("2000-01-01T00:00:00Z")->notMarkedSince("2015-12-31T23:59:59Z"), [1,2,3,4,5,6,7,8,20]], + "Marked or labelled in 2010" => [(new Context)->markedSince("2010-01-01T00:00:00Z")->notMarkedSince("2010-12-31T23:59:59Z"), [2,4,6,20]], "Paged results" => [(new Context)->limit(2)->oldestEdition(4), [4,5]], "Reversed paged results" => [(new Context)->limit(2)->latestEdition(7)->reverse(true), [7,6]], "With label ID 1" => [(new Context)->label(1), [1,19]], @@ -483,9 +448,50 @@ trait SeriesArticle { "Search author 2" => [(new Context)->authorTerms(["jane doe"]), [6,7]], "Search author 3" => [(new Context)->authorTerms(["doe", "jane"]), [6,7]], "Search author 4" => [(new Context)->authorTerms(["doe jane"]), []], + "Folder tree 1 excluding subscription 4" => [(new Context)->not->subscription(4)->folder(1), [5,6]], + "Folder tree 1 excluding articles 7 and 8" => [(new Context)->folder(1)->not->articles([7,8]), [5,6]], + "Folder tree 1 excluding no articles" => [(new Context)->folder(1)->not->articles([]), [5,6,7,8]], + "Marked or labelled between 2000 and 2015 excluding in 2010" => [(new Context)->markedSince("2000-01-01T00:00:00Z")->notMarkedSince("2015-12-31T23:59:59")->not->markedSince("2010-01-01T00:00:00Z")->not->notMarkedSince("2010-12-31T23:59:59Z"), [1,3,5,7,8]], ]; } + public function testRetrieveArticleIdsForEditions() { + $exp = [ + 1 => 1, + 2 => 2, + 3 => 3, + 4 => 4, + 5 => 5, + 6 => 6, + 7 => 7, + 8 => 8, + 9 => 9, + 10 => 10, + 11 => 11, + 12 => 12, + 13 => 13, + 14 => 14, + 15 => 15, + 16 => 16, + 17 => 17, + 18 => 18, + 19 => 19, + 20 => 20, + 101 => 101, + 102 => 102, + 103 => 103, + 104 => 104, + 105 => 105, + 202 => 102, + 203 => 103, + 204 => 104, + 205 => 105, + 305 => 105, + 1001 => 20, + ]; + $this->assertEquals($exp, Arsse::$db->editionArticle(...range(1, 1001))); + } + public function testListArticlesOfAMissingFolder() { $this->assertException("idMissing", "Db", "ExceptionInput"); Arsse::$db->articleList($this->user, (new Context)->folder(1)); From 89f25d7b91e8be953a727200a25a01a588dc9f56 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Tue, 26 Feb 2019 11:12:40 -0500 Subject: [PATCH 021/142] Fix coverage a little --- lib/Context/Context.php | 1 + lib/Context/ExclusionContext.php | 1 + 2 files changed, 2 insertions(+) diff --git a/lib/Context/Context.php b/lib/Context/Context.php index 922c535d..858409f6 100644 --- a/lib/Context/Context.php +++ b/lib/Context/Context.php @@ -26,6 +26,7 @@ class Context extends ExclusionContext { $this->not = clone $this->not; } + /** @codeCoverageIgnore */ public function __destruct() { unset($this->not); } diff --git a/lib/Context/ExclusionContext.php b/lib/Context/ExclusionContext.php index cfec246d..9fc2381b 100644 --- a/lib/Context/ExclusionContext.php +++ b/lib/Context/ExclusionContext.php @@ -48,6 +48,7 @@ class ExclusionContext { } } + /** @codeCoverageIgnore */ public function __destruct() { unset($this->parent); } From 677e33e5185ee4792a8e04ca77d8d222ba0d0a9d Mon Sep 17 00:00:00 2001 From: "J. King" Date: Tue, 26 Feb 2019 11:39:19 -0500 Subject: [PATCH 022/142] Add text search exclusions --- lib/Database.php | 33 ++++++++++++++++++-------- tests/cases/Database/SeriesArticle.php | 1 + 2 files changed, 24 insertions(+), 10 deletions(-) diff --git a/lib/Database.php b/lib/Database.php index 13f49bff..a8067751 100644 --- a/lib/Database.php +++ b/lib/Database.php @@ -159,8 +159,9 @@ class Database { * * @param string[] $terms The terms to search for * @param string[] $cols The columns to match against; these are -not- sanitized, so much -not- come directly from user input + * @param boolean $matchAny Whether the search is successful when it matches any (true) or all (false) terms */ - protected function generateSearch(array $terms, array $cols): array { + protected function generateSearch(array $terms, array $cols, bool $matchAny = false): array { $clause = []; $types = []; $values = []; @@ -176,7 +177,8 @@ class Database { } $clause[] = "(".implode(" or ", $spec).")"; } - $clause = "(".implode(" and ", $clause).")"; + $glue = $matchAny ? "or" : "and"; + $clause = "(".implode(" $glue ", $clause).")"; return [$clause, $types, $values]; } @@ -382,7 +384,7 @@ class Database { * * @param string $uer The user whose folders are to be listed * @param integer|null $parent Restricts the list to the descendents of the specified folder identifier - * @param boolean $recursive Whether to list all descendents, or only direct children + * @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. @@ -500,7 +502,7 @@ class Database { * * @param string $user The user who owns the folder to be validated * @param integer|null $id The identifier of the folder to validate; null or zero represent the implied root folder - * @param boolean $subject Whether the folder is the subject rather than the object of the operation being performed; this only affects the semantics of the error message if validation fails + * @param boolean $subject Whether the folder 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 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 @@ -839,7 +841,7 @@ class Database { * * @param string $user The user who owns the subscription to be validated * @param integer|null $id The identifier of the subscription to validate - * @param boolean $subject Whether the subscription is the subject rather than the object of the operation being performed; this only affects the semantics of the error message if validation fails + * @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)) { @@ -1199,7 +1201,6 @@ class Database { "unread" => ["unread", "=", "bool", "", 1], "starred" => ["starred", "=", "bool", "", 1], ]; - $optionsSeen = []; foreach ($options as $m => list($col, $op, $type, $pair, $max)) { if (!$context->$m()) { @@ -1273,12 +1274,13 @@ class Database { $q->setWhere("arsse_subscriptions.folder in (select folder from folders)"); } // handle text-matching context options - foreach ([ + $options = [ "titleTerms" => [10, ["arsse_articles.title"]], "searchTerms" => [20, ["arsse_articles.title", "arsse_articles.content"]], "authorTerms" => [10, ["arsse_articles.author"]], "annotationTerms" => [20, ["arsse_marks.note"]], - ] as $m => list($max, $cols)) { + ]; + foreach ($options as $m => list($max, $cols)) { if (!$context->$m()) { continue; } elseif (!$context->$m) { @@ -1288,6 +1290,17 @@ class Database { } $q->setWhere(...$this->generateSearch($context->$m, $cols)); } + // further handle exclusionary text-matching context options + foreach ($options as $m => list($max, $cols)) { + if (!$context->not->$m()) { + continue; + } elseif (!$context->not->$m) { + continue; + } elseif (sizeof($context->not->$m) > $max) { + throw new Db\ExceptionInput("tooLong", ['field' => "$m (not)", 'action' => $this->caller(), 'max' => $max]); + } + $q->setWhereNot(...$this->generateSearch($context->not->$m, $cols, true)); + } // return the query return $q; } @@ -1503,7 +1516,7 @@ class Database { * * @param string $user The user whose labels are to be listed * @param integer $id The numeric identifier of the article whose labels are to be listed - * @param boolean $byName Whether to return the label names instead of the numeric label identifiers + * @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__)) { @@ -1903,7 +1916,7 @@ class Database { * @param integer|string $id The numeric identifier or name of the label to validate * @param boolean $byName Whether to interpret the $id parameter as the label's name (true) or identifier (false) * @param boolean $checkDb Whether to check whether the label exists (true) or only if the identifier or name is syntactically valid (false) - * @param boolean $subject Whether the label is the subject rather than the object of the operation being performed; this only affects the semantics of the error message if validation fails + * @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)) { diff --git a/tests/cases/Database/SeriesArticle.php b/tests/cases/Database/SeriesArticle.php index 85b4c22b..7e2b1e48 100644 --- a/tests/cases/Database/SeriesArticle.php +++ b/tests/cases/Database/SeriesArticle.php @@ -452,6 +452,7 @@ trait SeriesArticle { "Folder tree 1 excluding articles 7 and 8" => [(new Context)->folder(1)->not->articles([7,8]), [5,6]], "Folder tree 1 excluding no articles" => [(new Context)->folder(1)->not->articles([]), [5,6,7,8]], "Marked or labelled between 2000 and 2015 excluding in 2010" => [(new Context)->markedSince("2000-01-01T00:00:00Z")->notMarkedSince("2015-12-31T23:59:59")->not->markedSince("2010-01-01T00:00:00Z")->not->notMarkedSince("2010-12-31T23:59:59Z"), [1,3,5,7,8]], + "Search with exclusion" => [(new Context)->searchTerms(["Article"])->not->searchTerms(["one", "two"]), [3]], ]; } From 1e7724ec80f6d435a768b8ebb509dbe8f46c1d03 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Tue, 26 Feb 2019 12:54:27 -0500 Subject: [PATCH 023/142] Filter out duplicates in set context options --- lib/Context/ExclusionContext.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/Context/ExclusionContext.php b/lib/Context/ExclusionContext.php index 9fc2381b..d5299fe0 100644 --- a/lib/Context/ExclusionContext.php +++ b/lib/Context/ExclusionContext.php @@ -77,7 +77,7 @@ class ExclusionContext { $spec[$a] = 0; } } - return array_values(array_filter($spec)); + return array_values(array_unique(array_filter($spec))); } protected function cleanStringArray(array $spec): array { @@ -90,7 +90,7 @@ class ExclusionContext { unset($spec[$a]); } } - return array_values($spec); + return array_values(array_unique($spec)); } public function folder(int $spec = null) { From 95de375e0b177cba4f1ed0f0118a80b4734f3146 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Wed, 27 Feb 2019 10:48:11 -0500 Subject: [PATCH 024/142] Handle folder and label exclusion Consequently the way label data are retrieved was completely overhauled --- lib/Database.php | 61 ++++++++++++++++---------- tests/cases/Database/SeriesArticle.php | 2 + 2 files changed, 39 insertions(+), 24 deletions(-) diff --git a/lib/Database.php b/lib/Database.php index a8067751..61eefa58 100644 --- a/lib/Database.php +++ b/lib/Database.php @@ -1118,9 +1118,7 @@ class Database { $this->labelValidateId($user, $context->label, false); } if ($context->labelName()) { - // dereference the label name to an ID - $context->label((int) $this->labelValidateId($user, $context->labelName, true)['id']); - $context->labelName(null); + $this->labelValidateId($user, $context->labelName, true); } // prepare the output column list; the column definitions are also used later $greatest = $this->db->sqlToken("greatest"); @@ -1142,7 +1140,7 @@ class Database { '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(arsse_label_members.modified, '0001-01-01 00:00:00'))", + '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", @@ -1170,17 +1168,16 @@ class Database { join arsse_feeds on arsse_subscriptions.feed = arsse_feeds.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 - left join arsse_label_members on arsse_label_members.subscription = arsse_subscriptions.id and arsse_label_members.article = arsse_articles.id and arsse_label_members.assigned = 1 - left join arsse_labels on arsse_labels.owner = arsse_subscriptions.owner and arsse_label_members.label = arsse_labels.id", - ["str"], - [$user] + join ( + SELECT article, max(id) as edition from arsse_editions group by article + ) as latest_editions on arsse_articles.id = latest_editions.article + left join ( + SELECT arsse_label_members.article, max(arsse_label_members.modified) as modified, sum(arsse_label_members.assigned) as assigned from arsse_label_members join arsse_labels on arsse_labels.id = arsse_label_members.label where arsse_labels.owner = ? group by arsse_label_members.article + ) as label_stats on label_stats.article = arsse_articles.id", + ["str", "str"], + [$user, $user] ); $q->setLimit($context->limit, $context->offset); - $q->setCTE("latest_editions(article,edition)", "SELECT article,max(id) from arsse_editions group by article", [], [], "join latest_editions on arsse_articles.id = latest_editions.article"); - if ($cols) { - // if there are no output columns requested we're getting a count and should not group, but otherwise we should - $q->setGroup("arsse_articles.id", "arsse_marks.note", "arsse_enclosures.url", "arsse_enclosures.type", "arsse_subscriptions.title", "arsse_feeds.title", "arsse_subscriptions.id", "arsse_marks.modified", "arsse_label_members.modified", "arsse_marks.read", "arsse_marks.starred", "latest_editions.edition"); - } // handle the simple context options $options = [ // each context array consists of a column identifier (see $colDefs above), a comparison operator, a data type, an option to pair with for BETWEEN evaluation, and an upper bound if the value is an array @@ -1202,7 +1199,6 @@ class Database { "starred" => ["starred", "=", "bool", "", 1], ]; foreach ($options as $m => list($col, $op, $type, $pair, $max)) { - if (!$context->$m()) { // context is not being used continue; @@ -1254,24 +1250,41 @@ class Database { } } // handle complex context options - if ($context->labelled()) { - // any label (true) or no label (false) - $isOrIsNot = (!$context->labelled ? "is" : "is not"); - $q->setWhere("arsse_labels.id $isOrIsNot null"); - } - if ($context->label()) { - // label ID (label names are dereferenced during input validation above) - $q->setWhere("arsse_labels.id = ?", "int", $context->label); - } if ($context->annotated()) { $comp = ($context->annotated) ? "<>" : "="; $q->setWhere("coalesce(arsse_marks.note,'') $comp ''"); } + if ($context->labelled()) { + // any label (true) or no label (false) + $op = $context->labelled ? ">" : "="; + $q->setWhere("coalesce(label_stats.assigned,0) $op 0"); + } + if ($context->label() || $context->not->label() || $context->labelName() || $context->not->labelName()) { + $q->setCTE("labelled(article,label_id,label_name)","SELECT m.article, l.id, l.name from arsse_label_members as m join arsse_labels as l on l.id = m.label where l.owner = ? and m.assigned = 1", "str", $user); + if ($context->label()) { + $q->setWhere("arsse_articles.id in (select article from labelled where label_id = ?)", "int", $context->label); + } + if ($context->not->label()) { + $q->setWhereNot("arsse_articles.id in (select article from labelled where label_id = ?)", "int", $context->not->label); + } + if ($context->labelName()) { + $q->setWhere("arsse_articles.id in (select article from labelled where label_name = ?)", "str", $context->labelName); + } + if ($context->not->labelName()) { + $q->setWhereNot("arsse_articles.id in (select article from labelled where label_name = ?)", "str", $context->not->labelName); + } + } if ($context->folder()) { // add a common table expression to list the folder and its children so that we select from the entire subtree $q->setCTE("folders(folder)", "SELECT ? union select id from arsse_folders join folders on parent = folder", "int", $context->folder); // limit subscriptions to the listed folders - $q->setWhere("arsse_subscriptions.folder in (select folder from folders)"); + $q->setWhere("coalesce(arsse_subscriptions.folder,0) in (select folder from folders)"); + } + if ($context->not->folder()) { + // add a common table expression to list the folder and its children so that we exclude from the entire subtree + $q->setCTE("folders_excluded(folder)", "SELECT ? union select id from arsse_folders join folders_excluded on parent = folder", "int", $context->not->folder); + // excluded any subscriptions in the listed folders + $q->setWhereNot("coalesce(arsse_subscriptions.folder,0) in (select folder from folders_excluded)"); } // handle text-matching context options $options = [ diff --git a/tests/cases/Database/SeriesArticle.php b/tests/cases/Database/SeriesArticle.php index 7e2b1e48..3887d785 100644 --- a/tests/cases/Database/SeriesArticle.php +++ b/tests/cases/Database/SeriesArticle.php @@ -453,6 +453,8 @@ trait SeriesArticle { "Folder tree 1 excluding no articles" => [(new Context)->folder(1)->not->articles([]), [5,6,7,8]], "Marked or labelled between 2000 and 2015 excluding in 2010" => [(new Context)->markedSince("2000-01-01T00:00:00Z")->notMarkedSince("2015-12-31T23:59:59")->not->markedSince("2010-01-01T00:00:00Z")->not->notMarkedSince("2010-12-31T23:59:59Z"), [1,3,5,7,8]], "Search with exclusion" => [(new Context)->searchTerms(["Article"])->not->searchTerms(["one", "two"]), [3]], + "Excluded folder tree" => [(new Context)->not->folder(1), [1,2,3,4,19,20]], + "Excluding label ID 2" => [(new Context)->not->label(2), [2,3,4,6,7,8,19]], ]; } From 85307bc90aee6d97dd894c4840f5bf72a7e9ec8b Mon Sep 17 00:00:00 2001 From: "J. King" Date: Thu, 28 Feb 2019 15:31:33 -0500 Subject: [PATCH 025/142] Add parser for TTRSS search strings --- lib/REST/TinyTinyRSS/Search.php | 361 ++++++++++++++++++++ tests/cases/REST/TinyTinyRSS/TestSearch.php | 126 +++++++ tests/phpunit.xml | 1 + 3 files changed, 488 insertions(+) create mode 100644 lib/REST/TinyTinyRSS/Search.php create mode 100644 tests/cases/REST/TinyTinyRSS/TestSearch.php diff --git a/lib/REST/TinyTinyRSS/Search.php b/lib/REST/TinyTinyRSS/Search.php new file mode 100644 index 00000000..4ff634b6 --- /dev/null +++ b/lib/REST/TinyTinyRSS/Search.php @@ -0,0 +1,361 @@ + "unread", + "star" => "starred", + "note" => "annotated", + "pub" => "published", // TODO: not implemented + ]; + const FIELDS_TEXT = [ + "title" => "titleTerms", + "author" => "authorTerms", + "note" => "annotationTerms", + "" => "searchTerms", + ]; + + public static function parse(string $search, Context $context = null) { + // normalize the input + $search = strtolower(trim(preg_replace("<\s+>", " ", $search))); + // set initial state + $tokens = []; + $pos = -1; + $stop = strlen($search); + $state = self::STATE_BEFORE_TOKEN; + $buffer = ""; + $tag = ""; + $flag_negative = false; + $context = $context ?? new Context; + // process + try { + while (++$pos <= $stop) { + $char = @$search[$pos]; + switch ($state) { + case self::STATE_BEFORE_TOKEN: + switch ($char) { + case "": + continue 3; + case " ": + continue 3; + case '"': + if ($flag_negative) { + $buffer .= $char; + $state = self::STATE_IN_TOKEN_OR_TAG; + } else { + $state = self::STATE_BEFORE_TOKEN_QUOTED; + } + continue 3; + case "-": + if (!$flag_negative) { + $flag_negative = true; + } else { + $buffer .= $char; + $state = self::STATE_IN_TOKEN_OR_TAG; + } + continue 3; + case "@": + $state = self::STATE_IN_DATE; + continue 3; + case ":": + $state = self::STATE_IN_TOKEN; + continue 3; + default: + $buffer .= $char; + $state = self::STATE_IN_TOKEN_OR_TAG; + continue 3; + } + case self::STATE_BEFORE_TOKEN_QUOTED: + switch ($char) { + case "": + continue 3; + case '"': + if (($pos + 1 == $stop) || $search[$pos + 1] === " ") { + $context = self::processToken($context, $buffer, $tag, $flag_negative, false); + $state = self::STATE_BEFORE_TOKEN; + $flag_negative = false; + $buffer = $tag = ""; + } elseif ($search[$pos + 1] === '"') { + $buffer .= '"'; + $pos++; + $state = self::STATE_IN_TOKEN_OR_TAG_QUOTED; + } else { + $state = self::STATE_IN_TOKEN_OR_TAG; + } + continue 3; + case "\\": + if ($pos + 1 == $stop) { + $buffer .= $char; + } elseif ($search[$pos + 1] === '"') { + $buffer .= '"'; + $pos++; + } else { + $buffer .= $char; + } + $state = self::STATE_IN_TOKEN_OR_TAG_QUOTED; + continue 3; + case "-": + if (!$flag_negative) { + $flag_negative = true; + } else { + $buffer .= $char; + $state = self::STATE_IN_TOKEN_OR_TAG_QUOTED; + } + continue 3; + case "@": + $state = self::STATE_IN_DATE_QUOTED; + continue 3; + case ":": + $state = self::STATE_IN_TOKEN_QUOTED; + continue 3; + default: + $buffer .= $char; + $state = self::STATE_IN_TOKEN_OR_TAG_QUOTED; + continue 3; + } + case self::STATE_IN_DATE: + while ($pos < $stop && $search[$pos] !== " ") { + $buffer .= $search[$pos++]; + } + $context = self::processToken($context, $buffer, $tag, $flag_negative, true); + $state = self::STATE_BEFORE_TOKEN; + $flag_negative = false; + $buffer = $tag = ""; + continue 2; + case self::STATE_IN_DATE_QUOTED: + switch ($char) { + case "": + case '"': + if (($pos + 1 >= $stop) || $search[$pos + 1] === " ") { + $context = self::processToken($context, $buffer, $tag, $flag_negative, true); + $state = self::STATE_BEFORE_TOKEN; + $flag_negative = false; + $buffer = $tag = ""; + } elseif ($search[$pos + 1] === '"') { + $buffer .= '"'; + $pos++; + } else { + $state = self::STATE_IN_DATE; + } + continue 3; + case "\\": + if ($pos + 1 == $stop) { + $buffer .= $char; + } elseif ($search[$pos + 1] === '"') { + $buffer .= '"'; + $pos++; + } else { + $buffer .= $char; + } + continue 3; + default: + $buffer .= $char; + continue 3; + } + case self::STATE_IN_TOKEN: + while ($pos < $stop && $search[$pos] !== " ") { + $buffer .= $search[$pos++]; + } + if (!strlen($tag)) { + $buffer = ":".$buffer; + } + $context = self::processToken($context, $buffer, $tag, $flag_negative, false); + $state = self::STATE_BEFORE_TOKEN; + $flag_negative = false; + $buffer = $tag = ""; + continue 2; + case self::STATE_IN_TOKEN_QUOTED: + switch ($char) { + case "": + case '"': + if (($pos + 1 >= $stop) || $search[$pos + 1] === " ") { + if (!strlen($tag)) { + $buffer = ":".$buffer; + } + $context = self::processToken($context, $buffer, $tag, $flag_negative, false); + $state = self::STATE_BEFORE_TOKEN; + $flag_negative = false; + $buffer = $tag = ""; + } elseif ($search[$pos + 1] === '"') { + $buffer .= '"'; + $pos++; + } else { + $state = self::STATE_IN_TOKEN; + } + continue 3; + case "\\": + if ($pos + 1 == $stop) { + $buffer .= $char; + } elseif ($search[$pos + 1] === '"') { + $buffer .= '"'; + $pos++; + } else { + $buffer .= $char; + } + continue 3; + default: + $buffer .= $char; + continue 3; + } + case self::STATE_IN_TOKEN_OR_TAG: + switch ($char) { + case "": + case " ": + $context = self::processToken($context, $buffer, $tag, $flag_negative, false); + $state = self::STATE_BEFORE_TOKEN; + $flag_negative = false; + $buffer = $tag = ""; + continue 3; + case ":"; + $tag = $buffer; + $buffer = ""; + $state = self::STATE_IN_TOKEN; + continue 3; + default: + $buffer .= $char; + continue 3; + } + case self::STATE_IN_TOKEN_OR_TAG_QUOTED: + switch ($char) { + case "": + case '"': + if (($pos + 1 >= $stop) || $search[$pos + 1] === " ") { + $context = self::processToken($context, $buffer, $tag, $flag_negative, false); + $state = self::STATE_BEFORE_TOKEN; + $flag_negative = false; + $buffer = $tag = ""; + } elseif ($search[$pos + 1] === '"') { + $buffer .= '"'; + $pos++; + } else { + $state = self::STATE_IN_TOKEN_OR_TAG; + } + continue 3; + case "\\": + if ($pos + 1 == $stop) { + $buffer .= $char; + } elseif ($search[$pos + 1] === '"') { + $buffer .= '"'; + $pos++; + } else { + $buffer .= $char; + } + continue 3; + case ":": + $tag = $buffer; + $buffer = ""; + $state = self::STATE_IN_TOKEN_QUOTED; + continue 3; + default: + $buffer .= $char; + continue 3; + } + default: + throw new \Exception; // @codeCoverageIgnore + } + } + } catch (Exception $e) { + return null; + } + return $context; + } + + protected static function processToken(Context $c, string $value, string $tag, bool $neg, bool $date): Context { + if (!strlen($value) && !strlen($tag)) { + return $c; + } elseif (!strlen($value)) { + // if a tag has an empty value, the tag is treated as a search term instead + $value = "$tag:"; + $tag = ""; + } + if ($date) { + return self::setDate($value, $c, $neg); + } elseif (isset(self::FIELDS_BOOLEAN[$tag])) { + return self::setBoolean($tag, $value, $c, $neg); + } else { + return self::addTerm($tag, $value, $c, $neg); + } + } + + protected static function addTerm(string $tag, string $value, Context $c, bool $neg): Context { + $c = $neg ? $c->not : $c; + $type = self::FIELDS_TEXT[$tag] ?? ""; + if (!$type) { + $value = "$tag:$value"; + $type = self::FIELDS_TEXT[""]; + } + return $c->$type(array_merge($c->$type ?? [], [$value])); + } + + protected static function setDate(string $value, Context $c, bool $neg): Context { + $spec = Date::normalize($value); + // TTRSS treats invalid dates as the start of the Unix epoch; we ignore them instead + if (!$spec) { + return $c; + } + $day = $spec->format("Y-m-d"); + $start = $day."T00:00:00+00:00"; + $end = $day."T23:59:59+00:00"; + // if a date is already set, the same date is a no-op; anything else is a contradiction + $cc = $neg ? $c->not : $c; + if ($cc->modifiedSince() || $cc->notModifiedSince()) { + if (!$cc->modifiedSince() || !$cc->notModifiedSince() || $cc->modifiedSince->format("c") !== $start || $cc->notModifiedSince->format("c") !== $end) { + // FIXME: multiple negative dates should be allowed, but the design of the Context class does not support this + throw new Exception; + } else { + return $c; + } + } + $cc->modifiedSince($start); + $cc->notModifiedSince($end); + return $c; + } + + protected static function setBoolean(string $tag, string $value, Context $c, bool $neg): Context { + $set = ["true" => true, "false" => false][$value] ?? null; + if (is_null($set)) { + return self::addTerm($tag, $value, $c, $neg); + } else { + // apply negation + $set = $neg ? !$set : $set; + if ($tag === "pub") { + // TODO: this needs to be implemented correctly if the Published feed is implemented + // currently specifying true will always yield an empty result (nothing is ever published), and specifying false is a no-op (matches everything) + if ($set) { + throw new Exception; + } else { + return $c; + } + } else { + $field = (self::FIELDS_BOOLEAN[$tag] ?? ""); + if (!$c->$field()) { + // field has not yet been set; set it + return $c->$field($set); + } elseif ($c->$field == $set) { + // field is already set to same value; do nothing + return $c; + } else { + // contradiction: query would return no results + throw new Exception; + } + } + } + } +} diff --git a/tests/cases/REST/TinyTinyRSS/TestSearch.php b/tests/cases/REST/TinyTinyRSS/TestSearch.php new file mode 100644 index 00000000..62ad553d --- /dev/null +++ b/tests/cases/REST/TinyTinyRSS/TestSearch.php @@ -0,0 +1,126 @@ + ["", new Context], + 'Whitespace only' => [" \n \t", new Context], + 'Simple bare token' => ['OOK', (new Context)->searchTerms(["ook"])], + 'Simple negative bare token' => ['-OOK', (new Context)->not->searchTerms(["ook"])], + 'Simple quoted token' => ['"OOK eek"', (new Context)->searchTerms(["ook eek"])], + 'Simple negative quoted token' => ['"-OOK eek"', (new Context)->not->searchTerms(["ook eek"])], + 'Simple bare tokens' => ['OOK eek', (new Context)->searchTerms(["ook", "eek"])], + 'Simple mixed bare tokens' => ['-OOK eek', (new Context)->not->searchTerms(["ook"])->searchTerms(["eek"])], + 'Unclosed quoted token' => ['"OOK eek', (new Context)->searchTerms(["ook eek"])], + 'Unclosed quoted token 2' => ['"OOK eek" "', (new Context)->searchTerms(["ook eek"])], + 'Broken quoted token 1' => ['"-OOK"eek"', (new Context)->not->searchTerms(["ookeek\""])], + 'Broken quoted token 2' => ['""eek"', (new Context)->searchTerms(["eek\""])], + 'Broken quoted token 3' => ['"-"eek"', (new Context)->not->searchTerms(["eek\""])], + 'Empty quoted token' => ['""', new Context], + 'Simple quoted tokens' => ['"OOK eek" "eek ack"', (new Context)->searchTerms(["ook eek", "eek ack"])], + 'Bare blank tag' => [':ook', (new Context)->searchTerms([":ook"])], + 'Quoted blank tag' => ['":ook"', (new Context)->searchTerms([":ook"])], + 'Bare negative blank tag' => ['-:ook', (new Context)->not->searchTerms([":ook"])], + 'Quoted negative blank tag' => ['"-:ook"', (new Context)->not->searchTerms([":ook"])], + 'Bare valueless blank tag' => [':', (new Context)->searchTerms([":"])], + 'Quoted valueless blank tag' => ['":"', (new Context)->searchTerms([":"])], + 'Bare negative valueless blank tag' => ['-:', (new Context)->not->searchTerms([":"])], + 'Quoted negative valueless blank tag' => ['"-:"', (new Context)->not->searchTerms([":"])], + 'Double negative' => ['--eek', (new Context)->not->searchTerms(["-eek"])], + 'Double negative 2' => ['--@eek', (new Context)->not->searchTerms(["-@eek"])], + 'Double negative 3' => ['"--@eek"', (new Context)->not->searchTerms(["-@eek"])], + 'Double negative 4' => ['"--eek"', (new Context)->not->searchTerms(["-eek"])], + 'Negative before quote' => ['-"ook"', (new Context)->not->searchTerms(["\"ook\""])], + 'Bare unread tag true' => ['UNREAD:true', (new Context)->unread(true)], + 'Bare unread tag false' => ['UNREAD:false', (new Context)->unread(false)], + 'Bare negative unread tag true' => ['-unread:true', (new Context)->unread(false)], + 'Bare negative unread tag false' => ['-unread:false', (new Context)->unread(true)], + 'Quoted unread tag true' => ['"UNREAD:true"', (new Context)->unread(true)], + 'Quoted unread tag false' => ['"UNREAD:false"', (new Context)->unread(false)], + 'Quoted negative unread tag true' => ['"-unread:true"', (new Context)->unread(false)], + 'Quoted negative unread tag false' => ['"-unread:false"', (new Context)->unread(true)], + 'Bare star tag true' => ['STAR:true', (new Context)->starred(true)], + 'Bare star tag false' => ['STAR:false', (new Context)->starred(false)], + 'Bare negative star tag true' => ['-star:true', (new Context)->starred(false)], + 'Bare negative star tag false' => ['-star:false', (new Context)->starred(true)], + 'Quoted star tag true' => ['"STAR:true"', (new Context)->starred(true)], + 'Quoted star tag false' => ['"STAR:false"', (new Context)->starred(false)], + 'Quoted negative star tag true' => ['"-star:true"', (new Context)->starred(false)], + 'Quoted negative star tag false' => ['"-star:false"', (new Context)->starred(true)], + 'Bare note tag true' => ['NOTE:true', (new Context)->annotated(true)], + 'Bare note tag false' => ['NOTE:false', (new Context)->annotated(false)], + 'Bare negative note tag true' => ['-note:true', (new Context)->annotated(false)], + 'Bare negative note tag false' => ['-note:false', (new Context)->annotated(true)], + 'Quoted note tag true' => ['"NOTE:true"', (new Context)->annotated(true)], + 'Quoted note tag false' => ['"NOTE:false"', (new Context)->annotated(false)], + 'Quoted negative note tag true' => ['"-note:true"', (new Context)->annotated(false)], + 'Quoted negative note tag false' => ['"-note:false"', (new Context)->annotated(true)], + 'Bare pub tag true' => ['PUB:true', null], + 'Bare pub tag false' => ['PUB:false', new Context], + 'Bare negative pub tag true' => ['-pub:true', new Context], + 'Bare negative pub tag false' => ['-pub:false', null], + 'Quoted pub tag true' => ['"PUB:true"', null], + 'Quoted pub tag false' => ['"PUB:false"', new Context], + 'Quoted negative pub tag true' => ['"-pub:true"', new Context], + 'Quoted negative pub tag false' => ['"-pub:false"', null], + 'Non-boolean unread tag' => ['unread:maybe', (new Context)->searchTerms(["unread:maybe"])], + 'Non-boolean star tag' => ['star:maybe', (new Context)->searchTerms(["star:maybe"])], + 'Non-boolean pub tag' => ['pub:maybe', (new Context)->searchTerms(["pub:maybe"])], + 'Non-boolean note tag' => ['note:maybe', (new Context)->annotationTerms(["maybe"])], + 'Valueless unread tag' => ['unread:', (new Context)->searchTerms(["unread:"])], + 'Valueless star tag' => ['star:', (new Context)->searchTerms(["star:"])], + 'Valueless pub tag' => ['pub:', (new Context)->searchTerms(["pub:"])], + 'Valueless note tag' => ['note:', (new Context)->searchTerms(["note:"])], + 'Valueless title tag' => ['title:', (new Context)->searchTerms(["title:"])], + 'Valueless author tag' => ['author:', (new Context)->searchTerms(["author:"])], + 'Escaped quote 1' => ['"""I say, Jeeves!"""', (new Context)->searchTerms(["\"i say, jeeves!\""])], + 'Escaped quote 2' => ['"\\"I say, Jeeves!\\""', (new Context)->searchTerms(["\"i say, jeeves!\""])], + 'Escaped quote 3' => ['\\"I say, Jeeves!\\"', (new Context)->searchTerms(["\\\"i", "say,", "jeeves!\\\""])], + 'Escaped quote 4' => ['"\\"\\I say, Jeeves!\\""', (new Context)->searchTerms(["\"\\i say, jeeves!\""])], + 'Escaped quote 5' => ['"\\I say, Jeeves!"', (new Context)->searchTerms(["\\i say, jeeves!"])], + 'Escaped quote 6' => ['"\\"I say, Jeeves!\\', (new Context)->searchTerms(["\"i say, jeeves!\\"])], + 'Escaped quote 7' => ['"\\', (new Context)->searchTerms(["\\"])], + 'Quoted author tag 1' => ['"author:Neal Stephenson"', (new Context)->authorTerms(["neal stephenson"])], + 'Quoted author tag 2' => ['"author:Jo ""Cap\'n Tripps"" Ashburn"', (new Context)->authorTerms(["jo \"cap'n tripps\" ashburn"])], + 'Quoted author tag 3' => ['"author:Jo \\"Cap\'n Tripps\\" Ashburn"', (new Context)->authorTerms(["jo \"cap'n tripps\" ashburn"])], + 'Quoted author tag 4' => ['"author:Jo ""Cap\'n Tripps"Ashburn"', (new Context)->authorTerms(["jo \"cap'n trippsashburn\""])], + 'Quoted author tag 5' => ['"author:Jo ""Cap\'n Tripps\ Ashburn"', (new Context)->authorTerms(["jo \"cap'n tripps\\ ashburn"])], + 'Quoted author tag 6' => ['"author:Neal Stephenson\\', (new Context)->authorTerms(["neal stephenson\\"])], + 'Quoted title tag' => ['"title:Generic title"', (new Context)->titleTerms(["generic title"])], + 'Contradictory booleans' => ['unread:true -unread:true', null], + 'Doubled boolean' => ['unread:true unread:true', (new Context)->unread(true)], + 'Bare blank date' => ['@', new Context], + 'Quoted blank date' => ['"@"', new Context], + 'Bare ISO date' => ['@2019-03-01', (new Context)->modifiedSince("2019-03-01T00:00:00Z")->notModifiedSince("2019-03-01T23:59:59Z")], + 'Quoted ISO date' => ['"@March 1st, 2019"', (new Context)->modifiedSince("2019-03-01T00:00:00Z")->notModifiedSince("2019-03-01T23:59:59Z")], + 'Bare negative ISO date' => ['-@2019-03-01', (new Context)->not->modifiedSince("2019-03-01T00:00:00Z")->not->notModifiedSince("2019-03-01T23:59:59Z")], + 'Quoted negative English date' => ['"-@March 1st, 2019"', (new Context)->not->modifiedSince("2019-03-01T00:00:00Z")->not->notModifiedSince("2019-03-01T23:59:59Z")], + 'Invalid date' => ['@Bugaboo', new Context], + 'Escaped quoted date 1' => ['"@""Yesterday" and today', (new Context)->searchTerms(["and", "today"])], + 'Escaped quoted date 2' => ['"@\\"Yesterday" and today', (new Context)->searchTerms(["and", "today"])], + 'Escaped quoted date 3' => ['"@Yesterday\\', new Context], + 'Escaped quoted date 4' => ['"@Yesterday\\and today', new Context], + 'Escaped quoted date 5' => ['"@Yesterday"and today', (new Context)->searchTerms(["today"])], + 'Contradictory dates' => ['@Yesterday @Today', null], + 'Doubled date' => ['"@March 1st, 2019" @2019-03-01', (new Context)->modifiedSince("2019-03-01T00:00:00Z")->notModifiedSince("2019-03-01T23:59:59Z")], + 'Doubled negative date' => ['"-@March 1st, 2019" -@2019-03-01', (new Context)->not->modifiedSince("2019-03-01T00:00:00Z")->not->notModifiedSince("2019-03-01T23:59:59Z")], + ]; + } + + /** @dataProvider provideSearchStrings */ + public function testApplySearchToContext(string $search, $exp) { + $act = Search::parse($search); + //var_export($act); + $this->assertEquals($exp, $act); + } +} diff --git a/tests/phpunit.xml b/tests/phpunit.xml index 65a08939..aac033bd 100644 --- a/tests/phpunit.xml +++ b/tests/phpunit.xml @@ -99,6 +99,7 @@ cases/REST/NextCloudNews/PDO/TestV1_2.php + cases/REST/TinyTinyRSS/TestSearch.php cases/REST/TinyTinyRSS/TestAPI.php cases/REST/TinyTinyRSS/TestIcon.php cases/REST/TinyTinyRSS/PDO/TestAPI.php From 3b8461b1ca4ba0e5cac8195493052cca94f92874 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Thu, 28 Feb 2019 16:22:04 -0500 Subject: [PATCH 026/142] Add searching to TTRSS handler --- CHANGELOG | 6 ++++++ README.md | 8 +++++++- lib/REST/TinyTinyRSS/API.php | 13 ++++++++++--- tests/cases/REST/TinyTinyRSS/TestAPI.php | 5 +++++ 4 files changed, 28 insertions(+), 4 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index 10832aae..af5159f3 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,9 @@ +Version 0.7.0 (2019-??-??) +========================== + +New features: +- Support for basic freeform searching in Tiny Tiny RSS + Version 0.6.1 (2019-01-23) ========================== diff --git a/README.md b/README.md index 2cec044d..d4fca7f3 100644 --- a/README.md +++ b/README.md @@ -130,7 +130,6 @@ We are not aware of any other extensions to the TTRSS protocol. If you know of a - The `getPref` operation is not implemented; it returns `UNKNOWN_METHOD` - The `shareToPublished` operation is not implemented; it returns `UNKNOWN_METHOD` - Setting an article's "published" flag with the `updateArticle` operation is not implemented and will gracefully fail -- The `search` parameter of the `getHeadlines` operation is not implemented; the operation will proceed as if no search string were specified - The `sanitize`, `force_update`, and `has_sandbox` parameters of the `getHeadlines` operation are ignored - String `feed_id` values for the `getCompactHeadlines` operation are not supported and will yield an `INCORRECT_USAGE` error - Articles are limited to a single attachment rather than multiple attachments @@ -141,6 +140,13 @@ We are not aware of any other extensions to the TTRSS protocol. If you know of a - Feed, category, and label names are normally unrestricted; The Arsse rejects empty strings, as well as strings composed solely of whitespace - Discovering multiple feeds during `subscribeToFeed` processing normally produces an error; The Arsse instead chooses the first feed it finds - Providing the `setArticleLabel` operation with an invalid label normally silently fails; The Arsse returns an `INVALID_USAGE` error instead +- Processing of the `search` parameter of the `getHeadlines` operation differs in the following ways: + - Values other than `"true"` or `"false"` for the `unread`, `star`, and `pub` special keywords treat the entire token as a search term rather than as `"false"` + - Limits are placed on the number of search terms: ten each for `title`, `author`, and `note`, and twenty for content searching; exceeding the limits will yield a non-standard `TOO_MANY_SEARCH_TERMS` error + - Invalid dates are ignored rather than assumed to be `"1970-01-01"` + - Only a single negative date is allowed (this is a known bug rather than intentional) + - Dates are always relative to UTC + - Full-text search is not yet employed with any database, including PostgreSQL - Article hashes are normally SHA1; The Arsse uses SHA256 hashes - Article attachments normally have unique IDs; The Arsse always gives attachments an ID of `"0"` - The default sort order of the `getHeadlines` operation normally uses custom sorting for "special" feeds; The Arsse's default sort order is equivalent to `feed_dates` for all feeds diff --git a/lib/REST/TinyTinyRSS/API.php b/lib/REST/TinyTinyRSS/API.php index f126324c..a3572ba3 100644 --- a/lib/REST/TinyTinyRSS/API.php +++ b/lib/REST/TinyTinyRSS/API.php @@ -49,7 +49,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { '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` and `subscribeToFeed` + '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 @@ -76,7 +76,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { '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` (not yet implemented) + '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` 'data' => ValueInfo::T_STRING, // note text in `updateArticle` if setting a note @@ -1478,7 +1478,14 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { default: throw new \JKingWeb\Arsse\Exception("constantUnknown", $viewMode); // @codeCoverageIgnore } - // TODO: implement searching + // handle the search string, if any + if (isset($data['search'])) { + $c = Search::parse($data['search'], $c); + if (!$c) { + // the search string inherently returns an empty result, either directly or interacting with other input + return new ResultEmpty; + } + } // handle sorting switch ($data['order_by']) { case "date_reverse": diff --git a/tests/cases/REST/TinyTinyRSS/TestAPI.php b/tests/cases/REST/TinyTinyRSS/TestAPI.php index 4b497a7f..91b370cf 100644 --- a/tests/cases/REST/TinyTinyRSS/TestAPI.php +++ b/tests/cases/REST/TinyTinyRSS/TestAPI.php @@ -1809,6 +1809,8 @@ LONG_STRING; ['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 @@ -1833,6 +1835,7 @@ LONG_STRING; ['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 @@ -1868,6 +1871,7 @@ LONG_STRING; Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->folderShallow(42), $this->anything())->thenReturn($this->generateHeadlines(14)); Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->folder(42), $this->anything())->thenReturn($this->generateHeadlines(15)); Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->reverse(false), $this->anything())->thenReturn($this->generateHeadlines(16)); + Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->subscription(42)->searchTerms(["interesting"]), $this->anything())->thenReturn($this->generateHeadlines(17)); $out2 = [ $this->respErr("INCORRECT_USAGE"), $this->outputHeadlines(11), @@ -1890,6 +1894,7 @@ LONG_STRING; $this->outputHeadlines(15), $this->outputHeadlines(11), // defaulting sorting is not fully implemented $this->outputHeadlines(16), + $this->outputHeadlines(17), ]; $out3 = [ $this->outputHeadlines(1001), From 837f3c6dd64cc4a2d167dd9bfbc1820846c2b802 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Fri, 1 Mar 2019 12:17:33 -0500 Subject: [PATCH 027/142] Simplify SQL type handling This is done in anticipation of dealing with SQL types in places other than statements --- lib/Db/AbstractStatement.php | 62 +++++++++++++++----------------- lib/Db/MySQL/Statement.php | 18 +++++----- lib/Db/PDOStatement.php | 14 ++++---- lib/Db/PostgreSQL/Statement.php | 16 ++++----- lib/Db/SQLite3/Statement.php | 14 ++++---- lib/Db/Statement.php | 58 +++++++++++++++++++++--------- tests/cases/Db/BaseStatement.php | 12 +++---- 7 files changed, 107 insertions(+), 87 deletions(-) diff --git a/lib/Db/AbstractStatement.php b/lib/Db/AbstractStatement.php index 1dc990fb..abf9f77f 100644 --- a/lib/Db/AbstractStatement.php +++ b/lib/Db/AbstractStatement.php @@ -12,11 +12,25 @@ use JKingWeb\Arsse\Misc\ValueInfo; abstract class AbstractStatement implements Statement { use SQLState; + const TYPE_NORM_MAP = [ + self::T_INTEGER => ValueInfo::M_NULL | ValueInfo::T_INT, + self::T_STRING => ValueInfo::M_NULL | ValueInfo::T_STRING, + self::T_BOOLEAN => ValueInfo::M_NULL | ValueInfo::T_BOOL, + self::T_DATETIME => ValueInfo::M_NULL | ValueInfo::T_DATE, + self::T_FLOAT => ValueInfo::M_NULL | ValueInfo::T_FLOAT, + self::T_BINARY => ValueInfo::M_NULL | ValueInfo::T_STRING, + self::T_NOT_NULL + self::T_INTEGER => ValueInfo::T_INT, + self::T_NOT_NULL + self::T_STRING => ValueInfo::T_STRING, + self::T_NOT_NULL + self::T_BOOLEAN => ValueInfo::T_BOOL, + self::T_NOT_NULL + self::T_DATETIME => ValueInfo::T_DATE, + self::T_NOT_NULL + self::T_FLOAT => ValueInfo::T_FLOAT, + self::T_NOT_NULL + self::T_BINARY => ValueInfo::T_STRING, + ]; + protected $types = []; - protected $isNullable = []; abstract public function runArray(array $values = []): Result; - abstract protected function bindValue($value, string $type, int $position): bool; + abstract protected function bindValue($value, int $type, int $position): bool; abstract protected function prepare(string $query): bool; abstract protected static function buildEngineException($code, string $msg): array; @@ -41,18 +55,11 @@ abstract class AbstractStatement implements Statement { // recursively flatten any arrays, which may be provided for SET or IN() clauses $this->retypeArray($binding, true); } else { - $binding = trim(strtolower($binding)); - if (strpos($binding, "strict ")===0) { - // "strict" types' values may never be null; null values will later be cast to the type specified - $this->isNullable[] = false; - $binding = substr($binding, 7); - } else { - $this->isNullable[] = true; - } - if (!array_key_exists($binding, self::TYPES)) { + $bindId = self::TYPES[trim(strtolower($binding))] ?? 0; + if (!$bindId) { throw new Exception("paramTypeInvalid", $binding); // @codeCoverageIgnore } - $this->types[] = self::TYPES[$binding]; + $this->types[] = $bindId; } } if (!$append) { @@ -61,27 +68,16 @@ abstract class AbstractStatement implements Statement { return true; } - protected function cast($v, string $t, bool $nullable) { + protected function cast($v, int $t) { switch ($t) { - case "datetime": + case self::T_DATETIME: + return Date::transform($v, "sql"); + case self::T_DATETIME + self::T_NOT_NULL: $v = Date::transform($v, "sql"); - if (is_null($v) && !$nullable) { - $v = 0; - $v = Date::transform($v, "sql"); - } - return $v; - case "integer": - return ValueInfo::normalize($v, ValueInfo::T_INT | ($nullable ? ValueInfo::M_NULL : 0), null, "sql"); - case "float": - return ValueInfo::normalize($v, ValueInfo::T_FLOAT | ($nullable ? ValueInfo::M_NULL : 0), null, "sql"); - case "binary": - case "string": - return ValueInfo::normalize($v, ValueInfo::T_STRING | ($nullable ? ValueInfo::M_NULL : 0), null, "sql"); - case "boolean": - $v = ValueInfo::normalize($v, ValueInfo::T_BOOL | ($nullable ? ValueInfo::M_NULL : 0), null, "sql"); - return is_null($v) ? $v : (int) $v; + return $v ? $v : "0001-01-01 00:00:00"; default: - throw new Exception("paramTypeUnknown", $type); // @codeCoverageIgnore + $v = ValueInfo::normalize($v, self::TYPE_NORM_MAP[$t], null, "sql"); + return is_bool($v) ? (int) $v : $v; } } @@ -92,8 +88,8 @@ abstract class AbstractStatement implements Statement { // recursively flatten any arrays, which may be provided for SET or IN() clauses $a += $this->bindValues($value, $a); } elseif (array_key_exists($a, $this->types)) { - $value = $this->cast($value, $this->types[$a], $this->isNullable[$a]); - $this->bindValue($value, $this->types[$a], ++$a); + $value = $this->cast($value, $this->types[$a]); + $this->bindValue($value, $this->types[$a] % self::T_NOT_NULL, ++$a); } else { throw new Exception("paramTypeMissing", $a+1); } @@ -102,7 +98,7 @@ abstract class AbstractStatement implements Statement { // SQLite will happily substitute null for a missing value, but other engines (viz. PostgreSQL) produce an error if (is_null($offset)) { while ($a < sizeof($this->types)) { - $this->bindValue(null, $this->types[$a], ++$a); + $this->bindValue(null, $this->types[$a] % self::T_NOT_NULL, ++$a); } } return $a - $offset; diff --git a/lib/Db/MySQL/Statement.php b/lib/Db/MySQL/Statement.php index 9612615a..acbf4a5d 100644 --- a/lib/Db/MySQL/Statement.php +++ b/lib/Db/MySQL/Statement.php @@ -14,12 +14,12 @@ class Statement extends \JKingWeb\Arsse\Db\AbstractStatement { use ExceptionBuilder; const BINDINGS = [ - "integer" => "i", - "float" => "d", - "datetime" => "s", - "binary" => "b", - "string" => "s", - "boolean" => "i", + self::T_INTEGER => "i", + self::T_FLOAT => "d", + self::T_DATETIME => "s", + self::T_BINARY => "b", + self::T_STRING => "s", + self::T_BOOLEAN => "i", ]; protected $db; @@ -93,11 +93,11 @@ class Statement extends \JKingWeb\Arsse\Db\AbstractStatement { return new Result($r, [$changes, $lastId], $this); } - protected function bindValue($value, string $type, int $position): bool { + protected function bindValue($value, int $type, int $position): bool { // this is a bit of a hack: we collect values (and MySQL bind types) here so that we can take // advantage of the work done by bindValues() even though MySQL requires everything to be bound // all at once; we also segregate large values for later packetization - if (($type === "binary" && !is_null($value)) || (is_string($value) && strlen($value) > $this->packetSize)) { + if (($type == self::T_BINARY && !is_null($value)) || (is_string($value) && strlen($value) > $this->packetSize)) { $this->values[] = null; $this->longs[$position - 1] = $value; $this->binds .= "b"; @@ -112,7 +112,7 @@ class Statement extends \JKingWeb\Arsse\Db\AbstractStatement { $out = ""; for ($b = 1; $b < sizeof($query); $b++) { $a = $b - 1; - $mark = (($types[$a] ?? "") === "datetime") ? "cast(? as datetime(0))" : "?"; + $mark = (($types[$a] ?? 0) % self::T_NOT_NULL == self::T_DATETIME) ? "cast(? as datetime(0))" : "?"; $out .= $query[$a].$mark; } $out .= array_pop($query); diff --git a/lib/Db/PDOStatement.php b/lib/Db/PDOStatement.php index 594ecf8e..2175231f 100644 --- a/lib/Db/PDOStatement.php +++ b/lib/Db/PDOStatement.php @@ -10,12 +10,12 @@ abstract class PDOStatement extends AbstractStatement { use PDOError; const BINDINGS = [ - "integer" => \PDO::PARAM_INT, - "float" => \PDO::PARAM_STR, - "datetime" => \PDO::PARAM_STR, - "binary" => \PDO::PARAM_LOB, - "string" => \PDO::PARAM_STR, - "boolean" => \PDO::PARAM_INT, // FIXME: using \PDO::PARAM_BOOL leads to incompatibilities with versions of SQLite bundled prior to PHP 7.3 + self::T_INTEGER => \PDO::PARAM_INT, + self::T_FLOAT => \PDO::PARAM_STR, + self::T_DATETIME => \PDO::PARAM_STR, + self::T_BINARY => \PDO::PARAM_LOB, + self::T_STRING => \PDO::PARAM_STR, + self::T_BOOLEAN => \PDO::PARAM_INT, // FIXME: using \PDO::PARAM_BOOL leads to incompatibilities with versions of SQLite bundled prior to PHP 7.3 ]; protected $st; @@ -55,7 +55,7 @@ abstract class PDOStatement extends AbstractStatement { return new PDOResult($this->db, $this->st); } - protected function bindValue($value, string $type, int $position): bool { + protected function bindValue($value, int $type, int $position): bool { return $this->st->bindValue($position, $value, is_null($value) ? \PDO::PARAM_NULL : self::BINDINGS[$type]); } } diff --git a/lib/Db/PostgreSQL/Statement.php b/lib/Db/PostgreSQL/Statement.php index df74e3dc..f5040f2d 100644 --- a/lib/Db/PostgreSQL/Statement.php +++ b/lib/Db/PostgreSQL/Statement.php @@ -14,12 +14,12 @@ class Statement extends \JKingWeb\Arsse\Db\AbstractStatement { use Dispatch; const BINDINGS = [ - "integer" => "bigint", - "float" => "decimal", - "datetime" => "timestamp(0) without time zone", - "binary" => "bytea", - "string" => "text", - "boolean" => "smallint", // FIXME: using boolean leads to incompatibilities with versions of SQLite bundled prior to PHP 7.3 + self::T_INTEGER => "bigint", + self::T_FLOAT => "decimal", + self::T_DATETIME => "timestamp(0) without time zone", + self::T_BINARY => "bytea", + self::T_STRING => "text", + self::T_BOOLEAN => "smallint", // FIXME: using boolean leads to incompatibilities with versions of SQLite bundled prior to PHP 7.3 ]; protected $db; @@ -47,7 +47,7 @@ class Statement extends \JKingWeb\Arsse\Db\AbstractStatement { } } - protected function bindValue($value, string $type, int $position): bool { + protected function bindValue($value, int $type, int $position): bool { $this->in[] = $value; return true; } @@ -59,7 +59,7 @@ class Statement extends \JKingWeb\Arsse\Db\AbstractStatement { for ($b = 1; $b < sizeof($q); $b++) { $a = $b - 1; $mark = $mungeParamMarkers ? "\$$b" : "?"; - $type = isset($types[$a]) ? "::".self::BINDINGS[$types[$a]] : ""; + $type = isset($types[$a]) ? "::".self::BINDINGS[$types[$a] % self::T_NOT_NULL] : ""; $out .= $q[$a].$mark.$type; } $out .= array_pop($q); diff --git a/lib/Db/SQLite3/Statement.php b/lib/Db/SQLite3/Statement.php index a0fb0cd4..bfae44d6 100644 --- a/lib/Db/SQLite3/Statement.php +++ b/lib/Db/SQLite3/Statement.php @@ -17,12 +17,12 @@ class Statement extends \JKingWeb\Arsse\Db\AbstractStatement { const SQLITE_CONSTRAINT = 19; const SQLITE_MISMATCH = 20; const BINDINGS = [ - "integer" => \SQLITE3_INTEGER, - "float" => \SQLITE3_FLOAT, - "datetime" => \SQLITE3_TEXT, - "binary" => \SQLITE3_BLOB, - "string" => \SQLITE3_TEXT, - "boolean" => \SQLITE3_INTEGER, + self::T_INTEGER => \SQLITE3_INTEGER, + self::T_FLOAT => \SQLITE3_FLOAT, + self::T_DATETIME => \SQLITE3_TEXT, + self::T_BINARY => \SQLITE3_BLOB, + self::T_STRING => \SQLITE3_TEXT, + self::T_BOOLEAN => \SQLITE3_INTEGER, ]; protected $db; @@ -68,7 +68,7 @@ class Statement extends \JKingWeb\Arsse\Db\AbstractStatement { return new Result($r, [$changes, $lastId], $this); } - protected function bindValue($value, string $type, int $position): bool { + protected function bindValue($value, int $type, int $position): bool { return $this->st->bindValue($position, $value, is_null($value) ? \SQLITE3_NULL : self::BINDINGS[$type]); } } diff --git a/lib/Db/Statement.php b/lib/Db/Statement.php index b59e075b..b85ceca4 100644 --- a/lib/Db/Statement.php +++ b/lib/Db/Statement.php @@ -8,24 +8,48 @@ namespace JKingWeb\Arsse\Db; interface Statement { const TYPES = [ - "int" => "integer", - "integer" => "integer", - "float" => "float", - "double" => "float", - "real" => "float", - "numeric" => "float", - "datetime" => "datetime", - "timestamp" => "datetime", - "blob" => "binary", - "bin" => "binary", - "binary" => "binary", - "text" => "string", - "string" => "string", - "str" => "string", - "bool" => "boolean", - "boolean" => "boolean", - "bit" => "boolean", + 'int' => self::T_INTEGER, + 'integer' => self::T_INTEGER, + 'float' => self::T_FLOAT, + 'double' => self::T_FLOAT, + 'real' => self::T_FLOAT, + 'numeric' => self::T_FLOAT, + 'datetime' => self::T_DATETIME, + 'timestamp' => self::T_DATETIME, + 'blob' => self::T_BINARY, + 'bin' => self::T_BINARY, + 'binary' => self::T_BINARY, + 'text' => self::T_STRING, + 'string' => self::T_STRING, + 'str' => self::T_STRING, + 'bool' => self::T_BOOLEAN, + 'boolean' => self::T_BOOLEAN, + 'bit' => self::T_BOOLEAN, + 'strict int' => self::T_NOT_NULL + self::T_INTEGER, + 'strict integer' => self::T_NOT_NULL + self::T_INTEGER, + 'strict float' => self::T_NOT_NULL + self::T_FLOAT, + 'strict double' => self::T_NOT_NULL + self::T_FLOAT, + 'strict real' => self::T_NOT_NULL + self::T_FLOAT, + 'strict numeric' => self::T_NOT_NULL + self::T_FLOAT, + 'strict datetime' => self::T_NOT_NULL + self::T_DATETIME, + 'strict timestamp' => self::T_NOT_NULL + self::T_DATETIME, + 'strict blob' => self::T_NOT_NULL + self::T_BINARY, + 'strict bin' => self::T_NOT_NULL + self::T_BINARY, + 'strict binary' => self::T_NOT_NULL + self::T_BINARY, + 'strict text' => self::T_NOT_NULL + self::T_STRING, + 'strict string' => self::T_NOT_NULL + self::T_STRING, + 'strict str' => self::T_NOT_NULL + self::T_STRING, + 'strict bool' => self::T_NOT_NULL + self::T_BOOLEAN, + 'strict boolean' => self::T_NOT_NULL + self::T_BOOLEAN, + 'strict bit' => self::T_NOT_NULL + self::T_BOOLEAN, ]; + const T_INTEGER = 1; + const T_STRING = 2; + const T_BOOLEAN = 3; + const T_DATETIME = 4; + const T_FLOAT = 5; + const T_BINARY = 6; + const T_NOT_NULL = 100; public function run(...$values): Result; public function runArray(array $values = []): Result; diff --git a/tests/cases/Db/BaseStatement.php b/tests/cases/Db/BaseStatement.php index bd719aac..cdc74a72 100644 --- a/tests/cases/Db/BaseStatement.php +++ b/tests/cases/Db/BaseStatement.php @@ -143,7 +143,7 @@ abstract class BaseStatement extends \JKingWeb\Arsse\Test\AbstractTest { 'Null as strict integer' => [null, "strict integer", "0"], 'Null as strict float' => [null, "strict float", "0.0"], 'Null as strict string' => [null, "strict string", "''"], - 'Null as strict datetime' => [null, "strict datetime", "'1970-01-01 00:00:00'"], + 'Null as strict datetime' => [null, "strict datetime", "'0001-01-01 00:00:00'"], 'Null as strict boolean' => [null, "strict boolean", "0"], 'True as integer' => [true, "integer", "1"], 'True as float' => [true, "float", "1.0"], @@ -153,7 +153,7 @@ abstract class BaseStatement extends \JKingWeb\Arsse\Test\AbstractTest { 'True as strict integer' => [true, "strict integer", "1"], 'True as strict float' => [true, "strict float", "1.0"], 'True as strict string' => [true, "strict string", "'1'"], - 'True as strict datetime' => [true, "strict datetime", "'1970-01-01 00:00:00'"], + 'True as strict datetime' => [true, "strict datetime", "'0001-01-01 00:00:00'"], 'True as strict boolean' => [true, "strict boolean", "1"], 'False as integer' => [false, "integer", "0"], 'False as float' => [false, "float", "0.0"], @@ -163,7 +163,7 @@ abstract class BaseStatement extends \JKingWeb\Arsse\Test\AbstractTest { 'False as strict integer' => [false, "strict integer", "0"], 'False as strict float' => [false, "strict float", "0.0"], 'False as strict string' => [false, "strict string", "''"], - 'False as strict datetime' => [false, "strict datetime", "'1970-01-01 00:00:00'"], + 'False as strict datetime' => [false, "strict datetime", "'0001-01-01 00:00:00'"], 'False as strict boolean' => [false, "strict boolean", "0"], 'Integer as integer' => [2112, "integer", "2112"], 'Integer as float' => [2112, "float", "2112.0"], @@ -213,7 +213,7 @@ abstract class BaseStatement extends \JKingWeb\Arsse\Test\AbstractTest { 'ASCII string as strict integer' => ["Random string", "strict integer", "0"], 'ASCII string as strict float' => ["Random string", "strict float", "0.0"], 'ASCII string as strict string' => ["Random string", "strict string", "'Random string'"], - 'ASCII string as strict datetime' => ["Random string", "strict datetime", "'1970-01-01 00:00:00'"], + 'ASCII string as strict datetime' => ["Random string", "strict datetime", "'0001-01-01 00:00:00'"], 'ASCII string as strict boolean' => ["Random string", "strict boolean", "1"], 'UTF-8 string as integer' => ["\u{e9}", "integer", "0"], 'UTF-8 string as float' => ["\u{e9}", "float", "0.0"], @@ -223,7 +223,7 @@ abstract class BaseStatement extends \JKingWeb\Arsse\Test\AbstractTest { 'UTF-8 string as strict integer' => ["\u{e9}", "strict integer", "0"], 'UTF-8 string as strict float' => ["\u{e9}", "strict float", "0.0"], 'UTF-8 string as strict string' => ["\u{e9}", "strict string", "char(233)"], - 'UTF-8 string as strict datetime' => ["\u{e9}", "strict datetime", "'1970-01-01 00:00:00'"], + 'UTF-8 string as strict datetime' => ["\u{e9}", "strict datetime", "'0001-01-01 00:00:00'"], 'UTF-8 string as strict boolean' => ["\u{e9}", "strict boolean", "1"], 'ISO 8601 string as integer' => ["2017-01-09T13:11:17", "integer", "0"], 'ISO 8601 string as float' => ["2017-01-09T13:11:17", "float", "0.0"], @@ -306,7 +306,7 @@ abstract class BaseStatement extends \JKingWeb\Arsse\Test\AbstractTest { '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", "'1970-01-01 00:00:00'"], + '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"], 'ISO 8601 string as binary' => ["2017-01-09T13:11:17", "binary", "x'323031372d30312d30395431333a31313a3137'"], 'ISO 8601 string as strict binary' => ["2017-01-09T13:11:17", "strict binary", "x'323031372d30312d30395431333a31313a3137'"], From 21fdd66d3796f0df42941921441c212a55eec00e Mon Sep 17 00:00:00 2001 From: "J. King" Date: Fri, 1 Mar 2019 22:36:25 -0500 Subject: [PATCH 028/142] Work around limit to SQL parameter placeholders for IN() clauses Improves #150 LIKE-based matches also need to be similarly conservative --- lib/Database.php | 300 +++++++++++-------------- lib/Db/Driver.php | 6 + lib/Db/MySQL/Driver.php | 4 + lib/Db/PDODriver.php | 4 + lib/Db/PostgreSQL/Driver.php | 4 + lib/Db/SQLite3/Driver.php | 4 + tests/cases/Database/SeriesArticle.php | 7 +- tests/cases/Db/BaseDriver.php | 4 + 8 files changed, 155 insertions(+), 178 deletions(-) diff --git a/lib/Database.php b/lib/Database.php index 61eefa58..f0987c33 100644 --- a/lib/Database.php +++ b/lib/Database.php @@ -38,8 +38,10 @@ use JKingWeb\Arsse\Misc\ValueInfo; class Database { /** The version number of the latest schema the interface is aware of */ const SCHEMA_VERSION = 4; - /** The maximum number of articles to mark in one query without chunking */ - const LIMIT_ARTICLES = 50; + /** The size of a set of values beyond which the set will be embedded into the query text */ + const LIMIT_SET_SIZE = 25; + /** The length of a string in an embedded set beyond which a parameter placeholder will be used for the string */ + const LIMIT_SET_STRING_LENGTH = 200; /** A map database driver short-names and their associated class names */ const DRIVER_NAMES = [ 'sqlite3' => \JKingWeb\Arsse\Db\SQLite3\Driver::class, @@ -126,29 +128,50 @@ class Database { return $out; } - /** Conputes the contents of an SQL "IN()" clause, producing one parameter placeholder for each input value + /** Computes the contents of an SQL "IN()" clause, for each input value either embedding the value or producing a parameter placeholder * - * Returns an indexed array containing the clause text, an array of types, and the array of values + * Returns an indexed array containing the clause text, an array of types, and an array of values. Note that the array of output values may not match the array of input values * * @param array $values Arbitrary values * @param string $type A single data type applied to each value */ protected function generateIn(array $values, string $type): array { - $out = [ - "", // query clause - [], // binding types - $values, // binding values - ]; - if (sizeof($values)) { - // the query clause is just a series of question marks separated by commas - $out[0] = implode(",", array_fill(0, sizeof($values), "?")); - // the binding types are just a repetition of the supplied type - $out[1] = array_fill(0, sizeof($values), $type); - } else { + if (!sizeof($values)) { // if the set is empty, some databases require an explicit null - $out[0] = "null"; + return ["null", [], []]; + } + $t = (Statement::TYPES[$type] ?? 0) % Statement::T_NOT_NULL; + if (sizeof($values) > self::LIMIT_SET_SIZE && ($t == Statement::T_INTEGER || $t == Statement::T_STRING)) { + $clause = []; + $params = []; + $count = 0; + $convType = Db\AbstractStatement::TYPE_NORM_MAP[Statement::TYPES[$type]]; + foreach($values as $v) { + $v = ValueInfo::normalize($v, $convType, null, "sql"); + if (is_null($v)) { + // nulls are pointless to have + continue; + } elseif (is_string($v)) { + if (strlen($v) > self::LIMIT_SET_STRING_LENGTH) { + $clause[] = "?"; + $params[] = $v; + } else { + $clause[] = $this->db->literalString($v); + } + } else { + $clause[] = ValueInfo::normalize($v, ValueInfo::T_STRING, null, "sql"); + } + $count++; + } + if (!$count) { + // the set is actually empty + return ["null", [], []]; + } else { + return [implode(",", $clause), array_fill(0, sizeof($params), $type), $params]; + } + } else { + return [implode(",", array_fill(0, sizeof($values), "?")), array_fill(0, sizeof($values), $type), $values]; } - return $out; } /** Computes basic LIKE-based text search constraints for use in a WHERE clause @@ -1074,10 +1097,10 @@ class Database { */ public function feedMatchIds(int $feedID, array $ids = [], array $hashesUT = [], array $hashesUC = [], array $hashesTC = []): Db\Result { // compile SQL IN() clauses and necessary type bindings for the four identifier lists - list($cId, $tId) = $this->generateIn($ids, "str"); - list($cHashUT, $tHashUT) = $this->generateIn($hashesUT, "str"); - list($cHashUC, $tHashUC) = $this->generateIn($hashesUC, "str"); - list($cHashTC, $tHashTC) = $this->generateIn($hashesTC, "str"); + list($cId, $tId, $vId) = $this->generateIn($ids, "str"); + list($cHashUT, $tHashUT, $vHashUT) = $this->generateIn($hashesUT, "str"); + list($cHashUC, $tHashUC, $vHashUC) = $this->generateIn($hashesUC, "str"); + list($cHashTC, $tHashTC, $vHashTC) = $this->generateIn($hashesTC, "str"); // perform the query return $articles = $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))", @@ -1086,7 +1109,7 @@ class Database { $tHashUT, $tHashUC, $tHashTC - )->run($feedID, $ids, $hashesUT, $hashesUC, $hashesTC); + )->run($feedID, $vId, $vHashUT, $vHashUC, $vHashTC); } /** Computes an SQL query to find and retrieve data about articles in the database @@ -1180,25 +1203,25 @@ class Database { $q->setLimit($context->limit, $context->offset); // handle the simple context options $options = [ - // each context array consists of a column identifier (see $colDefs above), a comparison operator, a data type, an option to pair with for BETWEEN evaluation, and an upper bound if the value is an array - "edition" => ["edition", "=", "int", "", 1], - "editions" => ["edition", "in", "int", "", self::LIMIT_ARTICLES], - "article" => ["id", "=", "int", "", 1], - "articles" => ["id", "in", "int", "", self::LIMIT_ARTICLES], - "oldestArticle" => ["id", ">=", "int", "latestArticle", 1], - "latestArticle" => ["id", "<=", "int", "oldestArticle", 1], - "oldestEdition" => ["edition", ">=", "int", "latestEdition", 1], - "latestEdition" => ["edition", "<=", "int", "oldestEdition", 1], - "modifiedSince" => ["modified_date", ">=", "datetime", "notModifiedSince", 1], - "notModifiedSince" => ["modified_date", "<=", "datetime", "modifiedSince", 1], - "markedSince" => ["marked_date", ">=", "datetime", "notMarkedSince", 1], - "notMarkedSince" => ["marked_date", "<=", "datetime", "markedSince", 1], - "folderShallow" => ["folder", "=", "int", "", 1], - "subscription" => ["subscription", "=", "int", "", 1], - "unread" => ["unread", "=", "bool", "", 1], - "starred" => ["starred", "=", "bool", "", 1], + // each context array consists of a column identifier (see $colDefs above), a comparison operator, a data type, and an option to pair with for BETWEEN evaluation + "edition" => ["edition", "=", "int", ""], + "editions" => ["edition", "in", "int", ""], + "article" => ["id", "=", "int", ""], + "articles" => ["id", "in", "int", ""], + "oldestArticle" => ["id", ">=", "int", "latestArticle"], + "latestArticle" => ["id", "<=", "int", "oldestArticle"], + "oldestEdition" => ["edition", ">=", "int", "latestEdition"], + "latestEdition" => ["edition", "<=", "int", "oldestEdition"], + "modifiedSince" => ["modified_date", ">=", "datetime", "notModifiedSince"], + "notModifiedSince" => ["modified_date", "<=", "datetime", "modifiedSince"], + "markedSince" => ["marked_date", ">=", "datetime", "notMarkedSince"], + "notMarkedSince" => ["marked_date", "<=", "datetime", "markedSince"], + "folderShallow" => ["folder", "=", "int", ""], + "subscription" => ["subscription", "=", "int", ""], + "unread" => ["unread", "=", "bool", ""], + "starred" => ["starred", "=", "bool", ""], ]; - foreach ($options as $m => list($col, $op, $type, $pair, $max)) { + foreach ($options as $m => list($col, $op, $type, $pair)) { if (!$context->$m()) { // context is not being used continue; @@ -1206,8 +1229,6 @@ class Database { // context option is an array of values if (!$context->$m) { throw new Db\ExceptionInput("tooShort", ['field' => $m, 'action' => $this->caller(), 'min' => 1]); // must have at least one array element - } elseif (sizeof($context->$m) > $max) { - throw new Db\ExceptionInput("tooLong", ['field' => $m, 'action' => $this->caller(), 'max' => $max]); // @codeCoverageIgnore } list($clause, $types, $values) = $this->generateIn($context->$m, $type); $q->setWhere("{$colDefs[$col]} $op ($clause)", $types, $values); @@ -1224,7 +1245,7 @@ class Database { } } // further handle exclusionary options if specified - foreach ($options as $m => list($col, $op, $type, $pair, $max)) { + foreach ($options as $m => list($col, $op, $type, $pair)) { if (!method_exists($context->not, $m) || !$context->not->$m()) { // context option is not being used continue; @@ -1232,8 +1253,6 @@ class Database { if (!$context->not->$m) { // for exclusions we don't care if the array is empty continue; - } elseif (sizeof($context->not->$m) > $max) { - throw new Db\ExceptionInput("tooLong", ['field' => "$m (not)", 'action' => $this->caller(), 'max' => $max]); } list($clause, $types, $values) = $this->generateIn($context->not->$m, $type); $q->setWhereNot("{$colDefs[$col]} $op ($clause)", $types, $values); @@ -1315,35 +1334,10 @@ class Database { $q->setWhereNot(...$this->generateSearch($context->not->$m, $cols, true)); } // return the query + //var_export((string) $q); return $q; } - /** Chunk a context with more than the maximum number of articles or editions into an array of contexts */ - protected function contextChunk(Context $context): array { - $exception = ""; - if ($context->editions()) { - // editions take precedence over articles - if (sizeof($context->editions) > self::LIMIT_ARTICLES) { - $exception = "editions"; - } - } elseif ($context->articles()) { - if (sizeof($context->articles) > self::LIMIT_ARTICLES) { - $exception = "articles"; - } - } - if ($exception) { - $out = []; - $list = array_chunk($context->$exception, self::LIMIT_ARTICLES); - foreach ($list as $chunk) { - $out[] = (clone $context)->$exception($chunk); - } - return $out; - } else { - return []; - } - } - - /** Lists articles in the database which match a given query context * * If an empty column list is supplied, a count of articles is returned instead @@ -1357,22 +1351,11 @@ class Database { throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); } $context = $context ?? new Context; - // if the context has more articles or editions than we can process in one query, perform a series of queries and return an aggregate result - if ($contexts = $this->contextChunk($context)) { - $out = []; - $tr = $this->begin(); - foreach ($contexts as $context) { - $out[] = $this->articleList($user, $context, $fields); - } - $tr->commit(); - return new Db\ResultAggregate(...$out); - } else { - $q = $this->articleQuery($user, $context, $fields); - $q->setOrder("arsse_articles.edited".($context->reverse ? " desc" : "")); - $q->setOrder("latest_editions.edition".($context->reverse ? " desc" : "")); - // perform the query and return results - return $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues()); - } + $q = $this->articleQuery($user, $context, $fields); + $q->setOrder("arsse_articles.edited".($context->reverse ? " desc" : "")); + $q->setOrder("latest_editions.edition".($context->reverse ? " desc" : "")); + // perform the query and return results + return $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues()); } /** Returns a count of articles which match the given query context @@ -1385,19 +1368,8 @@ class Database { throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); } $context = $context ?? new Context; - // if the context has more articles or editions than we can process in one query, perform a series of queries and return an aggregate result - if ($contexts = $this->contextChunk($context)) { - $out = 0; - $tr = $this->begin(); - foreach ($contexts as $context) { - $out += $this->articleCount($user, $context); - } - $tr->commit(); - return $out; - } else { - $q = $this->articleQuery($user, $context, []); - return (int) $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues())->getValue(); - } + $q = $this->articleQuery($user, $context, []); + return (int) $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues())->getValue(); } /** Applies one or multiple modifications to all articles matching the given query context @@ -1425,80 +1397,69 @@ class Database { return 0; } $context = $context ?? new Context; - // if the context has more articles or editions than we can process in one query, perform a series of queries and return an aggregate result - if ($contexts = $this->contextChunk($context)) { - $out = 0; - $tr = $this->begin(); - foreach ($contexts as $context) { - $out += $this->articleMark($user, $data, $context); + $tr = $this->begin(); + $out = 0; + if ($data['read'] || $data['starred'] || 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 + $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 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"); + // set read marks + $q = $this->articleQuery($user, $context, ["id", "subscription"]); + $q->setWhere("arsse_marks.read <> coalesce(?,arsse_marks.read)", "bool", $data['read']); + $q->pushCTE("target_articles(article,subscription)"); + $q->setBody("UPDATE arsse_marks set \"read\" = ?, touched = 1 where article in(select article from target_articles) and subscription in(select distinct subscription from target_articles)", "bool", $data['read']); + $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues()); + // get the articles associated with the requested editions + if ($context->edition()) { + $context->article($this->articleValidateEdition($user, $context->edition)['article'])->edition(null); + } else { + $context->articles($this->editionArticle(...$context->editions))->editions(null); } - $tr->commit(); - return $out; - } else { - $tr = $this->begin(); - $out = 0; - if ($data['read'] || $data['starred'] || 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 - $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 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"); - // set read marks + // set starred 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.read <> coalesce(?,arsse_marks.read)", "bool", $data['read']); + $q->setWhere("(arsse_marks.note <> coalesce(?,arsse_marks.note) or arsse_marks.starred <> coalesce(?,arsse_marks.starred))", ["str", "bool"], [$data['note'], $data['starred']]); $q->pushCTE("target_articles(article,subscription)"); - $q->setBody("UPDATE arsse_marks set \"read\" = ?, touched = 1 where article in(select article from target_articles) and subscription in(select distinct subscription from target_articles)", "bool", $data['read']); + $data = array_filter($data, function($v) { + return isset($v); + }); + list($set, $setTypes, $setValues) = $this->generateSet($data, ['starred' => "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()); + } + // 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(); + } else { + if (!isset($data['read']) && ($context->edition() || $context->editions())) { // get the articles associated with the requested editions if ($context->edition()) { $context->article($this->articleValidateEdition($user, $context->edition)['article'])->edition(null); } else { $context->articles($this->editionArticle(...$context->editions))->editions(null); } - // set starred 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->pushCTE("target_articles(article,subscription)"); - $data = array_filter($data, function($v) { - return isset($v); - }); - list($set, $setTypes, $setValues) = $this->generateSet($data, ['starred' => "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()); + if (!$context->article && !$context->articles) { + return 0; } - // 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(); - } else { - if (!isset($data['read']) && ($context->edition() || $context->editions())) { - // get the articles associated with the requested editions - if ($context->edition()) { - $context->article($this->articleValidateEdition($user, $context->edition)['article'])->edition(null); - } else { - $context->articles($this->editionArticle(...$context->editions))->editions(null); - } - if (!$context->article && !$context->articles) { - return 0; - } - } - $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->pushCTE("target_articles(article,subscription)"); - $data = array_filter($data, function($v) { - return isset($v); - }); - list($set, $setTypes, $setValues) = $this->generateSet($data, ['read' => "bool", 'starred' => "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(); } - $tr->commit(); - return $out; + $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->pushCTE("target_articles(article,subscription)"); + $data = array_filter($data, function($v) { + return isset($v); + }); + list($set, $setTypes, $setValues) = $this->generateSet($data, ['read' => "bool", 'starred' => "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(); } + $tr->commit(); + return $out; } /** Returns statistics about the articles starred by the given user @@ -1685,20 +1646,9 @@ class Database { public function editionArticle(int ...$edition): array { $out = []; $context = (new Context)->editions($edition); - // if the context has more articles or editions than we can process in one query, perform a series of queries and return an aggregate result - if ($contexts = $this->contextChunk($context)) { - $articles = $editions = []; - foreach ($contexts as $context) { - $out = $this->editionArticle(...$context->editions); - $editions = array_merge($editions, array_map("intval", array_keys($out))); - $articles = array_merge($articles, array_map("intval", array_values($out))); - } - return array_combine($editions, $articles); - } else { - list($in, $inTypes) = $this->generateIn($context->editions, "int"); - $out = $this->db->prepare("SELECT id as edition, article from arsse_editions where id in($in)", $inTypes)->run($context->editions)->getAll(); - return $out ? array_combine(array_column($out, "edition"), array_column($out, "article")) : []; - } + list($in, $inTypes, $inValues) = $this->generateIn($context->editions, "int"); + $out = $this->db->prepare("SELECT id as edition, article from arsse_editions where id in($in)", $inTypes)->run($inValues)->getAll(); + return $out ? array_combine(array_column($out, "edition"), array_column($out, "article")) : []; } /** Creates a label, and returns its numeric identifier diff --git a/lib/Db/Driver.php b/lib/Db/Driver.php index 959a5500..b0f572c8 100644 --- a/lib/Db/Driver.php +++ b/lib/Db/Driver.php @@ -76,4 +76,10 @@ interface Driver { * - "like": the case-insensitive LIKE operator */ public function sqlToken(string $token): string; + + /** Returns a string literal which is properly escaped to guard against SQL injections. Delimiters are included in the output string + * + * This functionality should be avoided in favour of using statement parameters whenever possible + */ + public function literalString(string $str): string; } diff --git a/lib/Db/MySQL/Driver.php b/lib/Db/MySQL/Driver.php index 8a4fe445..edd5f771 100644 --- a/lib/Db/MySQL/Driver.php +++ b/lib/Db/MySQL/Driver.php @@ -212,4 +212,8 @@ class Driver extends \JKingWeb\Arsse\Db\AbstractDriver { public function prepareArray(string $query, array $paramTypes): \JKingWeb\Arsse\Db\Statement { return new Statement($this->db, $query, $paramTypes, $this->packetSize); } + + public function literalString(string $str): string { + return "'".$this->db->real_escape_string($str)."'"; + } } diff --git a/lib/Db/PDODriver.php b/lib/Db/PDODriver.php index c5b4f4db..df5dcc3b 100644 --- a/lib/Db/PDODriver.php +++ b/lib/Db/PDODriver.php @@ -28,4 +28,8 @@ trait PDODriver { } return new PDOResult($this->db, $r); } + + public function literalString(string $str): string { + return $this->db->quote($str); + } } diff --git a/lib/Db/PostgreSQL/Driver.php b/lib/Db/PostgreSQL/Driver.php index 08c439d3..12ad8fcd 100644 --- a/lib/Db/PostgreSQL/Driver.php +++ b/lib/Db/PostgreSQL/Driver.php @@ -221,4 +221,8 @@ class Driver extends \JKingWeb\Arsse\Db\AbstractDriver { public function prepareArray(string $query, array $paramTypes): \JKingWeb\Arsse\Db\Statement { return new Statement($this->db, $query, $paramTypes); } + + public function literalString(string $str): string { + return pg_escape_literal($this->db, $str); + } } diff --git a/lib/Db/SQLite3/Driver.php b/lib/Db/SQLite3/Driver.php index f7e47fb9..3682d03b 100644 --- a/lib/Db/SQLite3/Driver.php +++ b/lib/Db/SQLite3/Driver.php @@ -179,4 +179,8 @@ class Driver extends \JKingWeb\Arsse\Db\AbstractDriver { $this->exec((!$rollback) ? "COMMIT" : "ROLLBACK"); return true; } + + public function literalString(string $str): string { + return "'".\SQLite3::escapeString($str)."'"; + } } diff --git a/tests/cases/Database/SeriesArticle.php b/tests/cases/Database/SeriesArticle.php index 3887d785..e2aa5988 100644 --- a/tests/cases/Database/SeriesArticle.php +++ b/tests/cases/Database/SeriesArticle.php @@ -432,7 +432,7 @@ trait SeriesArticle { "Multiple unstarred articles" => [(new Context)->articles([1,2,3])->starred(false), [2,3]], "Multiple articles" => [(new Context)->articles([1,20,50]), [1,20]], "Multiple editions" => [(new Context)->editions([1,1001,50]), [1,20]], - "150 articles" => [(new Context)->articles(range(1, Database::LIMIT_ARTICLES * 3)), [1,2,3,4,5,6,7,8,19,20]], + "150 articles" => [(new Context)->articles(range(1, Database::LIMIT_SET_SIZE * 3)), [1,2,3,4,5,6,7,8,19,20]], "Search title or content 1" => [(new Context)->searchTerms(["Article"]), [1,2,3]], "Search title or content 2" => [(new Context)->searchTerms(["one", "first"]), [1]], "Search title or content 3" => [(new Context)->searchTerms(["one first"]), []], @@ -455,6 +455,7 @@ trait SeriesArticle { "Search with exclusion" => [(new Context)->searchTerms(["Article"])->not->searchTerms(["one", "two"]), [3]], "Excluded folder tree" => [(new Context)->not->folder(1), [1,2,3,4,19,20]], "Excluding label ID 2" => [(new Context)->not->label(2), [2,3,4,6,7,8,19]], + "Excluding label 'Fascinating'" => [(new Context)->not->labelName("Fascinating"), [2,3,4,6,7,8,19]], ]; } @@ -744,7 +745,7 @@ trait SeriesArticle { } public function testMarkTooManyMultipleArticles() { - $this->assertSame(7, Arsse::$db->articleMark($this->user, ['read'=>false,'starred'=>true], (new Context)->articles(range(1, Database::LIMIT_ARTICLES * 3)))); + $this->assertSame(7, Arsse::$db->articleMark($this->user, ['read'=>false,'starred'=>true], (new Context)->articles(range(1, Database::LIMIT_SET_SIZE * 3)))); } public function testMarkAMissingArticle() { @@ -907,7 +908,7 @@ trait SeriesArticle { $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(10, Arsse::$db->articleCount("john.doe@example.com", (new Context)->articles(range(1, Database::LIMIT_ARTICLES *3)))); + $this->assertSame(10, Arsse::$db->articleCount("john.doe@example.com", (new Context)->articles(range(1, Database::LIMIT_SET_SIZE * 3)))); } public function testCountArticlesWithoutAuthority() { diff --git a/tests/cases/Db/BaseDriver.php b/tests/cases/Db/BaseDriver.php index 4967e84c..74ef7c97 100644 --- a/tests/cases/Db/BaseDriver.php +++ b/tests/cases/Db/BaseDriver.php @@ -378,4 +378,8 @@ abstract class BaseDriver extends \JKingWeb\Arsse\Test\AbstractTest { $this->drv->savepointUndo(); $this->assertTrue($this->exec(str_replace("#", "3", $this->setVersion))); } + + public function testProduceAStringLiteral() { + $this->assertSame("'It''s a string!'", $this->drv->literalString("It's a string!")); + } } From 44366f48bf1a6cf96a25ba7fae5e653bbe0cd079 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Sat, 2 Mar 2019 13:53:43 -0500 Subject: [PATCH 029/142] Remove arbitrary search term limits; fixes #150 --- README.md | 1 - lib/AbstractException.php | 1 + lib/Database.php | 39 +++++++++++---------- locale/en.php | 2 ++ tests/cases/Database/SeriesArticle.php | 11 +----- tests/cases/REST/TinyTinyRSS/TestSearch.php | 1 - 6 files changed, 25 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index d4fca7f3..ab7dc2ed 100644 --- a/README.md +++ b/README.md @@ -142,7 +142,6 @@ We are not aware of any other extensions to the TTRSS protocol. If you know of a - Providing the `setArticleLabel` operation with an invalid label normally silently fails; The Arsse returns an `INVALID_USAGE` error instead - Processing of the `search` parameter of the `getHeadlines` operation differs in the following ways: - Values other than `"true"` or `"false"` for the `unread`, `star`, and `pub` special keywords treat the entire token as a search term rather than as `"false"` - - Limits are placed on the number of search terms: ten each for `title`, `author`, and `note`, and twenty for content searching; exceeding the limits will yield a non-standard `TOO_MANY_SEARCH_TERMS` error - Invalid dates are ignored rather than assumed to be `"1970-01-01"` - Only a single negative date is allowed (this is a known bug rather than intentional) - Dates are always relative to UTC diff --git a/lib/AbstractException.php b/lib/AbstractException.php index 0249678e..7df22630 100644 --- a/lib/AbstractException.php +++ b/lib/AbstractException.php @@ -11,6 +11,7 @@ abstract class AbstractException extends \Exception { "Exception.uncoded" => -1, "Exception.unknown" => 10000, "Exception.constantUnknown" => 10001, + "Exception.arrayEmpty" => 10002, "ExceptionType.strictFailure" => 10011, "ExceptionType.typeUnknown" => 10012, "Lang/Exception.defaultFileMissing" => 10101, diff --git a/lib/Database.php b/lib/Database.php index f0987c33..7c47ad39 100644 --- a/lib/Database.php +++ b/lib/Database.php @@ -185,23 +185,33 @@ class Database { * @param boolean $matchAny Whether the search is successful when it matches any (true) or all (false) terms */ protected function generateSearch(array $terms, array $cols, bool $matchAny = false): array { + if (!$cols) { + throw new Exception("arrayEmpty", "cols"); // @codeCoverageIgnore + } $clause = []; $types = []; $values = []; $like = $this->db->sqlToken("like"); + $embedSet = sizeof($terms) > ((int) (self::LIMIT_SET_SIZE / sizeof($cols))); foreach($terms as $term) { + $embedTerm = ($embedSet && strlen($term) <= self::LIMIT_SET_STRING_LENGTH); $term = str_replace(["%", "_", "^"], ["^%", "^_", "^^"], $term); $term = "%$term%"; + $term = $embedTerm ? $this->db->literalString($term) : $term; $spec = []; foreach ($cols as $col) { - $spec[] = "$col $like ? escape '^'"; - $types[] = "str"; - $values[] = $term; + if ($embedTerm) { + $spec[] = "$col $like $term escape '^'"; + } else { + $spec[] = "$col $like ? escape '^'"; + $types[] = "str"; + $values[] = $term; + } } $clause[] = "(".implode(" or ", $spec).")"; } $glue = $matchAny ? "or" : "and"; - $clause = "(".implode(" $glue ", $clause).")"; + $clause = $clause ? "(".implode(" $glue ", $clause).")" : ""; return [$clause, $types, $values]; } @@ -1307,34 +1317,27 @@ class Database { } // handle text-matching context options $options = [ - "titleTerms" => [10, ["arsse_articles.title"]], - "searchTerms" => [20, ["arsse_articles.title", "arsse_articles.content"]], - "authorTerms" => [10, ["arsse_articles.author"]], - "annotationTerms" => [20, ["arsse_marks.note"]], + "titleTerms" => ["arsse_articles.title"], + "searchTerms" => ["arsse_articles.title", "arsse_articles.content"], + "authorTerms" => ["arsse_articles.author"], + "annotationTerms" => ["arsse_marks.note"], ]; - foreach ($options as $m => list($max, $cols)) { + foreach ($options as $m => $cols) { if (!$context->$m()) { continue; } elseif (!$context->$m) { throw new Db\ExceptionInput("tooShort", ['field' => $m, 'action' => $this->caller(), 'min' => 1]); // must have at least one array element - } elseif (sizeof($context->$m) > $max) { - throw new Db\ExceptionInput("tooLong", ['field' => $m, 'action' => $this->caller(), 'max' => $max]); } $q->setWhere(...$this->generateSearch($context->$m, $cols)); } // further handle exclusionary text-matching context options - foreach ($options as $m => list($max, $cols)) { - if (!$context->not->$m()) { + foreach ($options as $m => $cols) { + if (!$context->not->$m() || !$context->not->$m) { continue; - } elseif (!$context->not->$m) { - continue; - } elseif (sizeof($context->not->$m) > $max) { - throw new Db\ExceptionInput("tooLong", ['field' => "$m (not)", 'action' => $this->caller(), 'max' => $max]); } $q->setWhereNot(...$this->generateSearch($context->not->$m, $cols, true)); } // return the query - //var_export((string) $q); return $q; } diff --git a/locale/en.php b/locale/en.php index f576442d..5e8ad0fe 100644 --- a/locale/en.php +++ b/locale/en.php @@ -36,6 +36,8 @@ return [ 'Exception.JKingWeb/Arsse/Exception.unknown' => 'An unknown error has occurred', // indicates programming error 'Exception.JKingWeb/Arsse/Exception.constantUnknown' => 'Supplied constant value ({0}) is unknown or invalid in the context in which it was used', + // indicates programming error + 'Exception.JKingWeb/Arsse/Exception.arrayEmpty' => 'Supplied array "{0}" is empty, but should have at least one element', 'Exception.JKingWeb/Arsse/ExceptionType.strictFailure' => 'Supplied value could not be normalized to {0, select, 1 {null} 2 {boolean} diff --git a/tests/cases/Database/SeriesArticle.php b/tests/cases/Database/SeriesArticle.php index e2aa5988..f652c6f8 100644 --- a/tests/cases/Database/SeriesArticle.php +++ b/tests/cases/Database/SeriesArticle.php @@ -456,6 +456,7 @@ trait SeriesArticle { "Excluded folder tree" => [(new Context)->not->folder(1), [1,2,3,4,19,20]], "Excluding label ID 2" => [(new Context)->not->label(2), [2,3,4,6,7,8,19]], "Excluding label 'Fascinating'" => [(new Context)->not->labelName("Fascinating"), [2,3,4,6,7,8,19]], + "Search 501 terms" => [(new Context)->searchTerms(array_merge(range(1,500),[str_repeat("a", 1000)])), []], ]; } @@ -991,18 +992,8 @@ trait SeriesArticle { Arsse::$db->articleList($this->user, (new Context)->searchTerms([])); } - public function testSearchTooManyTerms() { - $this->assertException("tooLong", "Db", "ExceptionInput"); - Arsse::$db->articleList($this->user, (new Context)->searchTerms(range(1, 105))); - } - public function testSearchTooFewTermsInNote() { $this->assertException("tooShort", "Db", "ExceptionInput"); Arsse::$db->articleList($this->user, (new Context)->annotationTerms([])); } - - public function testSearchTooManyTermsInNote() { - $this->assertException("tooLong", "Db", "ExceptionInput"); - Arsse::$db->articleList($this->user, (new Context)->annotationTerms(range(1, 105))); - } } diff --git a/tests/cases/REST/TinyTinyRSS/TestSearch.php b/tests/cases/REST/TinyTinyRSS/TestSearch.php index 62ad553d..c858d1be 100644 --- a/tests/cases/REST/TinyTinyRSS/TestSearch.php +++ b/tests/cases/REST/TinyTinyRSS/TestSearch.php @@ -120,7 +120,6 @@ class TestSearch extends \JKingWeb\Arsse\Test\AbstractTest { /** @dataProvider provideSearchStrings */ public function testApplySearchToContext(string $search, $exp) { $act = Search::parse($search); - //var_export($act); $this->assertEquals($exp, $act); } } From 5efef2c2d0a4a27ba388a38b98f04cbfd0d13fc3 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Sat, 2 Mar 2019 14:59:44 -0500 Subject: [PATCH 030/142] Console command to refresh all feeds once; fixes #147 --- CHANGELOG | 6 +++++- lib/CLI.php | 11 ++++++++--- tests/cases/CLI/TestCLI.php | 10 ++++++++++ 3 files changed, 23 insertions(+), 4 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index af5159f3..0bd9c397 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,8 +1,12 @@ -Version 0.7.0 (2019-??-??) +Version 0.7.0 (2019-03-02) ========================== New features: - Support for basic freeform searching in Tiny Tiny RSS +- Console command to refresh all stale feeds once then exit + +Bug fixes: +- Ensure updating does not fail with newsfeeds larger than 250 entries Version 0.6.1 (2019-01-23) ========================== diff --git a/lib/CLI.php b/lib/CLI.php index 8ff1e1df..efb1f997 100644 --- a/lib/CLI.php +++ b/lib/CLI.php @@ -12,6 +12,7 @@ class CLI { const USAGE = << arsse.php conf save-defaults [] arsse.php user [list] @@ -23,8 +24,8 @@ Usage: arsse.php --help | -h The Arsse command-line interface currently allows you to start the refresh -daemon, refresh a specific feed by numeric ID, manage users, or save default -configuration to a sample file. +daemon, refresh all feeds or a specific feed by numeric ID, manage users, +or save default configuration to a sample file. USAGE_TEXT; protected function usage($prog): string { @@ -58,7 +59,7 @@ USAGE_TEXT; 'help' => false, ]); try { - switch ($this->command(["--help", "--version", "daemon", "feed refresh", "conf save-defaults", "user"], $args)) { + switch ($this->command(["--help", "--version", "daemon", "feed refresh", "feed refresh-all", "conf save-defaults", "user"], $args)) { case "--help": echo $this->usage($argv0).\PHP_EOL; return 0; @@ -72,6 +73,10 @@ USAGE_TEXT; case "feed refresh": $this->loadConf(); return (int) !Arsse::$db->feedUpdate((int) $args[''], true); + case "feed refresh-all": + $this->loadConf(); + $this->getService()->watch(false); + return 0; case "conf save-defaults": $file = $args['']; $file = ($file === "-" ? null : $file) ?? "php://output"; diff --git a/tests/cases/CLI/TestCLI.php b/tests/cases/CLI/TestCLI.php index c2a6b52f..608eebce 100644 --- a/tests/cases/CLI/TestCLI.php +++ b/tests/cases/CLI/TestCLI.php @@ -74,6 +74,16 @@ class TestCLI extends \JKingWeb\Arsse\Test\AbstractTest { Phake::verify($this->cli)->getService; } + public function testRefreshAllFeeds() { + $srv = Phake::mock(Service::class); + Phake::when($srv)->watch->thenReturn(new \DateTimeImmutable); + Phake::when($this->cli)->getService->thenReturn($srv); + $this->assertConsole($this->cli, "arsse.php feed refresh-all", 0); + $this->assertLoaded(true); + Phake::verify($srv)->watch(false); + Phake::verify($this->cli)->getService; + } + /** @dataProvider provideFeedUpdates */ public function testRefreshAFeed(string $cmd, int $exitStatus, string $output) { Arsse::$db = Phake::mock(Database::class); From fb1bdbfb372869c19ca58f9ab142652df402cc9f Mon Sep 17 00:00:00 2001 From: "J. King" Date: Sun, 3 Mar 2019 12:10:18 -0500 Subject: [PATCH 031/142] Database schema for subscription tags --- lib/Database.php | 2 +- sql/MySQL/0.sql | 10 +++++----- sql/MySQL/1.sql | 4 ++-- sql/MySQL/2.sql | 2 +- sql/MySQL/3.sql | 2 +- sql/MySQL/4.sql | 23 +++++++++++++++++++++++ sql/PostgreSQL/0.sql | 2 +- sql/PostgreSQL/1.sql | 2 +- sql/PostgreSQL/2.sql | 2 +- sql/PostgreSQL/3.sql | 2 +- sql/PostgreSQL/4.sql | 23 +++++++++++++++++++++++ sql/SQLite3/0.sql | 2 +- sql/SQLite3/1.sql | 4 ++-- sql/SQLite3/2.sql | 2 +- sql/SQLite3/3.sql | 2 +- sql/SQLite3/4.sql | 25 +++++++++++++++++++++++++ 16 files changed, 90 insertions(+), 19 deletions(-) create mode 100644 sql/MySQL/4.sql create mode 100644 sql/PostgreSQL/4.sql create mode 100644 sql/SQLite3/4.sql diff --git a/lib/Database.php b/lib/Database.php index 7c47ad39..cecfb33f 100644 --- a/lib/Database.php +++ b/lib/Database.php @@ -37,7 +37,7 @@ use JKingWeb\Arsse\Misc\ValueInfo; */ class Database { /** The version number of the latest schema the interface is aware of */ - const SCHEMA_VERSION = 4; + const SCHEMA_VERSION = 5; /** The size of a set of values beyond which the set will be embedded into the query text */ const LIMIT_SET_SIZE = 25; /** The length of a string in an embedded set beyond which a parameter placeholder will be used for the string */ diff --git a/sql/MySQL/0.sql b/sql/MySQL/0.sql index f6b00e26..3901ad74 100644 --- a/sql/MySQL/0.sql +++ b/sql/MySQL/0.sql @@ -5,7 +5,7 @@ -- Please consult the SQLite 3 schemata for commented version create table arsse_meta( - `key` varchar(255) primary key, + "key" varchar(255) primary key, value longtext ) character set utf8mb4; @@ -21,9 +21,9 @@ create table arsse_users( create table arsse_users_meta( owner varchar(255) not null references arsse_users(id) on delete cascade on update cascade, - `key` varchar(255) not null, + "key" varchar(255) not null, value varchar(255), - primary key(owner,`key`) + primary key(owner,"key") ) character set utf8mb4; create table arsse_folders( @@ -93,7 +93,7 @@ create table arsse_enclosures( create table arsse_marks( article bigint not null references arsse_articles(id) on delete cascade, subscription bigint not null references arsse_subscriptions(id) on delete cascade on update cascade, - `read` boolean not null default 0, + "read" boolean not null default 0, starred boolean not null default 0, modified datetime(0) not null default CURRENT_TIMESTAMP, primary key(article,subscription) @@ -110,4 +110,4 @@ create table arsse_categories( name varchar(255) ) character set utf8mb4; -insert into arsse_meta(`key`,value) values('schema_version','1'); +insert into arsse_meta("key",value) values('schema_version','1'); diff --git a/sql/MySQL/1.sql b/sql/MySQL/1.sql index eb4ce5f1..f7a85425 100644 --- a/sql/MySQL/1.sql +++ b/sql/MySQL/1.sql @@ -8,7 +8,7 @@ create table arsse_sessions ( id varchar(255) primary key, created datetime(0) not null default CURRENT_TIMESTAMP, expires datetime(0) not null, - `user` varchar(255) not null references arsse_users(id) on delete cascade on update cascade + "user" varchar(255) not null references arsse_users(id) on delete cascade on update cascade ) character set utf8mb4; create table arsse_labels ( @@ -30,4 +30,4 @@ create table arsse_label_members ( alter table arsse_marks add column note longtext; -update arsse_meta set value = '2' where `key` = 'schema_version'; +update arsse_meta set value = '2' where "key" = 'schema_version'; diff --git a/sql/MySQL/2.sql b/sql/MySQL/2.sql index d63cbb6a..feaf4c9a 100644 --- a/sql/MySQL/2.sql +++ b/sql/MySQL/2.sql @@ -20,4 +20,4 @@ alter table arsse_articles convert to character set utf8mb4 collate utf8mb4_unic alter table arsse_categories convert to character set utf8mb4 collate utf8mb4_unicode_ci; alter table arsse_labels convert to character set utf8mb4 collate utf8mb4_unicode_ci; -update arsse_meta set value = '3' where `key` = 'schema_version'; +update arsse_meta set value = '3' where "key" = 'schema_version'; diff --git a/sql/MySQL/3.sql b/sql/MySQL/3.sql index c02df032..32f87a64 100644 --- a/sql/MySQL/3.sql +++ b/sql/MySQL/3.sql @@ -7,4 +7,4 @@ alter table arsse_marks change column modified modified datetime(0); alter table arsse_marks add column touched boolean not null default 0; -update arsse_meta set value = '4' where `key` = 'schema_version'; +update arsse_meta set value = '4' where "key" = 'schema_version'; diff --git a/sql/MySQL/4.sql b/sql/MySQL/4.sql new file mode 100644 index 00000000..aa073a6e --- /dev/null +++ b/sql/MySQL/4.sql @@ -0,0 +1,23 @@ +-- SPDX-License-Identifier: MIT +-- Copyright 2017 J. King, Dustin Wilson et al. +-- See LICENSE and AUTHORS files for details + +-- Please consult the SQLite 3 schemata for commented version + +create table arsse_tags( + id serial primary key, + owner varchar(255) not null references arsse_users(id) on delete cascade on update cascade, + name varchar(255) not null, + modified datetime(0) not null default CURRENT_TIMESTAMP, + unique(owner,name) +) character set utf8mb4 collate utf8mb4_unicode_ci; + +create table arsse_tag_members( + tag bigint not null references arsse_tags(id) on delete cascade, + subscription bigint not null references arsse_subscriptions(id) on delete cascade, + assigned boolean not null default 1, + modified datetime(0) not null default CURRENT_TIMESTAMP, + primary key(tag,subscription) +) character set utf8mb4 collate utf8mb4_unicode_ci; + +update arsse_meta set value = '5' where "key" = 'schema_version'; diff --git a/sql/PostgreSQL/0.sql b/sql/PostgreSQL/0.sql index 3d940f5a..d11c2c43 100644 --- a/sql/PostgreSQL/0.sql +++ b/sql/PostgreSQL/0.sql @@ -110,4 +110,4 @@ create table arsse_categories( name text ); -insert into arsse_meta(key,value) values('schema_version','1'); +insert into arsse_meta("key",value) values('schema_version','1'); diff --git a/sql/PostgreSQL/1.sql b/sql/PostgreSQL/1.sql index 1549fd5f..d2a5480f 100644 --- a/sql/PostgreSQL/1.sql +++ b/sql/PostgreSQL/1.sql @@ -30,4 +30,4 @@ create table arsse_label_members ( alter table arsse_marks add column note text not null default ''; -update arsse_meta set value = '2' where key = 'schema_version'; +update arsse_meta set value = '2' where "key" = 'schema_version'; diff --git a/sql/PostgreSQL/2.sql b/sql/PostgreSQL/2.sql index 847edb70..33863fbd 100644 --- a/sql/PostgreSQL/2.sql +++ b/sql/PostgreSQL/2.sql @@ -13,4 +13,4 @@ alter table arsse_articles alter column author type text collate "und-x-icu"; alter table arsse_categories alter column name type text collate "und-x-icu"; alter table arsse_labels alter column name type text collate "und-x-icu"; -update arsse_meta set value = '3' where key = 'schema_version'; +update arsse_meta set value = '3' where "key" = 'schema_version'; diff --git a/sql/PostgreSQL/3.sql b/sql/PostgreSQL/3.sql index 2290ae5d..091cf46d 100644 --- a/sql/PostgreSQL/3.sql +++ b/sql/PostgreSQL/3.sql @@ -8,4 +8,4 @@ alter table arsse_marks alter column modified drop default; alter table arsse_marks alter column modified drop not null; alter table arsse_marks add column touched smallint not null default 0; -update arsse_meta set value = '4' where key = 'schema_version'; +update arsse_meta set value = '4' where "key" = 'schema_version'; diff --git a/sql/PostgreSQL/4.sql b/sql/PostgreSQL/4.sql new file mode 100644 index 00000000..e0cd8eb7 --- /dev/null +++ b/sql/PostgreSQL/4.sql @@ -0,0 +1,23 @@ +-- SPDX-License-Identifier: MIT +-- Copyright 2017 J. King, Dustin Wilson et al. +-- See LICENSE and AUTHORS files for details + +-- Please consult the SQLite 3 schemata for commented version + +create table arsse_tags( + id bigserial primary key, + owner text not null references arsse_users(id) on delete cascade on update cascade, + name text not null collate "und-x-icu", + modified timestamp(0) without time zone not null default CURRENT_TIMESTAMP, + unique(owner,name) +); + +create table arsse_tag_members( + tag bigint not null references arsse_tags(id) on delete cascade, + subscription bigint not null references arsse_subscriptions(id) on delete cascade, + assigned smallint not null default 1, + modified timestamp(0) without time zone not null default CURRENT_TIMESTAMP, + primary key(tag,subscription) +); + +update arsse_meta set value = '5' where "key" = 'schema_version'; diff --git a/sql/SQLite3/0.sql b/sql/SQLite3/0.sql index 7a9dea6a..97f54514 100644 --- a/sql/SQLite3/0.sql +++ b/sql/SQLite3/0.sql @@ -130,4 +130,4 @@ create table arsse_categories( -- set version marker pragma user_version = 1; -insert into arsse_meta(key,value) values('schema_version','1'); +insert into arsse_meta("key",value) values('schema_version','1'); diff --git a/sql/SQLite3/1.sql b/sql/SQLite3/1.sql index dc7862d9..38176450 100644 --- a/sql/SQLite3/1.sql +++ b/sql/SQLite3/1.sql @@ -20,7 +20,7 @@ create table arsse_labels( ); create table arsse_label_members( --- uabels assignments for articles +-- label assignments for articles label integer not null references arsse_labels(id) on delete cascade, -- label ID associated to an article; label IDs belong to a user article integer not null references arsse_articles(id) on delete cascade, -- article associated to a label subscription integer not null references arsse_subscriptions(id) on delete cascade, -- Subscription is included so that records are deleted when a subscription is removed @@ -48,4 +48,4 @@ alter table arsse_marks_new rename to arsse_marks; -- set version marker pragma user_version = 2; -update arsse_meta set value = '2' where key = 'schema_version'; +update arsse_meta set value = '2' where "key" = 'schema_version'; diff --git a/sql/SQLite3/2.sql b/sql/SQLite3/2.sql index b378467b..14a253d1 100644 --- a/sql/SQLite3/2.sql +++ b/sql/SQLite3/2.sql @@ -121,4 +121,4 @@ alter table arsse_labels_new rename to arsse_labels; -- set version marker pragma user_version = 3; -update arsse_meta set value = '3' where key = 'schema_version'; +update arsse_meta set value = '3' where "key" = 'schema_version'; diff --git a/sql/SQLite3/3.sql b/sql/SQLite3/3.sql index 0d583249..087bf32a 100644 --- a/sql/SQLite3/3.sql +++ b/sql/SQLite3/3.sql @@ -24,4 +24,4 @@ reindex nocase; -- set version marker pragma user_version = 4; -update arsse_meta set value = '4' where key = 'schema_version'; +update arsse_meta set value = '4' where "key" = 'schema_version'; diff --git a/sql/SQLite3/4.sql b/sql/SQLite3/4.sql new file mode 100644 index 00000000..aa7cfbd8 --- /dev/null +++ b/sql/SQLite3/4.sql @@ -0,0 +1,25 @@ +-- SPDX-License-Identifier: MIT +-- Copyright 2017 J. King, Dustin Wilson et al. +-- See LICENSE and AUTHORS files for details + +create table arsse_tags( +-- user-defined subscription tags + id integer primary key, -- numeric ID + owner text not null references arsse_users(id) on delete cascade on update cascade, -- owning user + name text not null collate nocase, -- tag text + modified text not null default CURRENT_TIMESTAMP, -- time at which the tag was last modified + unique(owner,name) +); + +create table arsse_tag_members( +-- tag assignments for subscriptions + tag integer not null references arsse_tags(id) on delete cascade, -- tag ID associated to a subscription + subscription integer not null references arsse_subscriptions(id) on delete cascade, -- Subscription associated to a tag + assigned boolean not null default 1, -- whether the association is current, to support soft deletion + modified text not null default CURRENT_TIMESTAMP, -- time at which the association was last made or unmade + primary key(tag,subscription) -- only one association of a given tag to a given subscription +) without rowid; + +-- set version marker +pragma user_version = 5; +update arsse_meta set value = '5' where "key" = 'schema_version'; From ed22090e4999b6d6dba1e0f8f1cd615baa100440 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Mon, 4 Mar 2019 11:05:46 -0500 Subject: [PATCH 032/142] Work around various SQLite-related problems - WAL mode was not getting set properly - Queries using the PDO driver could fail because PDO sucks --- lib/AbstractException.php | 1 + lib/Db/ExceptionRetry.php | 10 +++++++ lib/Db/SQLite3/AbstractPDODriver.php | 11 +++++++ lib/Db/SQLite3/Driver.php | 5 ++++ lib/Db/SQLite3/ExceptionBuilder.php | 3 ++ lib/Db/SQLite3/PDODriver.php | 40 +++++++++++++++++++++++-- lib/Db/SQLite3/PDOStatement.php | 19 ++++++++++++ locale/en.php | 1 + sql/SQLite3/0.sql | 3 -- tests/cases/DatabaseDrivers/SQLite3.php | 2 +- 10 files changed, 88 insertions(+), 7 deletions(-) create mode 100644 lib/Db/ExceptionRetry.php create mode 100644 lib/Db/SQLite3/AbstractPDODriver.php diff --git a/lib/AbstractException.php b/lib/AbstractException.php index 7df22630..a524da60 100644 --- a/lib/AbstractException.php +++ b/lib/AbstractException.php @@ -45,6 +45,7 @@ abstract class AbstractException extends \Exception { "Db/Exception.savepointInvalid" => 10226, "Db/Exception.savepointStale" => 10227, "Db/Exception.resultReused" => 10228, + "Db/ExceptionRetry.schemaChange" => 10229, "Db/ExceptionInput.missing" => 10231, "Db/ExceptionInput.whitespace" => 10232, "Db/ExceptionInput.tooLong" => 10233, diff --git a/lib/Db/ExceptionRetry.php b/lib/Db/ExceptionRetry.php new file mode 100644 index 00000000..be4769af --- /dev/null +++ b/lib/Db/ExceptionRetry.php @@ -0,0 +1,10 @@ +exec("PRAGMA journal_mode = wal"); + } // turn off foreign keys $this->exec("PRAGMA foreign_keys = no"); // run the generic updater diff --git a/lib/Db/SQLite3/ExceptionBuilder.php b/lib/Db/SQLite3/ExceptionBuilder.php index 9e3bfffd..c87e62f8 100644 --- a/lib/Db/SQLite3/ExceptionBuilder.php +++ b/lib/Db/SQLite3/ExceptionBuilder.php @@ -7,6 +7,7 @@ declare(strict_types=1); namespace JKingWeb\Arsse\Db\SQLite3; use JKingWeb\Arsse\Db\Exception; +use JKingWeb\Arsse\Db\ExceptionRetry; use JKingWeb\Arsse\Db\ExceptionInput; use JKingWeb\Arsse\Db\ExceptionTimeout; @@ -19,6 +20,8 @@ trait ExceptionBuilder { switch ($code) { case Driver::SQLITE_BUSY: return [ExceptionTimeout::class, 'general', $msg]; + case Driver::SQLITE_SCHEMA: + return [ExceptionRetry::class, 'schemaChange', $msg]; case Driver::SQLITE_CONSTRAINT: return [ExceptionInput::class, 'engineConstraintViolation', $msg]; case Driver::SQLITE_MISMATCH: diff --git a/lib/Db/SQLite3/PDODriver.php b/lib/Db/SQLite3/PDODriver.php index c36a3c1a..b1cff198 100644 --- a/lib/Db/SQLite3/PDODriver.php +++ b/lib/Db/SQLite3/PDODriver.php @@ -11,9 +11,7 @@ use JKingWeb\Arsse\Db\Exception; use JKingWeb\Arsse\Db\ExceptionInput; use JKingWeb\Arsse\Db\ExceptionTimeout; -class PDODriver extends Driver { - use \JKingWeb\Arsse\Db\PDODriver; - +class PDODriver extends AbstractPDODriver { protected $db; public static function requirementsMet(): bool { @@ -49,4 +47,40 @@ class PDODriver extends Driver { public function prepareArray(string $query, array $paramTypes): \JKingWeb\Arsse\Db\Statement { return new PDOStatement($this->db, $query, $paramTypes); } + + /** @codeCoverageIgnore */ + public function exec(string $query): bool { + // because PDO uses sqlite3_prepare() internally instead of sqlite3_prepare_v2(), + // we have to retry ourselves in cases of schema changes + // the SQLite3 class is not similarly affected + $attempts = 0; + retry: + try { + return parent::exec($query); + } catch (\JKingWeb\Arsse\Db\ExceptionRetry $e) { + if (++$attempts > 50) { + throw $e; + } else { + goto retry; + } + } + } + + /** @codeCoverageIgnore */ + public function query(string $query): \JKingWeb\Arsse\Db\Result { + // because PDO uses sqlite3_prepare() internally instead of sqlite3_prepare_v2(), + // we have to retry ourselves in cases of schema changes + // the SQLite3 class is not similarly affected + $attempts = 0; + retry: + try { + return parent::query($query); + } catch (\JKingWeb\Arsse\Db\ExceptionRetry $e) { + if (++$attempts > 50) { + throw $e; + } else { + goto retry; + } + } + } } diff --git a/lib/Db/SQLite3/PDOStatement.php b/lib/Db/SQLite3/PDOStatement.php index 7e7642da..166fe313 100644 --- a/lib/Db/SQLite3/PDOStatement.php +++ b/lib/Db/SQLite3/PDOStatement.php @@ -9,4 +9,23 @@ namespace JKingWeb\Arsse\Db\SQLite3; class PDOStatement extends \JKingWeb\Arsse\Db\PDOStatement { use ExceptionBuilder; use \JKingWeb\Arsse\Db\PDOError; + + /** @codeCoverageIgnore */ + public function runArray(array $values = []): \JKingWeb\Arsse\Db\Result { + // because PDO uses sqlite3_prepare() internally instead of sqlite3_prepare_v2(), + // we have to retry ourselves in cases of schema changes + // the SQLite3 class is not similarly affected + $attempts = 0; + retry: + try { + return parent::runArray($values); + } catch (\JKingWeb\Arsse\Db\ExceptionRetry $e) { + if (++$attempts > 50) { + throw $e; + } else { + $this->st = $this->db->prepare($this->st->queryString); + goto retry; + } + } + } } diff --git a/locale/en.php b/locale/en.php index 5e8ad0fe..ddbf1182 100644 --- a/locale/en.php +++ b/locale/en.php @@ -120,6 +120,7 @@ return [ 'Exception.JKingWeb/Arsse/Db/Exception.savepointStale' => 'Tried to {action} stale savepoint {index}', // 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.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/sql/SQLite3/0.sql b/sql/SQLite3/0.sql index 97f54514..d9a9b9f0 100644 --- a/sql/SQLite3/0.sql +++ b/sql/SQLite3/0.sql @@ -2,9 +2,6 @@ -- Copyright 2017 J. King, Dustin Wilson et al. -- See LICENSE and AUTHORS files for details --- Make the database WAL-journalled; this is persitent -PRAGMA journal_mode = wal; - create table arsse_meta( -- application metadata key text primary key not null, -- metadata key diff --git a/tests/cases/DatabaseDrivers/SQLite3.php b/tests/cases/DatabaseDrivers/SQLite3.php index e927d417..880539a3 100644 --- a/tests/cases/DatabaseDrivers/SQLite3.php +++ b/tests/cases/DatabaseDrivers/SQLite3.php @@ -28,7 +28,7 @@ trait SQLite3 { } public static function dbTableList($db): array { - $listTables = "SELECT name from sqlite_master where type = 'table' and name like 'arsse_%'"; + $listTables = "SELECT name from sqlite_master where type = 'table' and name like 'arsse^_%' escape '^'"; if ($db instanceof Driver) { $tables = $db->query($listTables)->getAll(); $tables = sizeof($tables) ? array_column($tables, "name") : []; From 6000d80b7bda8f0c76130bab70217635101b2517 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Mon, 4 Mar 2019 11:05:46 -0500 Subject: [PATCH 033/142] Work around various SQLite-related problems - WAL mode was not getting set properly - Queries using the PDO driver could fail because PDO sucks --- lib/AbstractException.php | 1 + lib/Db/ExceptionRetry.php | 10 +++++++ lib/Db/SQLite3/AbstractPDODriver.php | 11 +++++++ lib/Db/SQLite3/Driver.php | 5 ++++ lib/Db/SQLite3/ExceptionBuilder.php | 3 ++ lib/Db/SQLite3/PDODriver.php | 40 +++++++++++++++++++++++-- lib/Db/SQLite3/PDOStatement.php | 19 ++++++++++++ locale/en.php | 1 + sql/SQLite3/0.sql | 3 -- tests/cases/DatabaseDrivers/SQLite3.php | 2 +- 10 files changed, 88 insertions(+), 7 deletions(-) create mode 100644 lib/Db/ExceptionRetry.php create mode 100644 lib/Db/SQLite3/AbstractPDODriver.php diff --git a/lib/AbstractException.php b/lib/AbstractException.php index 7df22630..a524da60 100644 --- a/lib/AbstractException.php +++ b/lib/AbstractException.php @@ -45,6 +45,7 @@ abstract class AbstractException extends \Exception { "Db/Exception.savepointInvalid" => 10226, "Db/Exception.savepointStale" => 10227, "Db/Exception.resultReused" => 10228, + "Db/ExceptionRetry.schemaChange" => 10229, "Db/ExceptionInput.missing" => 10231, "Db/ExceptionInput.whitespace" => 10232, "Db/ExceptionInput.tooLong" => 10233, diff --git a/lib/Db/ExceptionRetry.php b/lib/Db/ExceptionRetry.php new file mode 100644 index 00000000..be4769af --- /dev/null +++ b/lib/Db/ExceptionRetry.php @@ -0,0 +1,10 @@ +exec("PRAGMA journal_mode = wal"); + } // turn off foreign keys $this->exec("PRAGMA foreign_keys = no"); // run the generic updater diff --git a/lib/Db/SQLite3/ExceptionBuilder.php b/lib/Db/SQLite3/ExceptionBuilder.php index 9e3bfffd..c87e62f8 100644 --- a/lib/Db/SQLite3/ExceptionBuilder.php +++ b/lib/Db/SQLite3/ExceptionBuilder.php @@ -7,6 +7,7 @@ declare(strict_types=1); namespace JKingWeb\Arsse\Db\SQLite3; use JKingWeb\Arsse\Db\Exception; +use JKingWeb\Arsse\Db\ExceptionRetry; use JKingWeb\Arsse\Db\ExceptionInput; use JKingWeb\Arsse\Db\ExceptionTimeout; @@ -19,6 +20,8 @@ trait ExceptionBuilder { switch ($code) { case Driver::SQLITE_BUSY: return [ExceptionTimeout::class, 'general', $msg]; + case Driver::SQLITE_SCHEMA: + return [ExceptionRetry::class, 'schemaChange', $msg]; case Driver::SQLITE_CONSTRAINT: return [ExceptionInput::class, 'engineConstraintViolation', $msg]; case Driver::SQLITE_MISMATCH: diff --git a/lib/Db/SQLite3/PDODriver.php b/lib/Db/SQLite3/PDODriver.php index c36a3c1a..b1cff198 100644 --- a/lib/Db/SQLite3/PDODriver.php +++ b/lib/Db/SQLite3/PDODriver.php @@ -11,9 +11,7 @@ use JKingWeb\Arsse\Db\Exception; use JKingWeb\Arsse\Db\ExceptionInput; use JKingWeb\Arsse\Db\ExceptionTimeout; -class PDODriver extends Driver { - use \JKingWeb\Arsse\Db\PDODriver; - +class PDODriver extends AbstractPDODriver { protected $db; public static function requirementsMet(): bool { @@ -49,4 +47,40 @@ class PDODriver extends Driver { public function prepareArray(string $query, array $paramTypes): \JKingWeb\Arsse\Db\Statement { return new PDOStatement($this->db, $query, $paramTypes); } + + /** @codeCoverageIgnore */ + public function exec(string $query): bool { + // because PDO uses sqlite3_prepare() internally instead of sqlite3_prepare_v2(), + // we have to retry ourselves in cases of schema changes + // the SQLite3 class is not similarly affected + $attempts = 0; + retry: + try { + return parent::exec($query); + } catch (\JKingWeb\Arsse\Db\ExceptionRetry $e) { + if (++$attempts > 50) { + throw $e; + } else { + goto retry; + } + } + } + + /** @codeCoverageIgnore */ + public function query(string $query): \JKingWeb\Arsse\Db\Result { + // because PDO uses sqlite3_prepare() internally instead of sqlite3_prepare_v2(), + // we have to retry ourselves in cases of schema changes + // the SQLite3 class is not similarly affected + $attempts = 0; + retry: + try { + return parent::query($query); + } catch (\JKingWeb\Arsse\Db\ExceptionRetry $e) { + if (++$attempts > 50) { + throw $e; + } else { + goto retry; + } + } + } } diff --git a/lib/Db/SQLite3/PDOStatement.php b/lib/Db/SQLite3/PDOStatement.php index 7e7642da..166fe313 100644 --- a/lib/Db/SQLite3/PDOStatement.php +++ b/lib/Db/SQLite3/PDOStatement.php @@ -9,4 +9,23 @@ namespace JKingWeb\Arsse\Db\SQLite3; class PDOStatement extends \JKingWeb\Arsse\Db\PDOStatement { use ExceptionBuilder; use \JKingWeb\Arsse\Db\PDOError; + + /** @codeCoverageIgnore */ + public function runArray(array $values = []): \JKingWeb\Arsse\Db\Result { + // because PDO uses sqlite3_prepare() internally instead of sqlite3_prepare_v2(), + // we have to retry ourselves in cases of schema changes + // the SQLite3 class is not similarly affected + $attempts = 0; + retry: + try { + return parent::runArray($values); + } catch (\JKingWeb\Arsse\Db\ExceptionRetry $e) { + if (++$attempts > 50) { + throw $e; + } else { + $this->st = $this->db->prepare($this->st->queryString); + goto retry; + } + } + } } diff --git a/locale/en.php b/locale/en.php index 5e8ad0fe..ddbf1182 100644 --- a/locale/en.php +++ b/locale/en.php @@ -120,6 +120,7 @@ return [ 'Exception.JKingWeb/Arsse/Db/Exception.savepointStale' => 'Tried to {action} stale savepoint {index}', // 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.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/sql/SQLite3/0.sql b/sql/SQLite3/0.sql index 7a9dea6a..54666296 100644 --- a/sql/SQLite3/0.sql +++ b/sql/SQLite3/0.sql @@ -2,9 +2,6 @@ -- Copyright 2017 J. King, Dustin Wilson et al. -- See LICENSE and AUTHORS files for details --- Make the database WAL-journalled; this is persitent -PRAGMA journal_mode = wal; - create table arsse_meta( -- application metadata key text primary key not null, -- metadata key diff --git a/tests/cases/DatabaseDrivers/SQLite3.php b/tests/cases/DatabaseDrivers/SQLite3.php index e927d417..880539a3 100644 --- a/tests/cases/DatabaseDrivers/SQLite3.php +++ b/tests/cases/DatabaseDrivers/SQLite3.php @@ -28,7 +28,7 @@ trait SQLite3 { } public static function dbTableList($db): array { - $listTables = "SELECT name from sqlite_master where type = 'table' and name like 'arsse_%'"; + $listTables = "SELECT name from sqlite_master where type = 'table' and name like 'arsse^_%' escape '^'"; if ($db instanceof Driver) { $tables = $db->query($listTables)->getAll(); $tables = sizeof($tables) ? array_column($tables, "name") : []; From 4945f8baa3c3025e5fe6d96e4e7bb1c27c276332 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Tue, 5 Mar 2019 19:22:01 -0500 Subject: [PATCH 034/142] Clarify various SQL queries --- lib/Database.php | 180 ++++++++++++-------------- tests/cases/Database/SeriesFolder.php | 63 +++++++-- 2 files changed, 139 insertions(+), 104 deletions(-) diff --git a/lib/Database.php b/lib/Database.php index cecfb33f..a5c7781f 100644 --- a/lib/Database.php +++ b/lib/Database.php @@ -428,14 +428,18 @@ class Database { $parent = $this->folderValidateId($user, $parent)['id']; $q = new Query( "SELECT - id,name,parent, - (select count(*) from arsse_folders as parents where coalesce(parents.parent,0) = coalesce(arsse_folders.id,0)) as children, - (select count(*) from arsse_subscriptions where coalesce(folder,0) = coalesce(arsse_folders.id,0)) as feeds - FROM arsse_folders" + id, + name, + arsse_folders.parent as parent, + coalesce(children,0) as children, + coalesce(feeds,0) as feeds + FROM arsse_folders + left join (SELECT parent,count(id) as children from arsse_folders group by parent) as child_stats on child_stats.parent = arsse_folders.id + left join (SELECT folder,count(id) as feeds from arsse_subscriptions group by folder) as sub_stats on sub_stats.folder = arsse_folders.id" ); if (!$recursive) { $q->setWhere("owner = ?", "str", $user); - $q->setWhere("coalesce(parent,0) = ?", "strict int", $parent); + $q->setWhere("coalesce(arsse_folders.parent,0) = ?", "strict int", $parent); } else { $q->setCTE("folders", "SELECT id from arsse_folders where owner = ? and coalesce(parent,0) = ? union select arsse_folders.id from arsse_folders join folders on arsse_folders.parent=folders.id", ["str", "strict int"], [$user, $parent]); $q->setWhere("id in (SELECT id from folders)"); @@ -687,22 +691,23 @@ class Database { $q = new Query( "SELECT arsse_subscriptions.id as id, - feed,url,favicon,source,folder,pinned,err_count,err_msg,order_type,added, + arsse_subscriptions.feed, + url,favicon,source,folder,pinned,err_count,err_msg,order_type,added, arsse_feeds.updated as updated, topmost.top as top_folder, coalesce(arsse_subscriptions.title, arsse_feeds.title) as title, - (SELECT count(*) from arsse_articles where feed = arsse_subscriptions.feed) - (SELECT count(*) from arsse_marks where subscription = arsse_subscriptions.id and \"read\" = 1) as unread - from arsse_subscriptions - join userdata on userid = owner - join arsse_feeds on feed = arsse_feeds.id - left join topmost on folder=f_id" + (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" ); + $q->setWhere("arsse_subscriptions.owner = ?", ["str"], [$user]); $nocase = $this->db->sqlToken("nocase"); $q->setOrder("pinned desc, coalesce(arsse_subscriptions.title, arsse_feeds.title) collate $nocase"); - // define common table expressions - $q->setCTE("userdata(userid)", "SELECT ?", "str", $user); // the subject user; this way we only have to pass it to prepare() once // topmost folders belonging to the user - $q->setCTE("topmost(f_id,top)", "SELECT id,id from arsse_folders join userdata on owner = userid where parent is null union select id,top from arsse_folders join topmost on parent=f_id"); + $q->setCTE("topmost(f_id,top)", "SELECT id,id from arsse_folders where owner = ? and parent is null union 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 @@ -801,7 +806,7 @@ class Database { * - "order_type": Whether articles should be sorted in reverse cronological order (2), chronological order (1), or the default (0) * * @param string $user The user whose subscription is to be modified - * @param integer|null $id the numeric identifier of the subscription to modfify + * @param integer $id the numeric identifier of the subscription to modfify * @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 { @@ -873,7 +878,7 @@ class Database { * Returns an associative array containing the id of the subscription and the id of the underlying newsfeed * * @param string $user The user who owns the subscription to be validated - * @param integer|null $id The identifier of the subscription to validate + * @param integer $id The identifier of the subscription to validate * @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 { @@ -1056,9 +1061,9 @@ class Database { public function feedCleanup(): bool { $tr = $this->begin(); // first unmark any feeds which are no longer orphaned - $this->db->query("UPDATE arsse_feeds set orphaned = null where exists(SELECT id from arsse_subscriptions where feed = arsse_feeds.id)"); + $this->db->query("WITH active_feeds as (select id from arsse_feeds left join (select feed, count(id) as count from arsse_subscriptions group by feed) as sub_stats on sub_stats.feed = arsse_feeds.id where orphaned is not null and count is not null) UPDATE arsse_feeds set orphaned = null where id in (select id from active_feeds)"); // next mark any newly orphaned feeds with the current date and time - $this->db->query("UPDATE arsse_feeds set orphaned = CURRENT_TIMESTAMP where orphaned is null and not exists(SELECT id from arsse_subscriptions where feed = arsse_feeds.id)"); + $this->db->query("WITH orphaned_feeds as (select id from arsse_feeds left join (select feed, count(id) as count from arsse_subscriptions group by feed) as sub_stats on sub_stats.feed = arsse_feeds.id where orphaned is null and count is null) UPDATE arsse_feeds set orphaned = CURRENT_TIMESTAMP where id in (select id from orphaned_feeds)"); // finally delete feeds that have been orphaned longer than the retention period, if a a purge threshold has been specified if (Arsse::$conf->purgeFeeds) { $limit = Date::sub(Arsse::$conf->purgeFeeds); @@ -1500,7 +1505,7 @@ class Database { throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); } $id = $this->articleValidateId($user, $id)['article']; - $out = $this->db->prepare("SELECT id,name from arsse_labels where owner = ? and exists(select id from arsse_label_members where article = ? and label = arsse_labels.id and assigned = 1)", "str", "int")->run($user, $id)->getAll(); + $out = $this->db->prepare("SELECT id, name from arsse_labels join arsse_label_members on arsse_label_members.label = arsse_labels.id where owner = ? and article = ? and assigned = 1", "str", "int")->run($user, $id)->getAll(); // flatten the result to return just the label ID or name, sorted $out = $out ? array_column($out, !$byName ? "id" : "name") : []; sort($out); @@ -1525,30 +1530,16 @@ class Database { /** Deletes from the database articles which are beyond the configured clean-up threshold */ public function articleCleanup(): bool { $query = $this->db->prepare( - "WITH target_feed(id,subs) as (". - "SELECT - id, (select count(*) from arsse_subscriptions where feed = arsse_feeds.id) as subs - from arsse_feeds where id = ?". - "), latest_editions(article,edition) as (". - "SELECT article,max(id) from arsse_editions group by article". - "), excepted_articles(id,edition) as (". - "SELECT - arsse_articles.id as id, - latest_editions.edition as edition - from arsse_articles - join target_feed on arsse_articles.feed = target_feed.id - join latest_editions on arsse_articles.id = latest_editions.article - order by edition desc limit ?". - ") ". - "DELETE from arsse_articles where - feed = (select max(id) from target_feed) - and id not in (select id from excepted_articles) - and (select count(*) from arsse_marks where article = arsse_articles.id and starred = 1) = 0 - and ( - coalesce((select max(modified) from arsse_marks where article = arsse_articles.id),modified) <= ? - or ((select max(subs) from target_feed) = (select count(*) from arsse_marks where article = arsse_articles.id and \"read\" = 1) and coalesce((select max(modified) from arsse_marks where article = arsse_articles.id),modified) <= ?) + "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) <= ?)) ) - ", + 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", @@ -1564,7 +1555,7 @@ class Database { } $feeds = $this->db->query("SELECT id, size from arsse_feeds")->getAll(); foreach ($feeds as $feed) { - $query->run($feed['id'], $feed['size'], $limitUnread, $limitRead); + $query->run($feed['id'], $feed['size'], $feed['id'], $limitUnread, $limitRead); } return true; } @@ -1574,21 +1565,19 @@ class Database { * Returns an associative array containing the id and latest edition of the article if it exists * * @param string $user The user who owns the article to be validated - * @param integer|null $id The identifier of the article to validate + * @param integer $id The identifier of the article to validate */ protected function articleValidateId(string $user, $id): array { if (!ValueInfo::id($id)) { throw new Db\ExceptionInput("typeViolation", ["action" => $this->caller(), "field" => "article", 'type' => "int > 0"]); // @codeCoverageIgnore } $out = $this->db->prepare( - "SELECT - arsse_articles.id as article, - (select max(id) from arsse_editions where article = arsse_articles.id) as edition - FROM arsse_articles - join arsse_feeds on arsse_feeds.id = arsse_articles.feed - join arsse_subscriptions on arsse_subscriptions.feed = arsse_feeds.id - WHERE - arsse_articles.id = ? and arsse_subscriptions.owner = ?", + "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" )->run($id, $user)->getRow(); @@ -1603,7 +1592,7 @@ class Database { * Returns an associative array containing the edition id, article id, and latest edition of the edition if it exists * * @param string $user The user who owns the edition to be validated - * @param integer|null $id The identifier of the edition to validate + * @param integer $id The identifier of the edition to validate */ protected function articleValidateEdition(string $user, int $id): array { if (!ValueInfo::id($id)) { @@ -1611,15 +1600,12 @@ class Database { } $out = $this->db->prepare( "SELECT - arsse_editions.id as edition, - arsse_editions.article as article, - (arsse_editions.id = (select max(id) from arsse_editions where article = arsse_editions.article)) as current - FROM arsse_editions - join arsse_articles on arsse_editions.article = arsse_articles.id - join arsse_feeds on arsse_feeds.id = arsse_articles.feed - join arsse_subscriptions on arsse_subscriptions.feed = arsse_feeds.id - WHERE - arsse_editions.id = ? and arsse_subscriptions.owner = ?", + arsse_editions.id, arsse_editions.article, edition_stats.edition as current + from arsse_editions + join arsse_articles on arsse_articles.id = arsse_editions.article + 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" )->run($id, $user)->getRow(); @@ -1693,18 +1679,28 @@ class Database { return $this->db->prepare( "SELECT * FROM ( SELECT - id,name, - (select count(*) from arsse_label_members where label = id and assigned = 1) as articles, - (select count(*) from arsse_label_members - join arsse_marks on arsse_label_members.article = arsse_marks.article and arsse_label_members.subscription = arsse_marks.subscription - where label = id and assigned = 1 and \"read\" = 1 - ) as \"read\" - FROM arsse_labels where owner = ?) as label_data + id,name,coalesce(articles,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 + 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 + where arsse_subscriptions.owner = ? + group by label + ) as mark_stats on mark_stats.label = arsse_labels.id + WHERE owner = ? + ) as label_data where articles >= ? order by name ", "str", + "str", "int" - )->run($user, !$includeEmpty); + )->run($user, $user, !$includeEmpty); } /** Deletes a label from the database @@ -1751,17 +1747,26 @@ class Database { $type = $byName ? "str" : "int"; $out = $this->db->prepare( "SELECT - id,name, - (select count(*) from arsse_label_members where label = id and assigned = 1) as articles, - (select count(*) from arsse_label_members - join arsse_marks on arsse_label_members.article = arsse_marks.article and arsse_label_members.subscription = arsse_marks.subscription - where label = id and assigned = 1 and \"read\" = 1 - ) as \"read\" - FROM arsse_labels where $field = ? and owner = ? + id,name,coalesce(articles,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 + 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 + where arsse_subscriptions.owner = ? + group by label + ) as mark_stats on mark_stats.label = arsse_labels.id + WHERE $field = ? and owner = ? ", + "str", $type, "str" - )->run($id, $user)->getRow(); + )->run($user, $id, $user)->getRow(); if (!$out) { throw new Db\ExceptionInput("subjectMissing", ["action" => __FUNCTION__, "field" => "label", 'id' => $id]); } @@ -1846,27 +1851,14 @@ class Database { $tr = $this->begin(); // first update any existing entries with the removal or re-addition of their association $q = $this->articleQuery($user, $context); - $q->setWhere("exists(select article from arsse_label_members where label = ? and article = arsse_articles.id)", "int", $id); $q->pushCTE("target_articles"); - $q->setBody( - "UPDATE arsse_label_members set assigned = ?, modified = CURRENT_TIMESTAMP where label = ? and assigned <> ? and article in (select id from target_articles)", - ["bool","int","bool"], - [!$remove, $id, !$remove] - ); + $q->setBody("UPDATE arsse_label_members set assigned = ?, modified = CURRENT_TIMESTAMP where label = ? and assigned <> ? and article in (select id from target_articles)", ["bool","int","bool"], [!$remove, $id, !$remove]); $out += $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues())->changes(); // next, if we're not removing, add any new entries that need to be added if (!$remove) { - $q = $this->articleQuery($user, $context, ["id", "feed"]); - $q->setWhere("not exists(select article from arsse_label_members where label = ? and article = arsse_articles.id)", "int", $id); + $q = $this->articleQuery($user, $context, ["id", "subscription"]); $q->pushCTE("target_articles"); - $q->setBody( - "SELECT - ?,id, - (select id from arsse_subscriptions where owner = ? and arsse_subscriptions.feed = target_articles.feed) - FROM target_articles", - ["int", "str"], - [$id, $user] - ); + $q->setBody("SELECT ?,id,subscription from target_articles where id not in (select article from arsse_label_members where label = ?)", ["int", "int"], [$id, $id]); $out += $this->db->prepare("INSERT INTO arsse_label_members(label,article,subscription) ".$q->getQuery(), $q->getTypes())->run($q->getValues())->changes(); } // commit the transaction diff --git a/tests/cases/Database/SeriesFolder.php b/tests/cases/Database/SeriesFolder.php index 7265f07a..99c9f1ae 100644 --- a/tests/cases/Database/SeriesFolder.php +++ b/tests/cases/Database/SeriesFolder.php @@ -49,6 +49,49 @@ trait SeriesFolder { [6, "john.doe@example.com", 2, "Politics"], ] ], + 'arsse_feeds' => [ + 'columns' => [ + 'id' => "int", + 'url' => "str", + 'title' => "str", + ], + 'rows' => [ + [1,"http://example.com/1", "Feed 1"], + [2,"http://example.com/2", "Feed 2"], + [3,"http://example.com/3", "Feed 3"], + [4,"http://example.com/4", "Feed 4"], + [5,"http://example.com/5", "Feed 5"], + [6,"http://example.com/6", "Feed 6"], + [7,"http://example.com/7", "Feed 7"], + [8,"http://example.com/8", "Feed 8"], + [9,"http://example.com/9", "Feed 9"], + [10,"http://example.com/10", "Feed 10"], + [11,"http://example.com/11", "Feed 11"], + [12,"http://example.com/12", "Feed 12"], + [13,"http://example.com/13", "Feed 13"], + ] + ], + 'arsse_subscriptions' => [ + 'columns' => [ + 'id' => "int", + 'owner' => "str", + 'feed' => "int", + 'folder' => "int", + ], + 'rows' => [ + [1, "john.doe@example.com",1, null], + [2, "john.doe@example.com",2, null], + [3, "john.doe@example.com",3, 1], + [4, "john.doe@example.com",4, 6], + [5, "john.doe@example.com",5, 5], + [6, "john.doe@example.com",10, 5], + [7, "jane.doe@example.com",1, null], + [8, "jane.doe@example.com",10,null], + [9, "jane.doe@example.com",2, 4], + [10,"jane.doe@example.com",3, 4], + [11,"jane.doe@example.com",4, 4], + ] + ], ]; } @@ -119,8 +162,8 @@ trait SeriesFolder { public function testListRootFolders() { $exp = [ - ['id' => 5, 'name' => "Politics", 'parent' => null, 'children' => 0], - ['id' => 1, 'name' => "Technology", 'parent' => null, 'children' => 2], + ['id' => 5, 'name' => "Politics", 'parent' => null, 'children' => 0, 'feeds' => 2], + ['id' => 1, 'name' => "Technology", 'parent' => null, 'children' => 2, 'feeds' => 1], ]; $this->assertResult($exp, Arsse::$db->folderList("john.doe@example.com", null, false)); $exp = [ @@ -136,17 +179,17 @@ trait SeriesFolder { public function testListFoldersRecursively() { $exp = [ - ['id' => 5, 'name' => "Politics", 'parent' => null, 'children' => 0], - ['id' => 6, 'name' => "Politics", 'parent' => 2, 'children' => 0], - ['id' => 3, 'name' => "Rocketry", 'parent' => 1, 'children' => 0], - ['id' => 2, 'name' => "Software", 'parent' => 1, 'children' => 1], - ['id' => 1, 'name' => "Technology", 'parent' => null, 'children' => 2], + ['id' => 5, 'name' => "Politics", 'parent' => null, 'children' => 0, 'feeds' => 2], + ['id' => 6, 'name' => "Politics", 'parent' => 2, 'children' => 0, 'feeds' => 1], + ['id' => 3, 'name' => "Rocketry", 'parent' => 1, 'children' => 0, 'feeds' => 0], + ['id' => 2, 'name' => "Software", 'parent' => 1, 'children' => 1, 'feeds' => 0], + ['id' => 1, 'name' => "Technology", 'parent' => null, 'children' => 2, 'feeds' => 1], ]; $this->assertResult($exp, Arsse::$db->folderList("john.doe@example.com", null, true)); $exp = [ - ['id' => 6, 'name' => "Politics", 'parent' => 2, 'children' => 0], - ['id' => 3, 'name' => "Rocketry", 'parent' => 1, 'children' => 0], - ['id' => 2, 'name' => "Software", 'parent' => 1, 'children' => 1], + ['id' => 6, 'name' => "Politics", 'parent' => 2, 'children' => 0, 'feeds' => 1], + ['id' => 3, 'name' => "Rocketry", 'parent' => 1, 'children' => 0, 'feeds' => 0], + ['id' => 2, 'name' => "Software", 'parent' => 1, 'children' => 1, 'feeds' => 0], ]; $this->assertResult($exp, Arsse::$db->folderList("john.doe@example.com", 1, true)); $exp = []; From e2cba68c1b05c0e4db50ecc4bd115dfc6748811e Mon Sep 17 00:00:00 2001 From: "J. King" Date: Tue, 5 Mar 2019 19:22:01 -0500 Subject: [PATCH 035/142] Clarify various SQL queries --- lib/Database.php | 180 ++++++++++++-------------- tests/cases/Database/SeriesFolder.php | 63 +++++++-- 2 files changed, 139 insertions(+), 104 deletions(-) diff --git a/lib/Database.php b/lib/Database.php index 7c47ad39..3f552f46 100644 --- a/lib/Database.php +++ b/lib/Database.php @@ -428,14 +428,18 @@ class Database { $parent = $this->folderValidateId($user, $parent)['id']; $q = new Query( "SELECT - id,name,parent, - (select count(*) from arsse_folders as parents where coalesce(parents.parent,0) = coalesce(arsse_folders.id,0)) as children, - (select count(*) from arsse_subscriptions where coalesce(folder,0) = coalesce(arsse_folders.id,0)) as feeds - FROM arsse_folders" + id, + name, + arsse_folders.parent as parent, + coalesce(children,0) as children, + coalesce(feeds,0) as feeds + FROM arsse_folders + left join (SELECT parent,count(id) as children from arsse_folders group by parent) as child_stats on child_stats.parent = arsse_folders.id + left join (SELECT folder,count(id) as feeds from arsse_subscriptions group by folder) as sub_stats on sub_stats.folder = arsse_folders.id" ); if (!$recursive) { $q->setWhere("owner = ?", "str", $user); - $q->setWhere("coalesce(parent,0) = ?", "strict int", $parent); + $q->setWhere("coalesce(arsse_folders.parent,0) = ?", "strict int", $parent); } else { $q->setCTE("folders", "SELECT id from arsse_folders where owner = ? and coalesce(parent,0) = ? union select arsse_folders.id from arsse_folders join folders on arsse_folders.parent=folders.id", ["str", "strict int"], [$user, $parent]); $q->setWhere("id in (SELECT id from folders)"); @@ -687,22 +691,23 @@ class Database { $q = new Query( "SELECT arsse_subscriptions.id as id, - feed,url,favicon,source,folder,pinned,err_count,err_msg,order_type,added, + arsse_subscriptions.feed, + url,favicon,source,folder,pinned,err_count,err_msg,order_type,added, arsse_feeds.updated as updated, topmost.top as top_folder, coalesce(arsse_subscriptions.title, arsse_feeds.title) as title, - (SELECT count(*) from arsse_articles where feed = arsse_subscriptions.feed) - (SELECT count(*) from arsse_marks where subscription = arsse_subscriptions.id and \"read\" = 1) as unread - from arsse_subscriptions - join userdata on userid = owner - join arsse_feeds on feed = arsse_feeds.id - left join topmost on folder=f_id" + (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" ); + $q->setWhere("arsse_subscriptions.owner = ?", ["str"], [$user]); $nocase = $this->db->sqlToken("nocase"); $q->setOrder("pinned desc, coalesce(arsse_subscriptions.title, arsse_feeds.title) collate $nocase"); - // define common table expressions - $q->setCTE("userdata(userid)", "SELECT ?", "str", $user); // the subject user; this way we only have to pass it to prepare() once // topmost folders belonging to the user - $q->setCTE("topmost(f_id,top)", "SELECT id,id from arsse_folders join userdata on owner = userid where parent is null union select id,top from arsse_folders join topmost on parent=f_id"); + $q->setCTE("topmost(f_id,top)", "SELECT id,id from arsse_folders where owner = ? and parent is null union 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 @@ -801,7 +806,7 @@ class Database { * - "order_type": Whether articles should be sorted in reverse cronological order (2), chronological order (1), or the default (0) * * @param string $user The user whose subscription is to be modified - * @param integer|null $id the numeric identifier of the subscription to modfify + * @param integer $id the numeric identifier of the subscription to modfify * @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 { @@ -873,7 +878,7 @@ class Database { * Returns an associative array containing the id of the subscription and the id of the underlying newsfeed * * @param string $user The user who owns the subscription to be validated - * @param integer|null $id The identifier of the subscription to validate + * @param integer $id The identifier of the subscription to validate * @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 { @@ -1056,9 +1061,9 @@ class Database { public function feedCleanup(): bool { $tr = $this->begin(); // first unmark any feeds which are no longer orphaned - $this->db->query("UPDATE arsse_feeds set orphaned = null where exists(SELECT id from arsse_subscriptions where feed = arsse_feeds.id)"); + $this->db->query("WITH active_feeds as (select id from arsse_feeds left join (select feed, count(id) as count from arsse_subscriptions group by feed) as sub_stats on sub_stats.feed = arsse_feeds.id where orphaned is not null and count is not null) UPDATE arsse_feeds set orphaned = null where id in (select id from active_feeds)"); // next mark any newly orphaned feeds with the current date and time - $this->db->query("UPDATE arsse_feeds set orphaned = CURRENT_TIMESTAMP where orphaned is null and not exists(SELECT id from arsse_subscriptions where feed = arsse_feeds.id)"); + $this->db->query("WITH orphaned_feeds as (select id from arsse_feeds left join (select feed, count(id) as count from arsse_subscriptions group by feed) as sub_stats on sub_stats.feed = arsse_feeds.id where orphaned is null and count is null) UPDATE arsse_feeds set orphaned = CURRENT_TIMESTAMP where id in (select id from orphaned_feeds)"); // finally delete feeds that have been orphaned longer than the retention period, if a a purge threshold has been specified if (Arsse::$conf->purgeFeeds) { $limit = Date::sub(Arsse::$conf->purgeFeeds); @@ -1500,7 +1505,7 @@ class Database { throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); } $id = $this->articleValidateId($user, $id)['article']; - $out = $this->db->prepare("SELECT id,name from arsse_labels where owner = ? and exists(select id from arsse_label_members where article = ? and label = arsse_labels.id and assigned = 1)", "str", "int")->run($user, $id)->getAll(); + $out = $this->db->prepare("SELECT id, name from arsse_labels join arsse_label_members on arsse_label_members.label = arsse_labels.id where owner = ? and article = ? and assigned = 1", "str", "int")->run($user, $id)->getAll(); // flatten the result to return just the label ID or name, sorted $out = $out ? array_column($out, !$byName ? "id" : "name") : []; sort($out); @@ -1525,30 +1530,16 @@ class Database { /** Deletes from the database articles which are beyond the configured clean-up threshold */ public function articleCleanup(): bool { $query = $this->db->prepare( - "WITH target_feed(id,subs) as (". - "SELECT - id, (select count(*) from arsse_subscriptions where feed = arsse_feeds.id) as subs - from arsse_feeds where id = ?". - "), latest_editions(article,edition) as (". - "SELECT article,max(id) from arsse_editions group by article". - "), excepted_articles(id,edition) as (". - "SELECT - arsse_articles.id as id, - latest_editions.edition as edition - from arsse_articles - join target_feed on arsse_articles.feed = target_feed.id - join latest_editions on arsse_articles.id = latest_editions.article - order by edition desc limit ?". - ") ". - "DELETE from arsse_articles where - feed = (select max(id) from target_feed) - and id not in (select id from excepted_articles) - and (select count(*) from arsse_marks where article = arsse_articles.id and starred = 1) = 0 - and ( - coalesce((select max(modified) from arsse_marks where article = arsse_articles.id),modified) <= ? - or ((select max(subs) from target_feed) = (select count(*) from arsse_marks where article = arsse_articles.id and \"read\" = 1) and coalesce((select max(modified) from arsse_marks where article = arsse_articles.id),modified) <= ?) + "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) <= ?)) ) - ", + 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", @@ -1564,7 +1555,7 @@ class Database { } $feeds = $this->db->query("SELECT id, size from arsse_feeds")->getAll(); foreach ($feeds as $feed) { - $query->run($feed['id'], $feed['size'], $limitUnread, $limitRead); + $query->run($feed['id'], $feed['size'], $feed['id'], $limitUnread, $limitRead); } return true; } @@ -1574,21 +1565,19 @@ class Database { * Returns an associative array containing the id and latest edition of the article if it exists * * @param string $user The user who owns the article to be validated - * @param integer|null $id The identifier of the article to validate + * @param integer $id The identifier of the article to validate */ protected function articleValidateId(string $user, $id): array { if (!ValueInfo::id($id)) { throw new Db\ExceptionInput("typeViolation", ["action" => $this->caller(), "field" => "article", 'type' => "int > 0"]); // @codeCoverageIgnore } $out = $this->db->prepare( - "SELECT - arsse_articles.id as article, - (select max(id) from arsse_editions where article = arsse_articles.id) as edition - FROM arsse_articles - join arsse_feeds on arsse_feeds.id = arsse_articles.feed - join arsse_subscriptions on arsse_subscriptions.feed = arsse_feeds.id - WHERE - arsse_articles.id = ? and arsse_subscriptions.owner = ?", + "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" )->run($id, $user)->getRow(); @@ -1603,7 +1592,7 @@ class Database { * Returns an associative array containing the edition id, article id, and latest edition of the edition if it exists * * @param string $user The user who owns the edition to be validated - * @param integer|null $id The identifier of the edition to validate + * @param integer $id The identifier of the edition to validate */ protected function articleValidateEdition(string $user, int $id): array { if (!ValueInfo::id($id)) { @@ -1611,15 +1600,12 @@ class Database { } $out = $this->db->prepare( "SELECT - arsse_editions.id as edition, - arsse_editions.article as article, - (arsse_editions.id = (select max(id) from arsse_editions where article = arsse_editions.article)) as current - FROM arsse_editions - join arsse_articles on arsse_editions.article = arsse_articles.id - join arsse_feeds on arsse_feeds.id = arsse_articles.feed - join arsse_subscriptions on arsse_subscriptions.feed = arsse_feeds.id - WHERE - arsse_editions.id = ? and arsse_subscriptions.owner = ?", + arsse_editions.id, arsse_editions.article, edition_stats.edition as current + from arsse_editions + join arsse_articles on arsse_articles.id = arsse_editions.article + 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" )->run($id, $user)->getRow(); @@ -1693,18 +1679,28 @@ class Database { return $this->db->prepare( "SELECT * FROM ( SELECT - id,name, - (select count(*) from arsse_label_members where label = id and assigned = 1) as articles, - (select count(*) from arsse_label_members - join arsse_marks on arsse_label_members.article = arsse_marks.article and arsse_label_members.subscription = arsse_marks.subscription - where label = id and assigned = 1 and \"read\" = 1 - ) as \"read\" - FROM arsse_labels where owner = ?) as label_data + id,name,coalesce(articles,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 + 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 + where arsse_subscriptions.owner = ? + group by label + ) as mark_stats on mark_stats.label = arsse_labels.id + WHERE owner = ? + ) as label_data where articles >= ? order by name ", "str", + "str", "int" - )->run($user, !$includeEmpty); + )->run($user, $user, !$includeEmpty); } /** Deletes a label from the database @@ -1751,17 +1747,26 @@ class Database { $type = $byName ? "str" : "int"; $out = $this->db->prepare( "SELECT - id,name, - (select count(*) from arsse_label_members where label = id and assigned = 1) as articles, - (select count(*) from arsse_label_members - join arsse_marks on arsse_label_members.article = arsse_marks.article and arsse_label_members.subscription = arsse_marks.subscription - where label = id and assigned = 1 and \"read\" = 1 - ) as \"read\" - FROM arsse_labels where $field = ? and owner = ? + id,name,coalesce(articles,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 + 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 + where arsse_subscriptions.owner = ? + group by label + ) as mark_stats on mark_stats.label = arsse_labels.id + WHERE $field = ? and owner = ? ", + "str", $type, "str" - )->run($id, $user)->getRow(); + )->run($user, $id, $user)->getRow(); if (!$out) { throw new Db\ExceptionInput("subjectMissing", ["action" => __FUNCTION__, "field" => "label", 'id' => $id]); } @@ -1846,27 +1851,14 @@ class Database { $tr = $this->begin(); // first update any existing entries with the removal or re-addition of their association $q = $this->articleQuery($user, $context); - $q->setWhere("exists(select article from arsse_label_members where label = ? and article = arsse_articles.id)", "int", $id); $q->pushCTE("target_articles"); - $q->setBody( - "UPDATE arsse_label_members set assigned = ?, modified = CURRENT_TIMESTAMP where label = ? and assigned <> ? and article in (select id from target_articles)", - ["bool","int","bool"], - [!$remove, $id, !$remove] - ); + $q->setBody("UPDATE arsse_label_members set assigned = ?, modified = CURRENT_TIMESTAMP where label = ? and assigned <> ? and article in (select id from target_articles)", ["bool","int","bool"], [!$remove, $id, !$remove]); $out += $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues())->changes(); // next, if we're not removing, add any new entries that need to be added if (!$remove) { - $q = $this->articleQuery($user, $context, ["id", "feed"]); - $q->setWhere("not exists(select article from arsse_label_members where label = ? and article = arsse_articles.id)", "int", $id); + $q = $this->articleQuery($user, $context, ["id", "subscription"]); $q->pushCTE("target_articles"); - $q->setBody( - "SELECT - ?,id, - (select id from arsse_subscriptions where owner = ? and arsse_subscriptions.feed = target_articles.feed) - FROM target_articles", - ["int", "str"], - [$id, $user] - ); + $q->setBody("SELECT ?,id,subscription from target_articles where id not in (select article from arsse_label_members where label = ?)", ["int", "int"], [$id, $id]); $out += $this->db->prepare("INSERT INTO arsse_label_members(label,article,subscription) ".$q->getQuery(), $q->getTypes())->run($q->getValues())->changes(); } // commit the transaction diff --git a/tests/cases/Database/SeriesFolder.php b/tests/cases/Database/SeriesFolder.php index 7265f07a..99c9f1ae 100644 --- a/tests/cases/Database/SeriesFolder.php +++ b/tests/cases/Database/SeriesFolder.php @@ -49,6 +49,49 @@ trait SeriesFolder { [6, "john.doe@example.com", 2, "Politics"], ] ], + 'arsse_feeds' => [ + 'columns' => [ + 'id' => "int", + 'url' => "str", + 'title' => "str", + ], + 'rows' => [ + [1,"http://example.com/1", "Feed 1"], + [2,"http://example.com/2", "Feed 2"], + [3,"http://example.com/3", "Feed 3"], + [4,"http://example.com/4", "Feed 4"], + [5,"http://example.com/5", "Feed 5"], + [6,"http://example.com/6", "Feed 6"], + [7,"http://example.com/7", "Feed 7"], + [8,"http://example.com/8", "Feed 8"], + [9,"http://example.com/9", "Feed 9"], + [10,"http://example.com/10", "Feed 10"], + [11,"http://example.com/11", "Feed 11"], + [12,"http://example.com/12", "Feed 12"], + [13,"http://example.com/13", "Feed 13"], + ] + ], + 'arsse_subscriptions' => [ + 'columns' => [ + 'id' => "int", + 'owner' => "str", + 'feed' => "int", + 'folder' => "int", + ], + 'rows' => [ + [1, "john.doe@example.com",1, null], + [2, "john.doe@example.com",2, null], + [3, "john.doe@example.com",3, 1], + [4, "john.doe@example.com",4, 6], + [5, "john.doe@example.com",5, 5], + [6, "john.doe@example.com",10, 5], + [7, "jane.doe@example.com",1, null], + [8, "jane.doe@example.com",10,null], + [9, "jane.doe@example.com",2, 4], + [10,"jane.doe@example.com",3, 4], + [11,"jane.doe@example.com",4, 4], + ] + ], ]; } @@ -119,8 +162,8 @@ trait SeriesFolder { public function testListRootFolders() { $exp = [ - ['id' => 5, 'name' => "Politics", 'parent' => null, 'children' => 0], - ['id' => 1, 'name' => "Technology", 'parent' => null, 'children' => 2], + ['id' => 5, 'name' => "Politics", 'parent' => null, 'children' => 0, 'feeds' => 2], + ['id' => 1, 'name' => "Technology", 'parent' => null, 'children' => 2, 'feeds' => 1], ]; $this->assertResult($exp, Arsse::$db->folderList("john.doe@example.com", null, false)); $exp = [ @@ -136,17 +179,17 @@ trait SeriesFolder { public function testListFoldersRecursively() { $exp = [ - ['id' => 5, 'name' => "Politics", 'parent' => null, 'children' => 0], - ['id' => 6, 'name' => "Politics", 'parent' => 2, 'children' => 0], - ['id' => 3, 'name' => "Rocketry", 'parent' => 1, 'children' => 0], - ['id' => 2, 'name' => "Software", 'parent' => 1, 'children' => 1], - ['id' => 1, 'name' => "Technology", 'parent' => null, 'children' => 2], + ['id' => 5, 'name' => "Politics", 'parent' => null, 'children' => 0, 'feeds' => 2], + ['id' => 6, 'name' => "Politics", 'parent' => 2, 'children' => 0, 'feeds' => 1], + ['id' => 3, 'name' => "Rocketry", 'parent' => 1, 'children' => 0, 'feeds' => 0], + ['id' => 2, 'name' => "Software", 'parent' => 1, 'children' => 1, 'feeds' => 0], + ['id' => 1, 'name' => "Technology", 'parent' => null, 'children' => 2, 'feeds' => 1], ]; $this->assertResult($exp, Arsse::$db->folderList("john.doe@example.com", null, true)); $exp = [ - ['id' => 6, 'name' => "Politics", 'parent' => 2, 'children' => 0], - ['id' => 3, 'name' => "Rocketry", 'parent' => 1, 'children' => 0], - ['id' => 2, 'name' => "Software", 'parent' => 1, 'children' => 1], + ['id' => 6, 'name' => "Politics", 'parent' => 2, 'children' => 0, 'feeds' => 1], + ['id' => 3, 'name' => "Rocketry", 'parent' => 1, 'children' => 0, 'feeds' => 0], + ['id' => 2, 'name' => "Software", 'parent' => 1, 'children' => 1, 'feeds' => 0], ]; $this->assertResult($exp, Arsse::$db->folderList("john.doe@example.com", 1, true)); $exp = []; From ff0c9a3a55f76b28b4328cb80b6c49fa45122338 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Wed, 6 Mar 2019 22:15:41 -0500 Subject: [PATCH 036/142] Add functionality for interacting with subscription tags --- lib/Database.php | 348 ++++++++++++++++- lib/Db/SQLite3/ExceptionBuilder.php | 3 +- tests/cases/Database/Base.php | 3 +- tests/cases/Database/SeriesSubscription.php | 45 +++ tests/cases/Database/SeriesTag.php | 395 ++++++++++++++++++++ 5 files changed, 775 insertions(+), 19 deletions(-) create mode 100644 tests/cases/Database/SeriesTag.php diff --git a/lib/Database.php b/lib/Database.php index a5c7781f..043b993d 100644 --- a/lib/Database.php +++ b/lib/Database.php @@ -21,6 +21,7 @@ use JKingWeb\Arsse\Misc\ValueInfo; * - Users * - Subscriptions to feeds, which belong to users * - 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 * - Articles, which belong to feeds and for which users can only affect metadata * - Editions, identifying authorial modifications to articles @@ -849,6 +850,22 @@ class Database { return $out; } + /** Returns an indexed array listing the tags assigned to a subscription + * + * @param string $user The user whose tags are to be listed + * @param integer $id The numeric identifier of the subscription whose tags are to be listed + * @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(); + return $out ? array_column($out, $field) : []; + } + /** Retrieves the URL of the icon for a subscription. * * Note that while the $user parameter is optional, it @@ -1505,11 +1522,9 @@ class Database { throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); } $id = $this->articleValidateId($user, $id)['article']; - $out = $this->db->prepare("SELECT id, name from arsse_labels join arsse_label_members on arsse_label_members.label = arsse_labels.id where owner = ? and article = ? and assigned = 1", "str", "int")->run($user, $id)->getAll(); - // flatten the result to return just the label ID or name, sorted - $out = $out ? array_column($out, !$byName ? "id" : "name") : []; - sort($out); - return $out; + $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(); + return $out ? array_column($out, $field) : []; } /** Returns the author-supplied categories associated with an article */ @@ -1846,22 +1861,28 @@ class Database { // validate the label ID, and get the numeric ID if matching by name $id = $this->labelValidateId($user, $id, $byName, true)['id']; $context = $context ?? new Context; - $out = 0; - // wrap this UPDATE and INSERT together into a transaction - $tr = $this->begin(); + // prepare either one or two queries // first update any existing entries with the removal or re-addition of their association - $q = $this->articleQuery($user, $context); - $q->pushCTE("target_articles"); - $q->setBody("UPDATE arsse_label_members set assigned = ?, modified = CURRENT_TIMESTAMP where label = ? and assigned <> ? and article in (select id from target_articles)", ["bool","int","bool"], [!$remove, $id, !$remove]); - $out += $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues())->changes(); + $q1 = $this->articleQuery($user, $context); + $q1->pushCTE("target_articles"); + $q1->setBody("UPDATE arsse_label_members set assigned = ?, modified = CURRENT_TIMESTAMP where label = ? and assigned <> ? and article in (select id from target_articles)", ["bool","int","bool"], [!$remove, $id, !$remove]); + $v1 = $q1->getValues(); + $q1 = $this->db->prepare($q1->getQuery(), $q1->getTypes()); // next, if we're not removing, add any new entries that need to be added if (!$remove) { - $q = $this->articleQuery($user, $context, ["id", "subscription"]); - $q->pushCTE("target_articles"); - $q->setBody("SELECT ?,id,subscription from target_articles where id not in (select article from arsse_label_members where label = ?)", ["int", "int"], [$id, $id]); - $out += $this->db->prepare("INSERT INTO arsse_label_members(label,article,subscription) ".$q->getQuery(), $q->getTypes())->run($q->getValues())->changes(); + $q2 = $this->articleQuery($user, $context, ["id", "subscription"]); + $q2->pushCTE("target_articles"); + $q2->setBody("SELECT ?,id,subscription from target_articles where id not in (select article from arsse_label_members where label = ?)", ["int", "int"], [$id, $id]); + $v2 = $q2->getValues(); + $q2 = $this->db->prepare("INSERT INTO arsse_label_members(label,article,subscription) ".$q2->getQuery(), $q2->getTypes()); + } + // execute them in a transaction + $out = 0; + $tr = $this->begin(); + $out += $q1->run($v1)->changes(); + if (!$remove) { + $out += $q2->run($v2)->changes(); } - // commit the transaction $tr->commit(); return $out; } @@ -1912,4 +1933,297 @@ class Database { return true; } } + + /** Creates a tag, and returns its numeric identifier + * + * Tags are discrete objects in the database and can be associated with multiple subscriptions; a subscription may in turn be associated with multiple tags + * + * @param string $user The user who will own the created tag + * @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); + // perform the insert + return $this->db->prepare("INSERT INTO arsse_tags(owner,name) values(?,?)", "str", "str")->run($user, $name)->lastId(); + } + + /** Lists a user's subscription tags + * + * The following keys are included in each record: + * + * - "id": The tag's numeric identifier + * - "name" The tag's textual name + * - "subscriptions": The count of subscriptions which have the tag assigned to them + * + * @param string $user The user whose tags are to be listed + * @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 + 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 owner = ? + ) as tag_data + where subscriptions >= ? order by name + ", + "str", + "int" + )->run($user, !$includeEmpty); + } + + /** Lists the associations between all tags and subscription + * + * The following keys are included in each record: + * + * - "tag_id": The tag's numeric identifier + * - "tag_name" The tag's textual name + * - "subscription_id": The numeric identifier of the associated subscription + * - "subscription_name" The subscription's textual name + * + * @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 tag_id, + arsse_tags.name as tag_name, + arsse_subscriptions.id as subscription_id, + coalesce(arsse_subscriptions.title, arsse_feeds.title) as subscription_name + FROM arsse_tag_members + join arsse_tags on arsse_tags.id = arsse_tag_members.tag + join arsse_subscriptions on arsse_subscriptions.id = arsse_tag_members.subscription + join arsse_feeds on arsse_feeds.id = arsse_subscriptions.feed + WHERE arsse_tags.owner = ? and assigned = 1", + "str" + )->run($user); + } + + /** Deletes a tag from the database + * + * Any subscriptions associated with the tag remains untouched + * + * @param string $user The owner of the tag to remove + * @param integer|string $id The numeric identifier or name of the tag + * @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"; + $changes = $this->db->prepare("DELETE FROM arsse_tags where owner = ? and $field = ?", "str", $type)->run($user, $id)->changes(); + if (!$changes) { + throw new Db\ExceptionInput("subjectMissing", ["action" => __FUNCTION__, "field" => "tag", 'id' => $id]); + } + return true; + } + + /** Retrieves the properties of a tag + * + * The following keys are included in the output array: + * + * - "id": The tag's numeric identifier + * - "name" The tag's textual name + * - "subscriptions": The count of subscriptions which have the tag assigned to them + * + * @param string $user The owner of the tag to remove + * @param integer|string $id The numeric identifier or name of the tag + * @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"; + $out = $this->db->prepare( + "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" + )->run($id, $user)->getRow(); + if (!$out) { + throw new Db\ExceptionInput("subjectMissing", ["action" => __FUNCTION__, "field" => "tag", 'id' => $id]); + } + return $out; + } + + /** Sets the properties of a tag + * + * @param string $user The owner of the tag to query + * @param integer|string $id The numeric identifier or name of the tag + * @param array $data An associative array defining the tag's properties; currently only "name" is understood + * @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']); + } + $field = $byName ? "name" : "id"; + $type = $byName ? "str" : "int"; + $valid = [ + 'name' => "str", + ]; + list($setClause, $setTypes, $setValues) = $this->generateSet($data, $valid); + if (!$setClause) { + // if no changes would actually be applied, just return + return false; + } + $out = (bool) $this->db->prepare("UPDATE arsse_tags set $setClause, modified = CURRENT_TIMESTAMP where owner = ? and $field = ?", $setTypes, "str", $type)->run($setValues, $user, $id)->changes(); + if (!$out) { + throw new Db\ExceptionInput("subjectMissing", ["action" => __FUNCTION__, "field" => "tag", 'id' => $id]); + } + return $out; + } + + /** Returns an indexed array of subscription identifiers assigned to a tag + * + * @param string $user The owner of the tag to query + * @param integer|string $id The numeric identifier or name of the tag + * @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"; + $type = !$byName ? "int" : "str"; + $out = $this->db->prepare("SELECT subscription from arsse_tag_members join arsse_tags on tag = id where assigned = 1 and $field = ? and owner = ? order by subscription", $type, "str")->run($id, $user)->getAll(); + if (!$out) { + // if no results were returned, do a full validation on the tag ID + $this->tagValidateId($user, $id, $byName, true, true); + // if the validation passes, return the empty result + return $out; + } else { + // flatten the result to return just the subscription IDs in a simple array + return array_column($out, "subscription"); + } + } + + /** Makes or breaks associations between a given tag and specified subscriptions + * + * @param string $user The owner of the tag + * @param integer|string $id The numeric identifier or name of the tag + * @param integer[] $context The query context matching the desired subscriptions + * @param boolean $remove Whether to remove (true) rather than add (true) an association with the subscriptions matching the context + * @param boolean $byName Whether to interpret the $id parameter as the tag's name (true) or identifier (false) + */ + public function tagSubscriptionsSet(string $user, $id, array $subscriptions, bool $remove = false, bool $byName = false): int { + 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']; + // prepare either one or two queries + list($inClause, $inTypes, $inValues) = $this->generateIn($subscriptions, "int"); + // first update any existing entries with the removal or re-addition of their association + $q1 = $this->db->prepare( + "UPDATE arsse_tag_members + set assigned = ?, modified = CURRENT_TIMESTAMP + where tag = ? and assigned <> ? and subscription in (select id from arsse_subscriptions where owner = ? and id in ($inClause))", + "bool", + "int", + "bool", + "str", + $inTypes + ); + $v1 = [!$remove, $id, !$remove, $user, $inValues]; + // next, if we're not removing, add any new entries that need to be added + if (!$remove) { + $q2 = $this->db->prepare( + "INSERT INTO arsse_tag_members(tag,subscription) SELECT ?,id from arsse_subscriptions where id not in (select subscription from arsse_tag_members where tag = ?) and owner = ? and id in ($inClause)", + "int", + "int", + "str", + $inTypes + ); + $v2 = [$id, $id, $user, $inValues]; + } + // execute them in a transaction + $out = 0; + $tr = $this->begin(); + $out += $q1->run($v1)->changes(); + if (!$remove) { + $out += $q2->run($v2)->changes(); + } + $tr->commit(); + return $out; + } + + /** Ensures the specified tag identifier or name is valid (and optionally whether it exists) and raises an exception otherwise + * + * Returns an associative array containing the id, name of the tag if it exists + * + * @param string $user The user who owns the tag to be validated + * @param integer|string $id The numeric identifier or name of the tag to validate + * @param boolean $byName Whether to interpret the $id parameter as the tag's name (true) or identifier (false) + * @param boolean $checkDb Whether to check whether the tag exists (true) or only if the identifier or name is syntactically valid (false) + * @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 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)) { + // 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) { + $field = !$byName ? "id" : "name"; + $type = !$byName ? "int" : "str"; + $l = $this->db->prepare("SELECT id,name from arsse_tags where $field = ? and owner = ?", $type, "str")->run($id, $user)->getRow(); + if (!$l) { + throw new Db\ExceptionInput($subject ? "subjectMissing" : "idMissing", ["action" => $this->caller(), "field" => "tag", 'id' => $id]); + } else { + return $l; + } + } + return [ + 'id' => !$byName ? $id : null, + 'name' => $byName ? $id : null, + ]; + } + + /** 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)) { + throw new Db\ExceptionInput("missing", ["action" => $this->caller(), "field" => "name"]); + } elseif ($info & ValueInfo::WHITE) { + throw new Db\ExceptionInput("whitespace", ["action" => $this->caller(), "field" => "name"]); + } elseif (!($info & ValueInfo::VALID)) { + throw new Db\ExceptionInput("typeViolation", ["action" => $this->caller(), "field" => "name", 'type' => "string"]); + } else { + return true; + } + } } diff --git a/lib/Db/SQLite3/ExceptionBuilder.php b/lib/Db/SQLite3/ExceptionBuilder.php index c87e62f8..22d17230 100644 --- a/lib/Db/SQLite3/ExceptionBuilder.php +++ b/lib/Db/SQLite3/ExceptionBuilder.php @@ -21,7 +21,8 @@ trait ExceptionBuilder { case Driver::SQLITE_BUSY: return [ExceptionTimeout::class, 'general', $msg]; case Driver::SQLITE_SCHEMA: - return [ExceptionRetry::class, 'schemaChange', $msg]; + // sometimes encountered with PDO, because PDO sucks + return [ExceptionRetry::class, 'schemaChange', $msg]; // @codeCoverageIgnore case Driver::SQLITE_CONSTRAINT: return [ExceptionInput::class, 'engineConstraintViolation', $msg]; case Driver::SQLITE_MISMATCH: diff --git a/tests/cases/Database/Base.php b/tests/cases/Database/Base.php index 219d4c02..9e140c4d 100644 --- a/tests/cases/Database/Base.php +++ b/tests/cases/Database/Base.php @@ -23,8 +23,9 @@ abstract class Base extends \JKingWeb\Arsse\Test\AbstractTest { use SeriesFolder; use SeriesFeed; use SeriesSubscription; - use SeriesArticle; use SeriesLabel; + use SeriesTag; + use SeriesArticle; use SeriesCleanup; /** @var \JKingWeb\Arsse\Db\Driver */ diff --git a/tests/cases/Database/SeriesSubscription.php b/tests/cases/Database/SeriesSubscription.php index f2811f1d..0adac9e6 100644 --- a/tests/cases/Database/SeriesSubscription.php +++ b/tests/cases/Database/SeriesSubscription.php @@ -69,6 +69,33 @@ trait SeriesSubscription { [3,"john.doe@example.com",3,"Ook",2,0,1], ] ], + 'arsse_tags' => [ + 'columns' => [ + 'id' => "int", + 'owner' => "str", + 'name' => "str", + ], + 'rows' => [ + [1,"john.doe@example.com","Interesting"], + [2,"john.doe@example.com","Fascinating"], + [3,"jane.doe@example.com","Boring"], + [4,"john.doe@example.com","Lonely"], + ], + ], + 'arsse_tag_members' => [ + 'columns' => [ + 'tag' => "int", + 'subscription' => "int", + 'assigned' => "bool", + ], + 'rows' => [ + [1,1,1], + [1,3,0], + [2,1,1], + [2,3,1], + [3,2,1], + ], + ], 'arsse_articles' => [ 'columns' => [ 'id' => "int", @@ -447,4 +474,22 @@ trait SeriesSubscription { $this->assertException("notAuthorized", "User", "ExceptionAuthz"); Arsse::$db->subscriptionFavicon(-2112, $user); } + + public function testListTheTagsOfASubscription() { + $this->assertEquals([1,2], Arsse::$db->subscriptionTagsGet("john.doe@example.com", 1)); + $this->assertEquals([2], Arsse::$db->subscriptionTagsGet("john.doe@example.com", 3)); + $this->assertEquals(["Fascinating","Interesting"], Arsse::$db->subscriptionTagsGet("john.doe@example.com", 1, true)); + $this->assertEquals(["Fascinating"], Arsse::$db->subscriptionTagsGet("john.doe@example.com", 3, true)); + } + + public function testListTheTagsOfAMissingSubscription() { + $this->assertException("subjectMissing", "Db", "ExceptionInput"); + Arsse::$db->subscriptionTagsGet($this->user, 101); + } + + public function testListTheTagsOfASubscriptionWithoutAuthority() { + Phake::when(Arsse::$user)->authorize->thenReturn(false); + $this->assertException("notAuthorized", "User", "ExceptionAuthz"); + Arsse::$db->subscriptionTagsGet("john.doe@example.com", 1); + } } diff --git a/tests/cases/Database/SeriesTag.php b/tests/cases/Database/SeriesTag.php new file mode 100644 index 00000000..ea40d414 --- /dev/null +++ b/tests/cases/Database/SeriesTag.php @@ -0,0 +1,395 @@ +data = [ + 'arsse_users' => [ + 'columns' => [ + 'id' => 'str', + 'password' => 'str', + 'name' => 'str', + ], + 'rows' => [ + ["jane.doe@example.com", "", "Jane Doe"], + ["john.doe@example.com", "", "John Doe"], + ["john.doe@example.org", "", "John Doe"], + ["john.doe@example.net", "", "John Doe"], + ], + ], + 'arsse_feeds' => [ + 'columns' => [ + 'id' => "int", + 'url' => "str", + 'title' => "str", + ], + 'rows' => [ + [1,"http://example.com/1",""], + [2,"http://example.com/2",""], + [3,"http://example.com/3","Feed Title"], + [4,"http://example.com/4",""], + [5,"http://example.com/5","Feed Title"], + [6,"http://example.com/6",""], + [7,"http://example.com/7",""], + [8,"http://example.com/8",""], + [9,"http://example.com/9",""], + [10,"http://example.com/10",""], + [11,"http://example.com/11",""], + [12,"http://example.com/12",""], + [13,"http://example.com/13",""], + ] + ], + 'arsse_subscriptions' => [ + 'columns' => [ + 'id' => "int", + 'owner' => "str", + 'feed' => "int", + 'title' => "str", + ], + 'rows' => [ + [1, "john.doe@example.com", 1,"Lord of Carrots"], + [2, "john.doe@example.com", 2,null], + [3, "john.doe@example.com", 3,"Subscription Title"], + [4, "john.doe@example.com", 4,null], + [5, "john.doe@example.com",10,null], + [6, "jane.doe@example.com", 1,null], + [7, "jane.doe@example.com",10,null], + [8, "john.doe@example.org",11,null], + [9, "john.doe@example.org",12,null], + [10,"john.doe@example.org",13,null], + [11,"john.doe@example.net",10,null], + [12,"john.doe@example.net", 2,null], + [13,"john.doe@example.net", 3,null], + [14,"john.doe@example.net", 4,null], + ] + ], + 'arsse_tags' => [ + 'columns' => [ + 'id' => "int", + 'owner' => "str", + 'name' => "str", + ], + 'rows' => [ + [1,"john.doe@example.com","Interesting"], + [2,"john.doe@example.com","Fascinating"], + [3,"jane.doe@example.com","Boring"], + [4,"john.doe@example.com","Lonely"], + ], + ], + 'arsse_tag_members' => [ + 'columns' => [ + 'tag' => "int", + 'subscription' => "int", + 'assigned' => "bool", + ], + 'rows' => [ + [1,1,1], + [1,3,0], + [1,5,1], + [2,1,1], + [2,3,1], + [2,5,1], + ], + ], + ]; + $this->checkTags = ['arsse_tags' => ["id","owner","name"]]; + $this->checkMembers = ['arsse_tag_members' => ["tag","subscription","assigned"]]; + $this->user = "john.doe@example.com"; + } + + protected function tearDownSeriesTag() { + unset($this->data, $this->checkTags, $this->checkMembers, $this->user); + } + + public function testAddATag() { + $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($state); + } + + public function testAddADuplicateTag() { + $this->assertException("constraintViolation", "Db", "ExceptionInput"); + Arsse::$db->tagAdd("john.doe@example.com", ['name' => "Interesting"]); + } + + public function testAddATagWithAMissingName() { + $this->assertException("missing", "Db", "ExceptionInput"); + Arsse::$db->tagAdd("john.doe@example.com", []); + } + + public function testAddATagWithABlankName() { + $this->assertException("missing", "Db", "ExceptionInput"); + Arsse::$db->tagAdd("john.doe@example.com", ['name' => ""]); + } + + public function testAddATagWithAWhitespaceName() { + $this->assertException("whitespace", "Db", "ExceptionInput"); + Arsse::$db->tagAdd("john.doe@example.com", ['name' => " "]); + } + + public function testAddATagWithoutAuthority() { + Phake::when(Arsse::$user)->authorize->thenReturn(false); + $this->assertException("notAuthorized", "User", "ExceptionAuthz"); + Arsse::$db->tagAdd("john.doe@example.com", ['name' => "Boring"]); + } + + public function testListTags() { + $exp = [ + ['id' => 2, 'name' => "Fascinating"], + ['id' => 1, 'name' => "Interesting"], + ['id' => 4, 'name' => "Lonely"], + ]; + $this->assertResult($exp, Arsse::$db->tagList("john.doe@example.com")); + $exp = [ + ['id' => 3, 'name' => "Boring"], + ]; + $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() { + Phake::when(Arsse::$user)->authorize->thenReturn(false); + $this->assertException("notAuthorized", "User", "ExceptionAuthz"); + Arsse::$db->tagList("john.doe@example.com"); + } + + public function testRemoveATag() { + $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($state); + } + + public function testRemoveATagByName() { + $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($state); + } + + public function testRemoveAMissingTag() { + $this->assertException("subjectMissing", "Db", "ExceptionInput"); + Arsse::$db->tagRemove("john.doe@example.com", 2112); + } + + public function testRemoveAnInvalidTag() { + $this->assertException("typeViolation", "Db", "ExceptionInput"); + Arsse::$db->tagRemove("john.doe@example.com", -1); + } + + public function testRemoveAnInvalidTagByName() { + $this->assertException("typeViolation", "Db", "ExceptionInput"); + Arsse::$db->tagRemove("john.doe@example.com", [], true); + } + + public function testRemoveATagOfTheWrongOwner() { + $this->assertException("subjectMissing", "Db", "ExceptionInput"); + Arsse::$db->tagRemove("john.doe@example.com", 3); // tag ID 3 belongs to Jane + } + + public function testRemoveATagWithoutAuthority() { + Phake::when(Arsse::$user)->authorize->thenReturn(false); + $this->assertException("notAuthorized", "User", "ExceptionAuthz"); + Arsse::$db->tagRemove("john.doe@example.com", 1); + } + + public function testGetThePropertiesOfATag() { + $exp = [ + 'id' => 2, + 'name' => "Fascinating", + ]; + $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() { + $this->assertException("subjectMissing", "Db", "ExceptionInput"); + Arsse::$db->tagPropertiesGet("john.doe@example.com", 2112); + } + + public function testGetThePropertiesOfAnInvalidTag() { + $this->assertException("typeViolation", "Db", "ExceptionInput"); + Arsse::$db->tagPropertiesGet("john.doe@example.com", -1); + } + + public function testGetThePropertiesOfAnInvalidTagByName() { + $this->assertException("typeViolation", "Db", "ExceptionInput"); + Arsse::$db->tagPropertiesGet("john.doe@example.com", [], true); + } + + public function testGetThePropertiesOfATagOfTheWrongOwner() { + $this->assertException("subjectMissing", "Db", "ExceptionInput"); + Arsse::$db->tagPropertiesGet("john.doe@example.com", 3); // tag ID 3 belongs to Jane + } + + public function testGetThePropertiesOfATagWithoutAuthority() { + Phake::when(Arsse::$user)->authorize->thenReturn(false); + $this->assertException("notAuthorized", "User", "ExceptionAuthz"); + Arsse::$db->tagPropertiesGet("john.doe@example.com", 1); + } + + public function testMakeNoChangesToATag() { + $this->assertFalse(Arsse::$db->tagPropertiesSet("john.doe@example.com", 1, [])); + } + + public function testRenameATag() { + $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($state); + } + + public function testRenameATagByName() { + $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($state); + } + + public function testRenameATagToTheEmptyString() { + $this->assertException("missing", "Db", "ExceptionInput"); + $this->assertTrue(Arsse::$db->tagPropertiesSet("john.doe@example.com", 1, ['name' => ""])); + } + + public function testRenameATagToWhitespaceOnly() { + $this->assertException("whitespace", "Db", "ExceptionInput"); + $this->assertTrue(Arsse::$db->tagPropertiesSet("john.doe@example.com", 1, ['name' => " "])); + } + + public function testRenameATagToAnInvalidValue() { + $this->assertException("typeViolation", "Db", "ExceptionInput"); + $this->assertTrue(Arsse::$db->tagPropertiesSet("john.doe@example.com", 1, ['name' => []])); + } + + public function testCauseATagCollision() { + $this->assertException("constraintViolation", "Db", "ExceptionInput"); + Arsse::$db->tagPropertiesSet("john.doe@example.com", 1, ['name' => "Fascinating"]); + } + + public function testSetThePropertiesOfAMissingTag() { + $this->assertException("subjectMissing", "Db", "ExceptionInput"); + Arsse::$db->tagPropertiesSet("john.doe@example.com", 2112, ['name' => "Exciting"]); + } + + public function testSetThePropertiesOfAnInvalidTag() { + $this->assertException("typeViolation", "Db", "ExceptionInput"); + Arsse::$db->tagPropertiesSet("john.doe@example.com", -1, ['name' => "Exciting"]); + } + + public function testSetThePropertiesOfAnInvalidTagByName() { + $this->assertException("typeViolation", "Db", "ExceptionInput"); + Arsse::$db->tagPropertiesSet("john.doe@example.com", [], ['name' => "Exciting"], true); + } + + public function testSetThePropertiesOfATagForTheWrongOwner() { + $this->assertException("subjectMissing", "Db", "ExceptionInput"); + Arsse::$db->tagPropertiesSet("john.doe@example.com", 3, ['name' => "Exciting"]); // tag ID 3 belongs to Jane + } + + public function testSetThePropertiesOfATagWithoutAuthority() { + Phake::when(Arsse::$user)->authorize->thenReturn(false); + $this->assertException("notAuthorized", "User", "ExceptionAuthz"); + Arsse::$db->tagPropertiesSet("john.doe@example.com", 1, ['name' => "Exciting"]); + } + + public function testListTagledSubscriptions() { + $exp = [1,5]; + $this->assertEquals($exp, Arsse::$db->tagSubscriptionsGet("john.doe@example.com", 1)); + $this->assertEquals($exp, Arsse::$db->tagSubscriptionsGet("john.doe@example.com", "Interesting", true)); + $exp = [1,3,5]; + $this->assertEquals($exp, Arsse::$db->tagSubscriptionsGet("john.doe@example.com", 2)); + $this->assertEquals($exp, Arsse::$db->tagSubscriptionsGet("john.doe@example.com", "Fascinating", true)); + $exp = []; + $this->assertEquals($exp, Arsse::$db->tagSubscriptionsGet("john.doe@example.com", 4)); + $this->assertEquals($exp, Arsse::$db->tagSubscriptionsGet("john.doe@example.com", "Lonely", true)); + } + + public function testListTagledSubscriptionsForAMissingTag() { + $this->assertException("subjectMissing", "Db", "ExceptionInput"); + Arsse::$db->tagSubscriptionsGet("john.doe@example.com", 3); + } + + public function testListTagledSubscriptionsForAnInvalidTag() { + $this->assertException("typeViolation", "Db", "ExceptionInput"); + Arsse::$db->tagSubscriptionsGet("john.doe@example.com", -1); + } + + public function testListTagledSubscriptionsWithoutAuthority() { + Phake::when(Arsse::$user)->authorize->thenReturn(false); + $this->assertException("notAuthorized", "User", "ExceptionAuthz"); + Arsse::$db->tagSubscriptionsGet("john.doe@example.com", 1); + } + + public function testApplyATagToSubscriptions() { + Arsse::$db->tagSubscriptionsSet("john.doe@example.com", 1, [3,4]); + $state = $this->primeExpectations($this->data, $this->checkMembers); + $state['arsse_tag_members']['rows'][1][2] = 1; + $state['arsse_tag_members']['rows'][] = [1,4,1]; + $this->compareExpectations($state); + } + + public function testClearATagFromSubscriptions() { + Arsse::$db->tagSubscriptionsSet("john.doe@example.com", 1, [1,3], true); + $state = $this->primeExpectations($this->data, $this->checkMembers); + $state['arsse_tag_members']['rows'][0][2] = 0; + $this->compareExpectations($state); + } + + public function testApplyATagToSubscriptionsByName() { + Arsse::$db->tagSubscriptionsSet("john.doe@example.com", "Interesting", [3,4], false, true); + $state = $this->primeExpectations($this->data, $this->checkMembers); + $state['arsse_tag_members']['rows'][1][2] = 1; + $state['arsse_tag_members']['rows'][] = [1,4,1]; + $this->compareExpectations($state); + } + + public function testClearATagFromSubscriptionsByName() { + Arsse::$db->tagSubscriptionsSet("john.doe@example.com", "Interesting", [1,3], true, true); + $state = $this->primeExpectations($this->data, $this->checkMembers); + $state['arsse_tag_members']['rows'][0][2] = 0; + $this->compareExpectations($state); + } + + public function testApplyATagToSubscriptionsWithoutAuthority() { + 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() { + $exp = [ + ['tag_id' => 1, 'tag_name' => "Interesting", 'subscription_id' => 1, 'subscription_name' => "Lord of Carrots"], + ['tag_id' => 1, 'tag_name' => "Interesting", 'subscription_id' => 5, 'subscription_name' => "Feed Title"], + ['tag_id' => 2, 'tag_name' => "Fascinating", 'subscription_id' => 1, 'subscription_name' => "Lord of Carrots"], + ['tag_id' => 2, 'tag_name' => "Fascinating", 'subscription_id' => 3, 'subscription_name' => "Subscription Title"], + ['tag_id' => 2, 'tag_name' => "Fascinating", 'subscription_id' => 5, 'subscription_name' => "Feed Title"], + ]; + $this->assertResult($exp, Arsse::$db->tagSummarize("john.doe@example.com")); + } + + public function testSummarizeTagsWithoutAuthority() { + Phake::when(Arsse::$user)->authorize->thenReturn(false); + $this->assertException("notAuthorized", "User", "ExceptionAuthz"); + Arsse::$db->tagSummarize("john.doe@example.com"); + } +} From e6f70527cf0c6d1f939244ff1b1cd7cdf33f937d Mon Sep 17 00:00:00 2001 From: "J. King" Date: Thu, 7 Mar 2019 08:20:09 -0500 Subject: [PATCH 037/142] Simplify tag summary --- lib/Database.php | 9 +++------ tests/cases/Database/SeriesTag.php | 10 +++++----- 2 files changed, 8 insertions(+), 11 deletions(-) diff --git a/lib/Database.php b/lib/Database.php index 043b993d..f5409d10 100644 --- a/lib/Database.php +++ b/lib/Database.php @@ -2002,14 +2002,11 @@ class Database { } return $this->db->prepare( "SELECT - arsse_tags.id as tag_id, - arsse_tags.name as tag_name, - arsse_subscriptions.id as subscription_id, - coalesce(arsse_subscriptions.title, arsse_feeds.title) as subscription_name + arsse_tags.id as id, + arsse_tags.name as name, + arsse_tag_members.subscription as subscription FROM arsse_tag_members join arsse_tags on arsse_tags.id = arsse_tag_members.tag - join arsse_subscriptions on arsse_subscriptions.id = arsse_tag_members.subscription - join arsse_feeds on arsse_feeds.id = arsse_subscriptions.feed WHERE arsse_tags.owner = ? and assigned = 1", "str" )->run($user); diff --git a/tests/cases/Database/SeriesTag.php b/tests/cases/Database/SeriesTag.php index ea40d414..7c5aa1c5 100644 --- a/tests/cases/Database/SeriesTag.php +++ b/tests/cases/Database/SeriesTag.php @@ -378,11 +378,11 @@ trait SeriesTag { public function testSummarizeTags() { $exp = [ - ['tag_id' => 1, 'tag_name' => "Interesting", 'subscription_id' => 1, 'subscription_name' => "Lord of Carrots"], - ['tag_id' => 1, 'tag_name' => "Interesting", 'subscription_id' => 5, 'subscription_name' => "Feed Title"], - ['tag_id' => 2, 'tag_name' => "Fascinating", 'subscription_id' => 1, 'subscription_name' => "Lord of Carrots"], - ['tag_id' => 2, 'tag_name' => "Fascinating", 'subscription_id' => 3, 'subscription_name' => "Subscription Title"], - ['tag_id' => 2, 'tag_name' => "Fascinating", 'subscription_id' => 5, 'subscription_name' => "Feed Title"], + ['id' => 1, 'name' => "Interesting", 'subscription' => 1], + ['id' => 1, 'name' => "Interesting", 'subscription' => 5], + ['id' => 2, 'name' => "Fascinating", 'subscription' => 1], + ['id' => 2, 'name' => "Fascinating", 'subscription' => 3], + ['id' => 2, 'name' => "Fascinating", 'subscription' => 5], ]; $this->assertResult($exp, Arsse::$db->tagSummarize("john.doe@example.com")); } From 5de1844f6d25b5940d6db8b82f47ec29ae4f4dbd Mon Sep 17 00:00:00 2001 From: "J. King" Date: Thu, 7 Mar 2019 11:07:22 -0500 Subject: [PATCH 038/142] Add article selection by tag --- lib/Context/ExclusionContext.php | 10 ++ lib/Database.php | 15 ++ tests/cases/Database/SeriesArticle.php | 222 +++++++++++++++---------- tests/cases/Misc/TestContext.php | 2 + 4 files changed, 160 insertions(+), 89 deletions(-) diff --git a/lib/Context/ExclusionContext.php b/lib/Context/ExclusionContext.php index d5299fe0..1f91994a 100644 --- a/lib/Context/ExclusionContext.php +++ b/lib/Context/ExclusionContext.php @@ -12,6 +12,8 @@ use JKingWeb\Arsse\Misc\Date; class ExclusionContext { public $folder; public $folderShallow; + public $tag; + public $tagName; public $subscription; public $edition; public $article; @@ -101,6 +103,14 @@ class ExclusionContext { return $this->act(__FUNCTION__, func_num_args(), $spec); } + public function tag(int $spec = null) { + return $this->act(__FUNCTION__, func_num_args(), $spec); + } + + public function tagName(string $spec = null) { + return $this->act(__FUNCTION__, func_num_args(), $spec); + } + public function subscription(int $spec = null) { return $this->act(__FUNCTION__, func_num_args(), $spec); } diff --git a/lib/Database.php b/lib/Database.php index f5409d10..dc2c74d5 100644 --- a/lib/Database.php +++ b/lib/Database.php @@ -1325,6 +1325,21 @@ class Database { $q->setWhereNot("arsse_articles.id in (select article from labelled where label_name = ?)", "str", $context->not->labelName); } } + if ($context->tag() || $context->not->tag() || $context->tagName() || $context->not->tagName()) { + $q->setCTE("tagged(id,name,subscription)","SELECT arsse_tags.id, arsse_tags.name, arsse_tag_members.subscription FROM arsse_tag_members join arsse_tags on arsse_tags.id = arsse_tag_members.tag WHERE arsse_tags.owner = ? and assigned = 1", "str", $user); + if ($context->tag()) { + $q->setWhere("arsse_subscriptions.id in (select subscription from tagged where id = ?)", "int", $context->tag); + } + if ($context->not->tag()) { + $q->setWhereNot("arsse_subscriptions.id in (select subscription from tagged where id = ?)", "int", $context->not->tag); + } + if ($context->tagName()) { + $q->setWhere("arsse_subscriptions.id in (select subscription from tagged where name = ?)", "str", $context->tagName); + } + if ($context->not->tagName()) { + $q->setWhereNot("arsse_subscriptions.id in (select subscription from tagged where name = ?)", "str", $context->not->tagName); + } + } if ($context->folder()) { // add a common table expression to list the folder and its children so that we select from the entire subtree $q->setCTE("folders(folder)", "SELECT ? union select id from arsse_folders join folders on parent = folder", "int", $context->folder); diff --git a/tests/cases/Database/SeriesArticle.php b/tests/cases/Database/SeriesArticle.php index f652c6f8..9fb893b2 100644 --- a/tests/cases/Database/SeriesArticle.php +++ b/tests/cases/Database/SeriesArticle.php @@ -28,25 +28,6 @@ trait SeriesArticle { ["john.doe@example.net", "", "John Doe"], ], ], - 'arsse_folders' => [ - 'columns' => [ - 'id' => "int", - 'owner' => "str", - 'parent' => "int", - 'name' => "str", - ], - 'rows' => [ - [1, "john.doe@example.com", null, "Technology"], - [2, "john.doe@example.com", 1, "Software"], - [3, "john.doe@example.com", 1, "Rocketry"], - [4, "jane.doe@example.com", null, "Politics"], - [5, "john.doe@example.com", null, "Politics"], - [6, "john.doe@example.com", 2, "Politics"], - [7, "john.doe@example.net", null, "Technology"], - [8, "john.doe@example.net", 7, "Software"], - [9, "john.doe@example.net", null, "Politics"], - ] - ], 'arsse_feeds' => [ 'columns' => [ 'id' => "int", @@ -69,6 +50,42 @@ trait SeriesArticle { [13,"http://example.com/13", "Feed 13"], ] ], + 'arsse_folders' => [ + 'columns' => [ + 'id' => "int", + 'owner' => "str", + 'parent' => "int", + 'name' => "str", + ], + 'rows' => [ + [1, "john.doe@example.com", null, "Technology"], + [2, "john.doe@example.com", 1, "Software"], + [3, "john.doe@example.com", 1, "Rocketry"], + [4, "jane.doe@example.com", null, "Politics"], + [5, "john.doe@example.com", null, "Politics"], + [6, "john.doe@example.com", 2, "Politics"], + [7, "john.doe@example.net", null, "Technology"], + [8, "john.doe@example.net", 7, "Software"], + [9, "john.doe@example.net", null, "Politics"], + ] + ], + 'arsse_tags' => [ + 'columns' => [ + 'id' => "int", + 'owner' => "str", + 'name' => "str", + ], + 'rows' => [ + [1, "john.doe@example.com", "Technology"], + [2, "john.doe@example.com", "Software"], + [3, "john.doe@example.com", "Rocketry"], + [4, "jane.doe@example.com", "Politics"], + [5, "john.doe@example.com", "Politics"], + [6, "john.doe@example.net", "Technology"], + [7, "john.doe@example.net", "Software"], + [8, "john.doe@example.net", "Politics"], + ] + ], 'arsse_subscriptions' => [ 'columns' => [ 'id' => "int", @@ -94,6 +111,25 @@ trait SeriesArticle { [14,"john.doe@example.net",4, 7,null], ] ], + 'arsse_tag_members' => [ + 'columns' => [ + 'tag' => "int", + 'subscription' => "int", + 'assigned' => "bool", + ], + 'rows' => [ + [1,3,1], + [1,4,1], + [2,4,1], + [5,1,0], + [5,4,1], + [5,5,1], + [6,13,1], + [6,14,1], + [7,13,1], + [8,12,1], + ], + ], 'arsse_articles' => [ 'columns' => [ 'id' => "int", @@ -387,76 +423,84 @@ trait SeriesArticle { public function provideContextMatches() { return [ - "Blank context" => [new Context, [1,2,3,4,5,6,7,8,19,20]], - "Folder tree" => [(new Context)->folder(1), [5,6,7,8]], - "Leaf folder" => [(new Context)->folder(6), [7,8]], - "Root folder only" => [(new Context)->folderShallow(0), [1,2,3,4]], - "Shallow folder" => [(new Context)->folderShallow(1), [5,6]], - "Subscription" => [(new Context)->subscription(5), [19,20]], - "Unread" => [(new Context)->subscription(5)->unread(true), [20]], - "Read" => [(new Context)->subscription(5)->unread(false), [19]], - "Starred" => [(new Context)->starred(true), [1,20]], - "Unstarred" => [(new Context)->starred(false), [2,3,4,5,6,7,8,19]], - "Starred and Read" => [(new Context)->starred(true)->unread(false), [1]], - "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]], - "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]], - "Not after edition 19" => [(new Context)->subscription(5)->latestEdition(19), [19]], - "Not before edition 999" => [(new Context)->subscription(5)->oldestEdition(999), [20]], - "Not before edition 1001" => [(new Context)->subscription(5)->oldestEdition(1001), [20]], - "Not after article 3" => [(new Context)->latestArticle(3), [1,2,3]], - "Not before article 19" => [(new Context)->oldestArticle(19), [19,20]], - "Modified by author since 2005" => [(new Context)->modifiedSince("2005-01-01T00:00:00Z"), [2,4,6,8,20]], - "Modified by author since 2010" => [(new Context)->modifiedSince("2010-01-01T00:00:00Z"), [2,4,6,8,20]], - "Not modified by author since 2005" => [(new Context)->notModifiedSince("2005-01-01T00:00:00Z"), [1,3,5,7,19]], - "Not modified by author since 2000" => [(new Context)->notModifiedSince("2000-01-01T00:00:00Z"), [1,3,5,7,19]], - "Marked or labelled since 2014" => [(new Context)->markedSince("2014-01-01T00:00:00Z"), [8,19]], - "Marked or labelled since 2010" => [(new Context)->markedSince("2010-01-01T00:00:00Z"), [2,4,6,8,19,20]], - "Not marked or labelled since 2014" => [(new Context)->notMarkedSince("2014-01-01T00:00:00Z"), [1,2,3,4,5,6,7,20]], - "Not marked or labelled since 2005" => [(new Context)->notMarkedSince("2005-01-01T00:00:00Z"), [1,3,5,7]], - "Marked or labelled between 2000 and 2015" => [(new Context)->markedSince("2000-01-01T00:00:00Z")->notMarkedSince("2015-12-31T23:59:59Z"), [1,2,3,4,5,6,7,8,20]], - "Marked or labelled in 2010" => [(new Context)->markedSince("2010-01-01T00:00:00Z")->notMarkedSince("2010-12-31T23:59:59Z"), [2,4,6,20]], - "Paged results" => [(new Context)->limit(2)->oldestEdition(4), [4,5]], - "Reversed paged results" => [(new Context)->limit(2)->latestEdition(7)->reverse(true), [7,6]], - "With label ID 1" => [(new Context)->label(1), [1,19]], - "With label ID 2" => [(new Context)->label(2), [1,5,20]], - "With label 'Interesting'" => [(new Context)->labelName("Interesting"), [1,19]], - "With label 'Fascinating'" => [(new Context)->labelName("Fascinating"), [1,5,20]], - "Article ID 20" => [(new Context)->article(20), [20]], - "Edition ID 1001" => [(new Context)->edition(1001), [20]], - "Multiple articles" => [(new Context)->articles([1,20,50]), [1,20]], - "Multiple starred articles" => [(new Context)->articles([1,2,3])->starred(true), [1]], - "Multiple unstarred articles" => [(new Context)->articles([1,2,3])->starred(false), [2,3]], - "Multiple articles" => [(new Context)->articles([1,20,50]), [1,20]], - "Multiple editions" => [(new Context)->editions([1,1001,50]), [1,20]], - "150 articles" => [(new Context)->articles(range(1, Database::LIMIT_SET_SIZE * 3)), [1,2,3,4,5,6,7,8,19,20]], - "Search title or content 1" => [(new Context)->searchTerms(["Article"]), [1,2,3]], - "Search title or content 2" => [(new Context)->searchTerms(["one", "first"]), [1]], - "Search title or content 3" => [(new Context)->searchTerms(["one first"]), []], - "Search title 1" => [(new Context)->titleTerms(["two"]), [2]], - "Search title 2" => [(new Context)->titleTerms(["title two"]), [2]], - "Search title 3" => [(new Context)->titleTerms(["two", "title"]), [2]], - "Search title 4" => [(new Context)->titleTerms(["two title"]), []], - "Search note 1" => [(new Context)->annotationTerms(["some"]), [2]], - "Search note 2" => [(new Context)->annotationTerms(["some Note"]), [2]], - "Search note 3" => [(new Context)->annotationTerms(["note", "some"]), [2]], - "Search note 4" => [(new Context)->annotationTerms(["some", "sauce"]), []], - "Search author 1" => [(new Context)->authorTerms(["doe"]), [4,5,6,7]], - "Search author 2" => [(new Context)->authorTerms(["jane doe"]), [6,7]], - "Search author 3" => [(new Context)->authorTerms(["doe", "jane"]), [6,7]], - "Search author 4" => [(new Context)->authorTerms(["doe jane"]), []], - "Folder tree 1 excluding subscription 4" => [(new Context)->not->subscription(4)->folder(1), [5,6]], - "Folder tree 1 excluding articles 7 and 8" => [(new Context)->folder(1)->not->articles([7,8]), [5,6]], - "Folder tree 1 excluding no articles" => [(new Context)->folder(1)->not->articles([]), [5,6,7,8]], - "Marked or labelled between 2000 and 2015 excluding in 2010" => [(new Context)->markedSince("2000-01-01T00:00:00Z")->notMarkedSince("2015-12-31T23:59:59")->not->markedSince("2010-01-01T00:00:00Z")->not->notMarkedSince("2010-12-31T23:59:59Z"), [1,3,5,7,8]], - "Search with exclusion" => [(new Context)->searchTerms(["Article"])->not->searchTerms(["one", "two"]), [3]], - "Excluded folder tree" => [(new Context)->not->folder(1), [1,2,3,4,19,20]], - "Excluding label ID 2" => [(new Context)->not->label(2), [2,3,4,6,7,8,19]], - "Excluding label 'Fascinating'" => [(new Context)->not->labelName("Fascinating"), [2,3,4,6,7,8,19]], - "Search 501 terms" => [(new Context)->searchTerms(array_merge(range(1,500),[str_repeat("a", 1000)])), []], + 'Blank context' => [new Context, [1,2,3,4,5,6,7,8,19,20]], + 'Folder tree' => [(new Context)->folder(1), [5,6,7,8]], + 'Leaf folder' => [(new Context)->folder(6), [7,8]], + 'Root folder only' => [(new Context)->folderShallow(0), [1,2,3,4]], + 'Shallow folder' => [(new Context)->folderShallow(1), [5,6]], + 'Subscription' => [(new Context)->subscription(5), [19,20]], + 'Unread' => [(new Context)->subscription(5)->unread(true), [20]], + 'Read' => [(new Context)->subscription(5)->unread(false), [19]], + 'Starred' => [(new Context)->starred(true), [1,20]], + 'Unstarred' => [(new Context)->starred(false), [2,3,4,5,6,7,8,19]], + 'Starred and Read' => [(new Context)->starred(true)->unread(false), [1]], + '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]], + '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]], + 'Not after edition 19' => [(new Context)->subscription(5)->latestEdition(19), [19]], + 'Not before edition 999' => [(new Context)->subscription(5)->oldestEdition(999), [20]], + 'Not before edition 1001' => [(new Context)->subscription(5)->oldestEdition(1001), [20]], + 'Not after article 3' => [(new Context)->latestArticle(3), [1,2,3]], + 'Not before article 19' => [(new Context)->oldestArticle(19), [19,20]], + 'Modified by author since 2005' => [(new Context)->modifiedSince("2005-01-01T00:00:00Z"), [2,4,6,8,20]], + 'Modified by author since 2010' => [(new Context)->modifiedSince("2010-01-01T00:00:00Z"), [2,4,6,8,20]], + 'Not modified by author since 2005' => [(new Context)->notModifiedSince("2005-01-01T00:00:00Z"), [1,3,5,7,19]], + 'Not modified by author since 2000' => [(new Context)->notModifiedSince("2000-01-01T00:00:00Z"), [1,3,5,7,19]], + 'Marked or labelled since 2014' => [(new Context)->markedSince("2014-01-01T00:00:00Z"), [8,19]], + 'Marked or labelled since 2010' => [(new Context)->markedSince("2010-01-01T00:00:00Z"), [2,4,6,8,19,20]], + 'Not marked or labelled since 2014' => [(new Context)->notMarkedSince("2014-01-01T00:00:00Z"), [1,2,3,4,5,6,7,20]], + 'Not marked or labelled since 2005' => [(new Context)->notMarkedSince("2005-01-01T00:00:00Z"), [1,3,5,7]], + 'Marked or labelled between 2000 and 2015' => [(new Context)->markedSince("2000-01-01T00:00:00Z")->notMarkedSince("2015-12-31T23:59:59Z"), [1,2,3,4,5,6,7,8,20]], + 'Marked or labelled in 2010' => [(new Context)->markedSince("2010-01-01T00:00:00Z")->notMarkedSince("2010-12-31T23:59:59Z"), [2,4,6,20]], + 'Paged results' => [(new Context)->limit(2)->oldestEdition(4), [4,5]], + 'Reversed paged results' => [(new Context)->limit(2)->latestEdition(7)->reverse(true), [7,6]], + 'With label ID 1' => [(new Context)->label(1), [1,19]], + 'With label ID 2' => [(new Context)->label(2), [1,5,20]], + 'With label "Interesting"' => [(new Context)->labelName("Interesting"), [1,19]], + 'With label "Fascinating"' => [(new Context)->labelName("Fascinating"), [1,5,20]], + 'Article ID 20' => [(new Context)->article(20), [20]], + 'Edition ID 1001' => [(new Context)->edition(1001), [20]], + 'Multiple articles' => [(new Context)->articles([1,20,50]), [1,20]], + 'Multiple starred articles' => [(new Context)->articles([1,2,3])->starred(true), [1]], + 'Multiple unstarred articles' => [(new Context)->articles([1,2,3])->starred(false), [2,3]], + 'Multiple articles' => [(new Context)->articles([1,20,50]), [1,20]], + 'Multiple editions' => [(new Context)->editions([1,1001,50]), [1,20]], + '150 articles' => [(new Context)->articles(range(1, Database::LIMIT_SET_SIZE * 3)), [1,2,3,4,5,6,7,8,19,20]], + 'Search title or content 1' => [(new Context)->searchTerms(["Article"]), [1,2,3]], + 'Search title or content 2' => [(new Context)->searchTerms(["one", "first"]), [1]], + 'Search title or content 3' => [(new Context)->searchTerms(["one first"]), []], + 'Search title 1' => [(new Context)->titleTerms(["two"]), [2]], + 'Search title 2' => [(new Context)->titleTerms(["title two"]), [2]], + 'Search title 3' => [(new Context)->titleTerms(["two", "title"]), [2]], + 'Search title 4' => [(new Context)->titleTerms(["two title"]), []], + 'Search note 1' => [(new Context)->annotationTerms(["some"]), [2]], + 'Search note 2' => [(new Context)->annotationTerms(["some Note"]), [2]], + 'Search note 3' => [(new Context)->annotationTerms(["note", "some"]), [2]], + 'Search note 4' => [(new Context)->annotationTerms(["some", "sauce"]), []], + 'Search author 1' => [(new Context)->authorTerms(["doe"]), [4,5,6,7]], + 'Search author 2' => [(new Context)->authorTerms(["jane doe"]), [6,7]], + 'Search author 3' => [(new Context)->authorTerms(["doe", "jane"]), [6,7]], + 'Search author 4' => [(new Context)->authorTerms(["doe jane"]), []], + 'Folder tree 1 excluding subscription 4' => [(new Context)->not->subscription(4)->folder(1), [5,6]], + 'Folder tree 1 excluding articles 7 and 8' => [(new Context)->folder(1)->not->articles([7,8]), [5,6]], + 'Folder tree 1 excluding no articles' => [(new Context)->folder(1)->not->articles([]), [5,6,7,8]], + 'Marked or labelled between 2000 and 2015 excluding in 2010' => [(new Context)->markedSince("2000-01-01T00:00:00Z")->notMarkedSince("2015-12-31T23:59:59")->not->markedSince("2010-01-01T00:00:00Z")->not->notMarkedSince("2010-12-31T23:59:59Z"), [1,3,5,7,8]], + 'Search with exclusion' => [(new Context)->searchTerms(["Article"])->not->searchTerms(["one", "two"]), [3]], + 'Excluded folder tree' => [(new Context)->not->folder(1), [1,2,3,4,19,20]], + 'Excluding label ID 2' => [(new Context)->not->label(2), [2,3,4,6,7,8,19]], + 'Excluding label "Fascinating"' => [(new Context)->not->labelName("Fascinating"), [2,3,4,6,7,8,19]], + 'Search 501 terms' => [(new Context)->searchTerms(array_merge(range(1,500),[str_repeat("a", 1000)])), []], + 'With tag ID 1' => [(new Context)->tag(1), [5,6,7,8]], + 'With tag ID 5' => [(new Context)->tag(5), [7,8,19,20]], + 'With tag "Technology"' => [(new Context)->tagName("Technology"), [5,6,7,8]], + 'With tag "Politics"' => [(new Context)->tagName("Politics"), [7,8,19,20]], + 'Excluding tag ID 1' => [(new Context)->not->tag(1), [1,2,3,4,19,20]], + 'Excluding tag ID 5' => [(new Context)->not->tag(5), [1,2,3,4,5,6]], + 'Excluding tag "Technology"' => [(new Context)->not->tagName("Technology"), [1,2,3,4,19,20]], + 'Excluding tag "Politics"' => [(new Context)->not->tagName("Politics"), [1,2,3,4,5,6]], ]; } diff --git a/tests/cases/Misc/TestContext.php b/tests/cases/Misc/TestContext.php index d134c0fc..e85d58ec 100644 --- a/tests/cases/Misc/TestContext.php +++ b/tests/cases/Misc/TestContext.php @@ -30,6 +30,8 @@ class TestContext extends \JKingWeb\Arsse\Test\AbstractTest { 'offset' => 5, 'folder' => 42, 'folderShallow' => 42, + 'tag' => 44, + 'tagName' => "XLIV", 'subscription' => 2112, 'article' => 255, 'edition' => 65535, From 38bdde116702df910afe73936a71fd33877ef76a Mon Sep 17 00:00:00 2001 From: "J. King" Date: Sat, 9 Mar 2019 16:23:56 -0500 Subject: [PATCH 039/142] Add access tokens to the db, with relevant code Tokens are similar to sessions in that they stand in for users, but the protocol handlers will manage them; Fever login hashes are the originating use case for them. These must never expire, for example, and we need to specify their values. This commit also performs a bit of database clean-up --- lib/Database.php | 54 ++++++++ lib/Db/MySQL/Driver.php | 2 +- sql/MySQL/4.sql | 18 +++ sql/PostgreSQL/4.sql | 17 +++ sql/SQLite3/1.sql | 4 +- sql/SQLite3/4.sql | 53 ++++++++ tests/cases/Database/Base.php | 1 + tests/cases/Database/SeriesArticle.php | 9 +- tests/cases/Database/SeriesCleanup.php | 30 ++++- 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 | 135 ++++++++++++++++++++ tests/cases/Database/SeriesUser.php | 12 +- 17 files changed, 333 insertions(+), 40 deletions(-) create mode 100644 tests/cases/Database/SeriesToken.php diff --git a/lib/Database.php b/lib/Database.php index dc2c74d5..01cd91ad 100644 --- a/lib/Database.php +++ b/lib/Database.php @@ -27,6 +27,7 @@ use JKingWeb\Arsse\Misc\ValueInfo; * - Editions, identifying authorial modifications to articles * - Labels, which belong to users and can be assigned to multiple articles * - Sessions, used by some protocols to identify users across periods of time + * - Tokens, similar to sessions, but with more control over their properties * - Metadata, used internally by the server * * The various methods of this class perform operations on these things, with @@ -380,6 +381,59 @@ class Database { return (($now + $diff) >= $expiry->getTimestamp()); } + /** Creates a new token for the given user in the given class + * + * @param string $user The user for whom to create the token + * @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 + */ + 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]); + } + // 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); + // return the ID + return $id; + } + + /** Revokes one or all tokens for a user in a class + * + * @param string $user The user who owns the token to be revoked + * @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 { + // 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 { + $out = $this->db->prepare("DELETE FROM arsse_tokens where \"user\" = ? and class = ? and id = ?", "str", "str", "str")->run($user, $class, $id)->changes(); + } + return (bool) $out; + } + + /** 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 > CURRENT_TIMESTAMP", "str", "str")->run($class, $id)->getRow(); + if (!$out) { + throw new Db\ExceptionInput("subjectMissing", ["action" => __FUNCTION__, "field" => "token", 'id' => $id]); + } + return $out; + } + + /** 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(); + } + /** Adds a folder for containing newsfeed subscriptions, returning an integer identifying the created folder * * The $data array may contain the following keys: diff --git a/lib/Db/MySQL/Driver.php b/lib/Db/MySQL/Driver.php index edd5f771..cec575b1 100644 --- a/lib/Db/MySQL/Driver.php +++ b/lib/Db/MySQL/Driver.php @@ -41,7 +41,7 @@ class Driver extends \JKingWeb\Arsse\Db\AbstractDriver { $this->exec($q); } // get the maximum packet size; parameter strings larger than this size need to be chunked - $this->packetSize = (int) $this->query("select variable_value from performance_schema.session_variables where variable_name = 'max_allowed_packet'")->getValue(); + $this->packetSize = (int) $this->query("SELECT variable_value from performance_schema.session_variables where variable_name = 'max_allowed_packet'")->getValue(); } public static function makeSetupQueries(): array { diff --git a/sql/MySQL/4.sql b/sql/MySQL/4.sql index aa073a6e..bde12122 100644 --- a/sql/MySQL/4.sql +++ b/sql/MySQL/4.sql @@ -20,4 +20,22 @@ create table arsse_tag_members( primary key(tag,subscription) ) character set utf8mb4 collate utf8mb4_unicode_ci; +create table arsse_tokens( + id varchar(255) not null, + class varchar(255) not null, + "user" varchar(255) not null references arsse_users(id) on delete cascade on update cascade, + created datetime(0) not null default CURRENT_TIMESTAMP, + expires datetime(0), + primary key(id,class) +) character set utf8mb4 collate utf8mb4_unicode_ci; + +alter table arsse_users drop column name; +alter table arsse_users drop column avatar_type; +alter table arsse_users drop column avatar_data; +alter table arsse_users drop column admin; +alter table arsse_users drop column rights; + +drop table arsse_users_meta; + + update arsse_meta set value = '5' where "key" = 'schema_version'; diff --git a/sql/PostgreSQL/4.sql b/sql/PostgreSQL/4.sql index e0cd8eb7..60962115 100644 --- a/sql/PostgreSQL/4.sql +++ b/sql/PostgreSQL/4.sql @@ -20,4 +20,21 @@ create table arsse_tag_members( primary key(tag,subscription) ); +create table arsse_tokens( + id text, + class text not null, + "user" text not null references arsse_users(id) on delete cascade on update cascade, + created timestamp(0) without time zone not null default CURRENT_TIMESTAMP, + expires timestamp(0) without time zone, + primary key(id,class) +); + +alter table arsse_users drop column name; +alter table arsse_users drop column avatar_type; +alter table arsse_users drop column avatar_data; +alter table arsse_users drop column admin; +alter table arsse_users drop column rights; + +drop table arsse_users_meta; + update arsse_meta set value = '5' where "key" = 'schema_version'; diff --git a/sql/SQLite3/1.sql b/sql/SQLite3/1.sql index 38176450..7f213e1b 100644 --- a/sql/SQLite3/1.sql +++ b/sql/SQLite3/1.sql @@ -5,8 +5,8 @@ create table arsse_sessions( -- sessions for Tiny Tiny RSS (and possibly others) id text primary key, -- UUID of session - created text not null default CURRENT_TIMESTAMP, -- Session start timestamp - expires text not null, -- Time at which session is no longer valid + created text not null default CURRENT_TIMESTAMP, -- session start timestamp + expires text not null, -- time at which session is no longer valid user text not null references arsse_users(id) on delete cascade on update cascade -- user associated with the session ) without rowid; diff --git a/sql/SQLite3/4.sql b/sql/SQLite3/4.sql index aa7cfbd8..f7cdd20f 100644 --- a/sql/SQLite3/4.sql +++ b/sql/SQLite3/4.sql @@ -20,6 +20,59 @@ create table arsse_tag_members( primary key(tag,subscription) -- only one association of a given tag to a given subscription ) without rowid; +create table arsse_tokens( +-- access tokens that are managed by the protocol handler and may optionally expire + id text, -- token identifier + class text not null, -- symbolic name of the protocol handler managing the token + user text not null references arsse_users(id) on delete cascade on update cascade, -- user associated with the token + created text not null default CURRENT_TIMESTAMP, -- creation timestamp + expires text, -- time at which token is no longer valid + primary key(id,class) -- tokens must be unique for their class +) without rowid; + + +-- clean up the user tables to remove unused stuff +-- if any of the removed things are implemented in future, necessary structures will be added back in at that time + +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 +) without rowid; +insert into arsse_users_new select id,password from arsse_users; +drop table arsse_users; +alter table arsse_users_new rename to arsse_users; + +drop table arsse_users_meta; + + +-- use WITHOUT ROWID tables when possible; this is an SQLite-specific change + +create table arsse_meta_new( +-- application metadata + key text primary key not null, -- metadata key + value text -- metadata value, serialized as a string +) without rowid; +insert into arsse_meta_new select * from arsse_meta; +drop table arsse_meta; +alter table arsse_meta_new rename to arsse_meta; + +create table arsse_marks_new( +-- users' actions on newsfeed entries + article integer not null references arsse_articles(id) on delete cascade, -- article associated with the marks + subscription integer not null references arsse_subscriptions(id) on delete cascade on update cascade, -- subscription associated with the marks; the subscription in turn belongs to a user + read boolean not null default 0, -- whether the article has been read + starred boolean not null default 0, -- whether the article is starred + modified text, -- time at which an article was last modified by a given user + note text not null default '', -- Tiny Tiny RSS freeform user note + touched boolean not null default 0, -- used to indicate a record has been modified during the course of some transactions + primary key(article,subscription) -- no more than one mark-set per article per user +) without rowid; +insert into arsse_marks_new select * from arsse_marks; +drop table arsse_marks; +alter table arsse_marks_new rename to arsse_marks; + + -- set version marker pragma user_version = 5; update arsse_meta set value = '5' where "key" = 'schema_version'; diff --git a/tests/cases/Database/Base.php b/tests/cases/Database/Base.php index 9e140c4d..47803ffd 100644 --- a/tests/cases/Database/Base.php +++ b/tests/cases/Database/Base.php @@ -20,6 +20,7 @@ abstract class Base extends \JKingWeb\Arsse\Test\AbstractTest { use SeriesMeta; use SeriesUser; use SeriesSession; + use SeriesToken; use SeriesFolder; use SeriesFeed; use SeriesSubscription; diff --git a/tests/cases/Database/SeriesArticle.php b/tests/cases/Database/SeriesArticle.php index 9fb893b2..5340fcc7 100644 --- a/tests/cases/Database/SeriesArticle.php +++ b/tests/cases/Database/SeriesArticle.php @@ -19,13 +19,12 @@ trait SeriesArticle { 'columns' => [ 'id' => 'str', 'password' => 'str', - 'name' => 'str', ], 'rows' => [ - ["jane.doe@example.com", "", "Jane Doe"], - ["john.doe@example.com", "", "John Doe"], - ["john.doe@example.org", "", "John Doe"], - ["john.doe@example.net", "", "John Doe"], + ["jane.doe@example.com", ""], + ["john.doe@example.com", ""], + ["john.doe@example.org", ""], + ["john.doe@example.net", ""], ], ], 'arsse_feeds' => [ diff --git a/tests/cases/Database/SeriesCleanup.php b/tests/cases/Database/SeriesCleanup.php index f8b4199b..6d80a7eb 100644 --- a/tests/cases/Database/SeriesCleanup.php +++ b/tests/cases/Database/SeriesCleanup.php @@ -29,11 +29,10 @@ trait SeriesCleanup { 'columns' => [ 'id' => 'str', 'password' => 'str', - 'name' => 'str', ], 'rows' => [ - ["jane.doe@example.com", "", "Jane Doe"], - ["john.doe@example.com", "", "John Doe"], + ["jane.doe@example.com", ""], + ["john.doe@example.com", ""], ], ], 'arsse_sessions' => [ @@ -51,6 +50,20 @@ trait SeriesCleanup { ["e", $daysago, $nowish, "jane.doe@example.com"], // created more than a day ago and expired, thus deleted ], ], + 'arsse_tokens' => [ + 'columns' => [ + 'id' => "str", + 'class' => "str", + 'user' => "str", + 'expires' => "datetime", + ], + 'rows' => [ + ["80fa94c1a11f11e78667001e673b2560", "fever.login", "jane.doe@example.com", $faroff], + ["27c6de8da13311e78667001e673b2560", "fever.login", "jane.doe@example.com", $weeksago], // expired + ["ab3b3eb8a13311e78667001e673b2560", "class.class", "jane.doe@example.com", null], + ["da772f8fa13c11e78667001e673b2560", "class.class", "john.doe@example.com", $soon], + ], + ], 'arsse_feeds' => [ 'columns' => [ 'id' => "int", @@ -226,4 +239,15 @@ trait SeriesCleanup { } $this->compareExpectations($state); } + + public function testCleanUpExpiredTokens() { + Arsse::$db->tokenCleanup(); + $state = $this->primeExpectations($this->data, [ + 'arsse_tokens' => ["id", "class"] + ]); + foreach ([2] as $id) { + unset($state['arsse_tokens']['rows'][$id - 1]); + } + $this->compareExpectations($state); + } } diff --git a/tests/cases/Database/SeriesFeed.php b/tests/cases/Database/SeriesFeed.php index c7cd2a4d..a01f0644 100644 --- a/tests/cases/Database/SeriesFeed.php +++ b/tests/cases/Database/SeriesFeed.php @@ -22,11 +22,10 @@ trait SeriesFeed { 'columns' => [ 'id' => 'str', 'password' => 'str', - 'name' => 'str', ], 'rows' => [ - ["jane.doe@example.com", "", "Jane Doe"], - ["john.doe@example.com", "", "John Doe"], + ["jane.doe@example.com", ""], + ["john.doe@example.com", ""], ], ], 'arsse_feeds' => [ diff --git a/tests/cases/Database/SeriesFolder.php b/tests/cases/Database/SeriesFolder.php index 99c9f1ae..9643b64b 100644 --- a/tests/cases/Database/SeriesFolder.php +++ b/tests/cases/Database/SeriesFolder.php @@ -16,11 +16,10 @@ trait SeriesFolder { 'columns' => [ 'id' => 'str', 'password' => 'str', - 'name' => 'str', ], 'rows' => [ - ["jane.doe@example.com", "", "Jane Doe"], - ["john.doe@example.com", "", "John Doe"], + ["jane.doe@example.com", ""], + ["john.doe@example.com", ""], ], ], 'arsse_folders' => [ diff --git a/tests/cases/Database/SeriesLabel.php b/tests/cases/Database/SeriesLabel.php index e6fc426e..9ffc01bc 100644 --- a/tests/cases/Database/SeriesLabel.php +++ b/tests/cases/Database/SeriesLabel.php @@ -18,13 +18,12 @@ trait SeriesLabel { 'columns' => [ 'id' => 'str', 'password' => 'str', - 'name' => 'str', ], 'rows' => [ - ["jane.doe@example.com", "", "Jane Doe"], - ["john.doe@example.com", "", "John Doe"], - ["john.doe@example.org", "", "John Doe"], - ["john.doe@example.net", "", "John Doe"], + ["jane.doe@example.com", ""], + ["john.doe@example.com", ""], + ["john.doe@example.org", ""], + ["john.doe@example.net", ""], ], ], 'arsse_folders' => [ diff --git a/tests/cases/Database/SeriesSession.php b/tests/cases/Database/SeriesSession.php index c9867420..74a809c1 100644 --- a/tests/cases/Database/SeriesSession.php +++ b/tests/cases/Database/SeriesSession.php @@ -27,11 +27,10 @@ trait SeriesSession { 'columns' => [ 'id' => 'str', 'password' => 'str', - 'name' => 'str', ], 'rows' => [ - ["jane.doe@example.com", "", "Jane Doe"], - ["john.doe@example.com", "", "John Doe"], + ["jane.doe@example.com", ""], + ["john.doe@example.com", ""], ], ], 'arsse_sessions' => [ diff --git a/tests/cases/Database/SeriesSubscription.php b/tests/cases/Database/SeriesSubscription.php index 0adac9e6..9756a281 100644 --- a/tests/cases/Database/SeriesSubscription.php +++ b/tests/cases/Database/SeriesSubscription.php @@ -18,11 +18,10 @@ trait SeriesSubscription { 'columns' => [ 'id' => 'str', 'password' => 'str', - 'name' => 'str', ], 'rows' => [ - ["jane.doe@example.com", "", "Jane Doe"], - ["john.doe@example.com", "", "John Doe"], + ["jane.doe@example.com", ""], + ["john.doe@example.com", ""], ], ], 'arsse_folders' => [ diff --git a/tests/cases/Database/SeriesTag.php b/tests/cases/Database/SeriesTag.php index 7c5aa1c5..404e2f1b 100644 --- a/tests/cases/Database/SeriesTag.php +++ b/tests/cases/Database/SeriesTag.php @@ -17,13 +17,12 @@ trait SeriesTag { 'columns' => [ 'id' => 'str', 'password' => 'str', - 'name' => 'str', ], 'rows' => [ - ["jane.doe@example.com", "", "Jane Doe"], - ["john.doe@example.com", "", "John Doe"], - ["john.doe@example.org", "", "John Doe"], - ["john.doe@example.net", "", "John Doe"], + ["jane.doe@example.com", ""], + ["john.doe@example.com", ""], + ["john.doe@example.org", ""], + ["john.doe@example.net", ""], ], ], 'arsse_feeds' => [ diff --git a/tests/cases/Database/SeriesToken.php b/tests/cases/Database/SeriesToken.php new file mode 100644 index 00000000..738fc58b --- /dev/null +++ b/tests/cases/Database/SeriesToken.php @@ -0,0 +1,135 @@ +data = [ + 'arsse_users' => [ + 'columns' => [ + 'id' => 'str', + 'password' => 'str', + ], + 'rows' => [ + ["jane.doe@example.com", ""], + ["john.doe@example.com", ""], + ], + ], + 'arsse_tokens' => [ + 'columns' => [ + 'id' => "str", + 'class' => "str", + 'user' => "str", + 'expires' => "datetime", + ], + '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], + ], + ], + ]; + } + + protected function tearDownSeriesToken() { + unset($this->data); + } + + public function testLookUpAValidToken() { + $exp1 = [ + 'id' => "80fa94c1a11f11e78667001e673b2560", + 'class' => "fever.login", + 'user' => "jane.doe@example.com" + ]; + $exp2 = [ + 'id' => "da772f8fa13c11e78667001e673b2560", + 'class' => "class.class", + 'user' => "john.doe@example.com" + ]; + $this->assertArraySubset($exp1, Arsse::$db->tokenLookup("fever.login", "80fa94c1a11f11e78667001e673b2560")); + $this->assertArraySubset($exp2, Arsse::$db->tokenLookup("class.class", "da772f8fa13c11e78667001e673b2560")); + // 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() { + $this->assertException("subjectMissing", "Db", "ExceptionInput"); + Arsse::$db->tokenLookup("class", "thisTokenDoesNotExist"); + } + + public function testLookUpAnExpiredToken() { + $this->assertException("subjectMissing", "Db", "ExceptionInput"); + Arsse::$db->tokenLookup("fever.login", "27c6de8da13311e78667001e673b2560"); + } + + public function testLookUpATokenOfTheWrongClass() { + $this->assertException("subjectMissing", "Db", "ExceptionInput"); + Arsse::$db->tokenLookup("some.class", "80fa94c1a11f11e78667001e673b2560"); + } + + public function testCreateAToken() { + $user = "jane.doe@example.com"; + $state = $this->primeExpectations($this->data, ['arsse_tokens' => ["id", "class", "expires", "user"]]); + $id = Arsse::$db->tokenCreate($user, "fever.login"); + $state['arsse_tokens']['rows'][] = [$id, "fever.login", null, $user]; + $this->compareExpectations($state); + $id = Arsse::$db->tokenCreate($user, "fever.login", null, new \DateTime("2020-01-01T00:00:00Z")); + $state['arsse_tokens']['rows'][] = [$id, "fever.login", "2020-01-01 00:00:00", $user]; + $this->compareExpectations($state); + Arsse::$db->tokenCreate($user, "fever.login", "token!", new \DateTime("2021-01-01T00:00:00Z")); + $state['arsse_tokens']['rows'][] = ["token!", "fever.login", "2021-01-01 00:00:00", $user]; + $this->compareExpectations($state); + } + + public function testCreateATokenWithoutAuthority() { + Phake::when(Arsse::$user)->authorize->thenReturn(false); + $this->assertException("notAuthorized", "User", "ExceptionAuthz"); + Arsse::$db->tokenCreate("fever.login", "jane.doe@example.com"); + } + + public function testRevokeAToken() { + $user = "jane.doe@example.com"; + $id = "80fa94c1a11f11e78667001e673b2560"; + $this->assertTrue(Arsse::$db->tokenRevoke($user, "fever.login", $id)); + $state = $this->primeExpectations($this->data, ['arsse_tokens' => ["id", "expires", "user"]]); + unset($state['arsse_tokens']['rows'][0]); + $this->compareExpectations($state); + // revoking a token which does not exist is not an error + $this->assertFalse(Arsse::$db->tokenRevoke($user, "fever.login", $id)); + } + + public function testRevokeAllTokens() { + $user = "jane.doe@example.com"; + $state = $this->primeExpectations($this->data, ['arsse_tokens' => ["id", "expires", "user"]]); + $this->assertTrue(Arsse::$db->tokenRevoke($user, "fever.login")); + unset($state['arsse_tokens']['rows'][0]); + unset($state['arsse_tokens']['rows'][1]); + $this->compareExpectations($state); + $this->assertTrue(Arsse::$db->tokenRevoke($user, "class.class")); + unset($state['arsse_tokens']['rows'][2]); + $this->compareExpectations($state); + // revoking tokens which do not exist is not an error + $this->assertFalse(Arsse::$db->tokenRevoke($user, "unknown.class")); + } + + public function testRevokeATokenWithoutAuthority() { + 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 49c324b9..991577a0 100644 --- a/tests/cases/Database/SeriesUser.php +++ b/tests/cases/Database/SeriesUser.php @@ -17,13 +17,11 @@ trait SeriesUser { 'columns' => [ 'id' => 'str', 'password' => 'str', - 'name' => 'str', - 'rights' => 'int', ], 'rows' => [ - ["admin@example.net", '$2y$10$PbcG2ZR3Z8TuPzM7aHTF8.v61dtCjzjK78gdZJcp4UePE8T9jEgBW', "Hard Lip Herbert", 100], // password is hash of "secret" - ["jane.doe@example.com", "", "Jane Doe", 0], - ["john.doe@example.com", "", "John Doe", 0], + ["admin@example.net", '$2y$10$PbcG2ZR3Z8TuPzM7aHTF8.v61dtCjzjK78gdZJcp4UePE8T9jEgBW'], // password is hash of "secret" + ["jane.doe@example.com", ""], + ["john.doe@example.com", ""], ], ], ]; @@ -68,8 +66,8 @@ trait SeriesUser { public function testAddANewUser() { $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','name','rights']]); - $state['arsse_users']['rows'][] = ["john.doe@example.org", null, 0]; + $state = $this->primeExpectations($this->data, ['arsse_users' => ['id']]); + $state['arsse_users']['rows'][] = ["john.doe@example.org"]; $this->compareExpectations($state); } From 3aa2b62d0266ff8c55d47ea77ada17cee8eb0cbb Mon Sep 17 00:00:00 2001 From: "J. King" Date: Sat, 9 Mar 2019 22:44:59 -0500 Subject: [PATCH 040/142] Basic Fever skeleton Authentication should work, but not tests have been written yet --- lib/REST.php | 12 +++--- lib/REST/Fever/API.php | 98 ++++++++++++++++++++++++++++++++++++++++++ lib/User.php | 2 +- 3 files changed, 106 insertions(+), 6 deletions(-) create mode 100644 lib/REST/Fever/API.php diff --git a/lib/REST.php b/lib/REST.php index 39899c19..1ad740d1 100644 --- a/lib/REST.php +++ b/lib/REST.php @@ -16,14 +16,12 @@ use Zend\Diactoros\Response\EmptyResponse; class REST { const API_LIST = [ - // NextCloud News version enumerator - 'ncn' => [ + 'ncn' => [ // NextCloud News version enumerator 'match' => '/index.php/apps/news/api', 'strip' => '/index.php/apps/news/api', 'class' => REST\NextCloudNews\Versions::class, ], - // NextCloud News v1-2 https://github.com/nextcloud/news/blob/master/docs/externalapi/Legacy.md - 'ncn_v1-2' => [ + 'ncn_v1-2' => [ // NextCloud News v1-2 https://github.com/nextcloud/news/blob/master/docs/externalapi/Legacy.md 'match' => '/index.php/apps/news/api/v1-2/', 'strip' => '/index.php/apps/news/api/v1-2', 'class' => REST\NextCloudNews\V1_2::class, @@ -38,9 +36,13 @@ class REST { 'strip' => '/tt-rss/feed-icons/', 'class' => REST\TinyTinyRSS\Icon::class, ], + 'fever' => [ // Fever https://web.archive.org/web/20161217042229/https://feedafever.com/api + 'match' => '/fever/', + 'strip' => '/fever/', + 'class' => REST\Fever\API::class, + ], // Other candidates: // Google Reader http://feedhq.readthedocs.io/en/latest/api/index.html - // Fever https://web.archive.org/web/20161217042229/https://feedafever.com/api // Feedbin v2 https://github.com/feedbin/feedbin-api // CommaFeed https://www.commafeed.com/api/ // Selfoss https://github.com/SSilence/selfoss/wiki/Restful-API-for-Apps-or-any-other-external-access diff --git a/lib/REST/Fever/API.php b/lib/REST/Fever/API.php new file mode 100644 index 00000000..b9fd5ad9 --- /dev/null +++ b/lib/REST/Fever/API.php @@ -0,0 +1,98 @@ +getQueryParams(); + if (!array_key_exists("api")) { + // the original would have shown the Fever UI in the absence of the "api" parameter, but we'll return 404 + return new EmptyResponse(404); + } + $xml = $inR['api'] === "xml"; + switch ($req->getMethod()) { + case "OPTIONS": + // do stuff + break; + case "POST": + if (strlen($req->getHeaderLine("Content-Type")) && $req->getHeaderLine("Content-Type") !== "application/x-www-form-urlencoded") { + return new EmptyResponse(415, ['Accept' => "application/x-www-form-urlencoded"]); + } + $inW = $req->getParsedBody(); + $out = [ + 'api_version' => self::LEVEL, + 'auth' => 0, + ]; + // check that the user specified credentials + if ($this->logIn(strtolower($inW['api_key'] ?? ""))) { + $out['auth'] = 1; + } else { + return $this->formatResponse($out, $xml); + } + // handle each possible parameter + # do stuff + // return the result + return $this->formatResponse($out, $xml); + break; + default: + return new EmptyResponse(405, ['Allow' => "OPTIONS,POST"]); + } + } + + protected function formatResponse(array $data, bool $xml): ResponseInterface { + if ($xml) { + throw \Exception("Not implemented yet"); + } else { + return new JsonResponse($data, 200, [], \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE); + } + } + + protected function logIn(string $hash): bool { + // if HTTP authentication was successful and sessions are not enforced, proceed unconditionally + if (isset(Arsse::$user->id) && !Arsse::$conf->userSessionEnforced) { + return true; + } + try { + // verify the supplied hash is valid + $s = Arsse::$db->TokenLookup($id, "fever.login"); + } catch (\JKingWeb\Arsse\Db\ExceptionInput $e) { + return false; + } + // set the user name + Arsse::$user->id = $s['user']; + return true; + } + + public static function registerUser(string $user, string $password = null): string { + $password = $password ?? Arsse::$user->generatePassword(); + $hash = md5("$user:$password"); + Arsse::$db->tokenCreate($user, "fever.login", $hash); + return $password; + } +} diff --git a/lib/User.php b/lib/User.php index d7aae1cc..82e8d3dd 100644 --- a/lib/User.php +++ b/lib/User.php @@ -114,7 +114,7 @@ class User { return $out; } - protected function generatePassword(): string { + public function generatePassword(): string { return (new PassGen)->length(Arsse::$conf->userTempPasswordLength)->get(); } } From b02c910b1e360b3287faad9c7b6ae7b4d34fa16b Mon Sep 17 00:00:00 2001 From: "J. King" Date: Sun, 10 Mar 2019 15:54:43 -0400 Subject: [PATCH 041/142] Make token creation check that the user exists --- lib/Database.php | 2 ++ tests/cases/Database/SeriesToken.php | 5 +++++ 2 files changed, 7 insertions(+) diff --git a/lib/Database.php b/lib/Database.php index 01cd91ad..df614b5a 100644 --- a/lib/Database.php +++ b/lib/Database.php @@ -392,6 +392,8 @@ class Database { // 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)) { + throw new User\Exception("doesNotExist", ["action" => __FUNCTION__, "user" => $user]); } // generate a token if it's not provided $id = $id ?? UUID::mint()->hex; diff --git a/tests/cases/Database/SeriesToken.php b/tests/cases/Database/SeriesToken.php index 738fc58b..ff85407b 100644 --- a/tests/cases/Database/SeriesToken.php +++ b/tests/cases/Database/SeriesToken.php @@ -96,6 +96,11 @@ trait SeriesToken { $this->compareExpectations($state); } + public function testCreateATokenForAMissingUser() { + $this->assertException("doesNotExist", "User"); + Arsse::$db->tokenCreate("fever.login", "jane.doe@example.biz"); + } + public function testCreateATokenWithoutAuthority() { Phake::when(Arsse::$user)->authorize->thenReturn(false); $this->assertException("notAuthorized", "User", "ExceptionAuthz"); From 86d52c8ff9a9538f017db776d2bf6c313979af32 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Sat, 16 Mar 2019 17:48:48 -0400 Subject: [PATCH 042/142] Fix test errors when PostgreSQL or MySQL are not available --- tests/cases/Database/Base.php | 10 ++++++---- tests/cases/DatabaseDrivers/MySQL.php | 2 +- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/tests/cases/Database/Base.php b/tests/cases/Database/Base.php index 219d4c02..92cf19cc 100644 --- a/tests/cases/Database/Base.php +++ b/tests/cases/Database/Base.php @@ -100,10 +100,12 @@ abstract class Base extends \JKingWeb\Arsse\Test\AbstractTest { } public static function tearDownAfterClass() { - // wipe the database absolutely clean - static::dbRaze(static::$drv); - // clean up - static::$drv = null; + if (static::$drv) { + // wipe the database absolutely clean + static::dbRaze(static::$drv); + // clean up + static::$drv = null; + } static::$failureReason = ""; static::clearData(); } diff --git a/tests/cases/DatabaseDrivers/MySQL.php b/tests/cases/DatabaseDrivers/MySQL.php index 27dcb4af..3d14d2eb 100644 --- a/tests/cases/DatabaseDrivers/MySQL.php +++ b/tests/cases/DatabaseDrivers/MySQL.php @@ -18,7 +18,7 @@ trait MySQL { protected static $stringOutput = true; public static function dbInterface() { - $d = new \mysqli(Arsse::$conf->dbMySQLHost, Arsse::$conf->dbMySQLUser, Arsse::$conf->dbMySQLPass, Arsse::$conf->dbMySQLDb, Arsse::$conf->dbMySQLPort); + $d = @new \mysqli(Arsse::$conf->dbMySQLHost, Arsse::$conf->dbMySQLUser, Arsse::$conf->dbMySQLPass, Arsse::$conf->dbMySQLDb, Arsse::$conf->dbMySQLPort); if ($d->connect_errno) { return; } From d59223bbcb7336ab44b45cbd70b041c7338f97cb Mon Sep 17 00:00:00 2001 From: "J. King" Date: Mon, 18 Mar 2019 22:49:47 -0400 Subject: [PATCH 043/142] First authentication test for Fever --- lib/REST/Fever/API.php | 8 +- tests/cases/REST/Fever/PDO/TestAPI.php | 13 ++++ tests/cases/REST/Fever/TestAPI.php | 100 +++++++++++++++++++++++++ tests/phpunit.xml | 4 + 4 files changed, 121 insertions(+), 4 deletions(-) create mode 100644 tests/cases/REST/Fever/PDO/TestAPI.php create mode 100644 tests/cases/REST/Fever/TestAPI.php diff --git a/lib/REST/Fever/API.php b/lib/REST/Fever/API.php index b9fd5ad9..6effe27a 100644 --- a/lib/REST/Fever/API.php +++ b/lib/REST/Fever/API.php @@ -20,7 +20,7 @@ use JKingWeb\Arsse\REST\Exception404; use JKingWeb\Arsse\REST\Exception405; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\ResponseInterface; -use Zend\Diactoros\Response\JsonResponse as Response; +use Zend\Diactoros\Response\JsonResponse; use Zend\Diactoros\Response\EmptyResponse; class API extends \JKingWeb\Arsse\REST\AbstractHandler { @@ -31,7 +31,8 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { public function dispatch(ServerRequestInterface $req): ResponseInterface { $inR = $req->getQueryParams(); - if (!array_key_exists("api")) { + $inW = $req->getParsedBody(); + if (!array_key_exists("api", $inR)) { // the original would have shown the Fever UI in the absence of the "api" parameter, but we'll return 404 return new EmptyResponse(404); } @@ -44,7 +45,6 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { if (strlen($req->getHeaderLine("Content-Type")) && $req->getHeaderLine("Content-Type") !== "application/x-www-form-urlencoded") { return new EmptyResponse(415, ['Accept' => "application/x-www-form-urlencoded"]); } - $inW = $req->getParsedBody(); $out = [ 'api_version' => self::LEVEL, 'auth' => 0, @@ -80,7 +80,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { } try { // verify the supplied hash is valid - $s = Arsse::$db->TokenLookup($id, "fever.login"); + $s = Arsse::$db->TokenLookup("fever.login", $hash); } catch (\JKingWeb\Arsse\Db\ExceptionInput $e) { return false; } diff --git a/tests/cases/REST/Fever/PDO/TestAPI.php b/tests/cases/REST/Fever/PDO/TestAPI.php new file mode 100644 index 00000000..02caa3d7 --- /dev/null +++ b/tests/cases/REST/Fever/PDO/TestAPI.php @@ -0,0 +1,13 @@ + + * @group optional */ +class TestAPI extends \JKingWeb\Arsse\TestCase\REST\Fever\TestAPI { + use \JKingWeb\Arsse\Test\PDOTest; +} diff --git a/tests/cases/REST/Fever/TestAPI.php b/tests/cases/REST/Fever/TestAPI.php new file mode 100644 index 00000000..54b3e07f --- /dev/null +++ b/tests/cases/REST/Fever/TestAPI.php @@ -0,0 +1,100 @@ + */ +class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest { + + protected function v($value) { + return $value; + } + + protected function req($dataGet, $dataPost, string $method = "POST", string $type = null, string $url = "", string $user = null): ResponseInterface { + $url = "/fever/".$url; + $server = [ + 'REQUEST_METHOD' => $method, + 'REQUEST_URI' => $url, + 'HTTP_CONTENT_TYPE' => $type ?? "application/x-www-form-urlencoded", + ]; + $req = new ServerRequest($server, [], $url, $method, "php://memory"); + if (is_array($dataGet)) { + $req = $req->withRequestTarget($url)->withQueryParams($dataGet); + } else { + $req = $req->withRequestTarget($url."?".http_build_query((string) $dataGet, "", "&", \PHP_QUERY_RFC3986)); + } + if (is_array($dataPost)) { + $req = $req->withParsedBody($dataPost); + } else { + $body = $req->getBody(); + $body->write($strData); + $req = $req->withBody($body); + } + if (isset($user)) { + if (strlen($user)) { + $req = $req->withAttribute("authenticated", true)->withAttribute("authenticatedUser", $user); + } else { + $req = $req->withAttribute("authenticationFailed", true); + } + } + return $this->h->dispatch($req); + } + + public function setUp() { + self::clearData(); + self::setConf(); + // create a mock user manager + Arsse::$user = Phake::mock(User::class); + Phake::when(Arsse::$user)->auth->thenReturn(true); + Arsse::$user->id = "john.doe@example.com"; + // create a mock database interface + Arsse::$db = Phake::mock(Database::class); + Phake::when(Arsse::$db)->begin->thenReturn(Phake::mock(Transaction::class)); + // instantiate the handler + $this->h = new API(); + } + + public function tearDown() { + self::clearData(); + } + + /** @dataProvider provideAuthenticationRequests */ + public function testAuthenticateAUser(bool $httpRequired, bool $tokenEnforced, string $httpUser = null, array $dataPost, array $dataGet, bool $success) { + self::setConf([ + 'userHTTPAuthRequired' => $httpRequired, + 'userSessionEnforced' => $tokenEnforced, + ], true); + \Phake::when(Arsse::$db)->tokenLookup->thenThrow(new ExceptionInput("subjectMissing")); + \Phake::when(Arsse::$db)->tokenLookup("fever.login", "validtoken")->thenReturn(['user' => "jane.doe@example.com"]); + $exp = new JsonResponse($success ? ['api_version' => API::LEVEL, 'auth' => 1] : ['api_version' => API::LEVEL, 'auth' => 0]); + $act = $this->req($dataGet, $dataPost, "POST", null, "", $httpUser); + $this->assertMessage($exp, $act); + } + + public function provideAuthenticationRequests() { + return [ + [false, true, null, ['api_key' => "validToken"], ['api' => null], true], + ]; + } +} diff --git a/tests/phpunit.xml b/tests/phpunit.xml index aac033bd..9200dd8f 100644 --- a/tests/phpunit.xml +++ b/tests/phpunit.xml @@ -104,6 +104,10 @@ cases/REST/TinyTinyRSS/TestIcon.php cases/REST/TinyTinyRSS/PDO/TestAPI.php + + cases/REST/Fever/TestAPI.php + cases/REST/Fever/PDO/TestAPI.php + cases/Service/TestService.php cases/CLI/TestCLI.php From 1e2d595992da5346b496c2c2be234991f0febdbb Mon Sep 17 00:00:00 2001 From: "J. King" Date: Tue, 19 Mar 2019 23:37:08 -0400 Subject: [PATCH 044/142] Full set of authentication tests for Fever --- lib/REST/Fever/API.php | 9 ++++++ tests/cases/REST/Fever/TestAPI.php | 44 ++++++++++++++++++++++++++++-- 2 files changed, 50 insertions(+), 3 deletions(-) diff --git a/lib/REST/Fever/API.php b/lib/REST/Fever/API.php index 6effe27a..30665abd 100644 --- a/lib/REST/Fever/API.php +++ b/lib/REST/Fever/API.php @@ -49,10 +49,19 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { 'api_version' => self::LEVEL, 'auth' => 0, ]; + if ($req->getAttribute("authenticated", false)) { + // if HTTP authentication was successfully used, set the expected user ID + Arsse::$user->id = $req->getAttribute("authenticatedUser"); + $out['auth'] = 1; + } elseif (Arsse::$conf->userHTTPAuthRequired || Arsse::$conf->userPreAuth || $req->getAttribute("authenticationFailed", false)) { + // otherwise if HTTP authentication failed or is required, deny access at the HTTP level + return new EmptyResponse(401); + } // check that the user specified credentials if ($this->logIn(strtolower($inW['api_key'] ?? ""))) { $out['auth'] = 1; } else { + $out['auth'] = 0; return $this->formatResponse($out, $xml); } // handle each possible parameter diff --git a/tests/cases/REST/Fever/TestAPI.php b/tests/cases/REST/Fever/TestAPI.php index 54b3e07f..ccf45641 100644 --- a/tests/cases/REST/Fever/TestAPI.php +++ b/tests/cases/REST/Fever/TestAPI.php @@ -80,21 +80,59 @@ class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest { } /** @dataProvider provideAuthenticationRequests */ - public function testAuthenticateAUser(bool $httpRequired, bool $tokenEnforced, string $httpUser = null, array $dataPost, array $dataGet, bool $success) { + public function testAuthenticateAUser(bool $httpRequired, bool $tokenEnforced, string $httpUser = null, array $dataPost, array $dataGet, ResponseInterface $exp) { self::setConf([ 'userHTTPAuthRequired' => $httpRequired, 'userSessionEnforced' => $tokenEnforced, ], true); + Arsse::$user->id = null; \Phake::when(Arsse::$db)->tokenLookup->thenThrow(new ExceptionInput("subjectMissing")); \Phake::when(Arsse::$db)->tokenLookup("fever.login", "validtoken")->thenReturn(['user' => "jane.doe@example.com"]); - $exp = new JsonResponse($success ? ['api_version' => API::LEVEL, 'auth' => 1] : ['api_version' => API::LEVEL, 'auth' => 0]); $act = $this->req($dataGet, $dataPost, "POST", null, "", $httpUser); $this->assertMessage($exp, $act); } public function provideAuthenticationRequests() { + $success = new JsonResponse(['api_version' => API::LEVEL, 'auth' => 1]); + $failure = new JsonResponse(['api_version' => API::LEVEL, 'auth' => 0]); + $denied = new EmptyResponse(401); return [ - [false, true, null, ['api_key' => "validToken"], ['api' => null], true], + [false, true, null, [], ['api' => null], $failure], + [false, false, null, [], ['api' => null], $failure], + [true, true, null, [], ['api' => null], $denied], + [true, false, null, [], ['api' => null], $denied], + [false, true, "", [], ['api' => null], $denied], + [false, false, "", [], ['api' => null], $denied], + [true, true, "", [], ['api' => null], $denied], + [true, false, "", [], ['api' => null], $denied], + [false, true, null, [], ['api' => null, 'api_key' => "validToken"], $failure], + [false, false, null, [], ['api' => null, 'api_key' => "validToken"], $failure], + [true, true, null, [], ['api' => null, 'api_key' => "validToken"], $denied], + [true, false, null, [], ['api' => null, 'api_key' => "validToken"], $denied], + [false, true, null, ['api_key' => "validToken"], ['api' => null], $success], + [false, false, null, ['api_key' => "validToken"], ['api' => null], $success], + [true, true, null, ['api_key' => "validToken"], ['api' => null], $denied], + [true, false, null, ['api_key' => "validToken"], ['api' => null], $denied], + [false, true, "", ['api_key' => "validToken"], ['api' => null], $denied], + [false, false, "", ['api_key' => "validToken"], ['api' => null], $denied], + [true, true, "", ['api_key' => "validToken"], ['api' => null], $denied], + [true, false, "", ['api_key' => "validToken"], ['api' => null], $denied], + [false, true, "validUser", ['api_key' => "validToken"], ['api' => null], $success], + [false, false, "validUser", ['api_key' => "validToken"], ['api' => null], $success], + [true, true, "validUser", ['api_key' => "validToken"], ['api' => null], $success], + [true, false, "validUser", ['api_key' => "validToken"], ['api' => null], $success], + [false, true, null, ['api_key' => "invalidToken"], ['api' => null], $failure], + [false, false, null, ['api_key' => "invalidToken"], ['api' => null], $failure], + [true, true, null, ['api_key' => "invalidToken"], ['api' => null], $denied], + [true, false, null, ['api_key' => "invalidToken"], ['api' => null], $denied], + [false, true, "", ['api_key' => "invalidToken"], ['api' => null], $denied], + [false, false, "", ['api_key' => "invalidToken"], ['api' => null], $denied], + [true, true, "", ['api_key' => "invalidToken"], ['api' => null], $denied], + [true, false, "", ['api_key' => "invalidToken"], ['api' => null], $denied], + [false, true, "validUser", ['api_key' => "invalidToken"], ['api' => null], $failure], + [false, false, "validUser", ['api_key' => "invalidToken"], ['api' => null], $success], + [true, true, "validUser", ['api_key' => "invalidToken"], ['api' => null], $failure], + [true, false, "validUser", ['api_key' => "invalidToken"], ['api' => null], $success], ]; } } From 91681552442694859b93e65725ff113b5e51640c Mon Sep 17 00:00:00 2001 From: "J. King" Date: Wed, 20 Mar 2019 10:42:04 -0400 Subject: [PATCH 045/142] Add method to unset a Fever password --- lib/REST/Fever/API.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lib/REST/Fever/API.php b/lib/REST/Fever/API.php index 30665abd..65854380 100644 --- a/lib/REST/Fever/API.php +++ b/lib/REST/Fever/API.php @@ -104,4 +104,8 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { Arsse::$db->tokenCreate($user, "fever.login", $hash); return $password; } + + public static function unregisterUser(string $user): bool { + return (bool) Arsse::$db->tokenRevoke($user, "fever.login"); + } } From 9ebaa206337c282f6ea4e768e11a6a17cac36a4a Mon Sep 17 00:00:00 2001 From: "J. King" Date: Wed, 20 Mar 2019 22:24:35 -0400 Subject: [PATCH 046/142] Tests for Fever password creation and removal --- lib/REST/Fever/API.php | 3 +++ tests/cases/REST/Fever/TestAPI.php | 40 +++++++++++++++++++++++++++++- tests/lib/AbstractTest.php | 21 ++++++++++------ 3 files changed, 55 insertions(+), 9 deletions(-) diff --git a/lib/REST/Fever/API.php b/lib/REST/Fever/API.php index 65854380..b5b91ab3 100644 --- a/lib/REST/Fever/API.php +++ b/lib/REST/Fever/API.php @@ -101,7 +101,10 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { public static function registerUser(string $user, string $password = null): string { $password = $password ?? Arsse::$user->generatePassword(); $hash = md5("$user:$password"); + $tr = Arsse::$db->begin(); + Arsse::$db->tokenRevoke($user, "fever.login"); Arsse::$db->tokenCreate($user, "fever.login", $hash); + $tr->commit(); return $password; } diff --git a/tests/cases/REST/Fever/TestAPI.php b/tests/cases/REST/Fever/TestAPI.php index ccf45641..0d974d18 100644 --- a/tests/cases/REST/Fever/TestAPI.php +++ b/tests/cases/REST/Fever/TestAPI.php @@ -16,6 +16,7 @@ use JKingWeb\Arsse\Test\Result; use JKingWeb\Arsse\Misc\Date; use JKingWeb\Arsse\Context\Context; use JKingWeb\Arsse\Db\ExceptionInput; +use JKingWeb\Arsse\User\Exception as UserException; use JKingWeb\Arsse\Db\Transaction; use JKingWeb\Arsse\REST\Fever\API; use Psr\Http\Message\ResponseInterface; @@ -48,7 +49,7 @@ class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest { $req = $req->withParsedBody($dataPost); } else { $body = $req->getBody(); - $body->write($strData); + $body->write($dataPost); $req = $req->withBody($body); } if (isset($user)) { @@ -135,4 +136,41 @@ class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest { [true, false, "validUser", ['api_key' => "invalidToken"], ['api' => null], $success], ]; } + + /** @dataProvider providePasswordCreations */ + public function testRegisterAUserPassword(string $user, string $password = null, $exp) { + \Phake::when(Arsse::$user)->generatePassword->thenReturn("RANDOM_PASSWORD"); + \Phake::when(Arsse::$db)->tokenCreate->thenReturnCallback(function($user, $class, $id = null) { + return $id ?? "RANDOM_TOKEN"; + }); + \Phake::when(Arsse::$db)->tokenCreate("john.doe@example.org", $this->anything(), $this->anything())->thenThrow(new UserException("doesNotExist")); + if ($exp instanceof \JKingWeb\Arsse\AbstractException) { + $this->assertException($exp); + API::registerUser($user, $password); + } else { + $this->assertSame($exp, API::registerUser($user, $password)); + } + \Phake::verify(Arsse::$db)->tokenRevoke($user, "fever.login"); + \Phake::verify(Arsse::$db)->tokenCreate($user, "fever.login", md5($user.":".($password ?? "RANDOM_PASSWORD"))); + } + + public function providePasswordCreations() { + return [ + ["jane.doe@example.com", "secret", "secret"], + ["jane.doe@example.com", "superman", "superman"], + ["jane.doe@example.com", null, "RANDOM_PASSWORD"], + ["john.doe@example.org", null, new UserException("doesNotExist")], + ["john.doe@example.net", null, "RANDOM_PASSWORD"], + ["john.doe@example.net", "secret", "secret"], + ]; + } + + public function testUnregisterAUser() { + \Phake::when(Arsse::$db)->tokenRevoke->thenReturn(3); + $this->assertTrue(API::unregisterUser("jane.doe@example.com")); + \Phake::verify(Arsse::$db)->tokenRevoke("jane.doe@example.com", "fever.login"); + \Phake::when(Arsse::$db)->tokenRevoke->thenReturn(0); + $this->assertFalse(API::unregisterUser("john.doe@example.com")); + \Phake::verify(Arsse::$db)->tokenRevoke("john.doe@example.com", "fever.login"); + } } diff --git a/tests/lib/AbstractTest.php b/tests/lib/AbstractTest.php index 38142210..f55ca9b5 100644 --- a/tests/lib/AbstractTest.php +++ b/tests/lib/AbstractTest.php @@ -55,17 +55,22 @@ abstract class AbstractTest extends \PHPUnit\Framework\TestCase { Arsse::$conf = (($force ? null : Arsse::$conf) ?? (new Conf))->import($defaults)->import($conf); } - public function assertException(string $msg = "", string $prefix = "", string $type = "Exception") { + public function assertException($msg = "", string $prefix = "", string $type = "Exception") { if (func_num_args()) { - $class = \JKingWeb\Arsse\NS_BASE . ($prefix !== "" ? str_replace("/", "\\", $prefix) . "\\" : "") . $type; - $msgID = ($prefix !== "" ? $prefix . "/" : "") . $type. ".$msg"; - if (array_key_exists($msgID, Exception::CODES)) { - $code = Exception::CODES[$msgID]; + if ($msg instanceof \JKingWeb\Arsse\AbstractException) { + $this->expectException(get_class($msg)); + $this->expectExceptionCode($msg->getCode()); } else { - $code = 0; + $class = \JKingWeb\Arsse\NS_BASE . ($prefix !== "" ? str_replace("/", "\\", $prefix) . "\\" : "") . $type; + $msgID = ($prefix !== "" ? $prefix . "/" : "") . $type. ".$msg"; + if (array_key_exists($msgID, Exception::CODES)) { + $code = Exception::CODES[$msgID]; + } else { + $code = 0; + } + $this->expectException($class); + $this->expectExceptionCode($code); } - $this->expectException($class); - $this->expectExceptionCode($code); } else { // expecting a standard PHP exception $this->expectException(\Throwable::class); From f51d20a863078f70ae09b0714f62fa261f0b302e Mon Sep 17 00:00:00 2001 From: "J. King" Date: Wed, 20 Mar 2019 22:25:00 -0400 Subject: [PATCH 047/142] Unix Robo fixes --- RoboFile.php | 2 +- robo | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/RoboFile.php b/RoboFile.php index e73d3e48..8df789ba 100644 --- a/RoboFile.php +++ b/RoboFile.php @@ -85,7 +85,7 @@ class RoboFile extends \Robo\Tasks { $dbg = dirname(\PHP_BINARY)."\\phpdbg.exe"; $dbg = file_exists($dbg) ? $dbg : ""; } else { - $dbg = `which phpdbg`; + $dbg = trim(`which phpdbg`); } if ($dbg) { return escapeshellarg($dbg)." -qrr"; diff --git a/robo b/robo index 7d4d4d76..d6af8dfa 100755 --- a/robo +++ b/robo @@ -3,8 +3,8 @@ base=`dirname "$0"` roboCommand="$1" shift -if [ "$1" == "clean" ]; then +if [ "$1" = "clean" ]; then "$base/vendor/bin/robo" "$roboCommand" $* else "$base/vendor/bin/robo" "$roboCommand" -- $* -fi \ No newline at end of file +fi From 5480b59d934e0cbab48754df98bd71280560ce5c Mon Sep 17 00:00:00 2001 From: "J. King" Date: Wed, 20 Mar 2019 22:25:00 -0400 Subject: [PATCH 048/142] Unix Robo fixes --- RoboFile.php | 2 +- robo | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/RoboFile.php b/RoboFile.php index e73d3e48..8df789ba 100644 --- a/RoboFile.php +++ b/RoboFile.php @@ -85,7 +85,7 @@ class RoboFile extends \Robo\Tasks { $dbg = dirname(\PHP_BINARY)."\\phpdbg.exe"; $dbg = file_exists($dbg) ? $dbg : ""; } else { - $dbg = `which phpdbg`; + $dbg = trim(`which phpdbg`); } if ($dbg) { return escapeshellarg($dbg)." -qrr"; diff --git a/robo b/robo index 7d4d4d76..d6af8dfa 100755 --- a/robo +++ b/robo @@ -3,8 +3,8 @@ base=`dirname "$0"` roboCommand="$1" shift -if [ "$1" == "clean" ]; then +if [ "$1" = "clean" ]; then "$base/vendor/bin/robo" "$roboCommand" $* else "$base/vendor/bin/robo" "$roboCommand" -- $* -fi \ No newline at end of file +fi From 07122b524a3a6bbb5d4dba3e5d89f26177264a9d Mon Sep 17 00:00:00 2001 From: "J. King" Date: Thu, 21 Mar 2019 10:19:30 -0400 Subject: [PATCH 049/142] Rename Fever user functions for consistency --- lib/REST/Fever/API.php | 4 ++-- tests/cases/REST/Fever/TestAPI.php | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/lib/REST/Fever/API.php b/lib/REST/Fever/API.php index b5b91ab3..1c0c6696 100644 --- a/lib/REST/Fever/API.php +++ b/lib/REST/Fever/API.php @@ -98,7 +98,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { return true; } - public static function registerUser(string $user, string $password = null): string { + public static function userRegister(string $user, string $password = null): string { $password = $password ?? Arsse::$user->generatePassword(); $hash = md5("$user:$password"); $tr = Arsse::$db->begin(); @@ -108,7 +108,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { return $password; } - public static function unregisterUser(string $user): bool { + public static function userUnregister(string $user): bool { return (bool) Arsse::$db->tokenRevoke($user, "fever.login"); } } diff --git a/tests/cases/REST/Fever/TestAPI.php b/tests/cases/REST/Fever/TestAPI.php index 0d974d18..8d7867ae 100644 --- a/tests/cases/REST/Fever/TestAPI.php +++ b/tests/cases/REST/Fever/TestAPI.php @@ -146,9 +146,9 @@ class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest { \Phake::when(Arsse::$db)->tokenCreate("john.doe@example.org", $this->anything(), $this->anything())->thenThrow(new UserException("doesNotExist")); if ($exp instanceof \JKingWeb\Arsse\AbstractException) { $this->assertException($exp); - API::registerUser($user, $password); + API::userRegister($user, $password); } else { - $this->assertSame($exp, API::registerUser($user, $password)); + $this->assertSame($exp, API::userRegister($user, $password)); } \Phake::verify(Arsse::$db)->tokenRevoke($user, "fever.login"); \Phake::verify(Arsse::$db)->tokenCreate($user, "fever.login", md5($user.":".($password ?? "RANDOM_PASSWORD"))); @@ -167,10 +167,10 @@ class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest { public function testUnregisterAUser() { \Phake::when(Arsse::$db)->tokenRevoke->thenReturn(3); - $this->assertTrue(API::unregisterUser("jane.doe@example.com")); + $this->assertTrue(API::userUnregister("jane.doe@example.com")); \Phake::verify(Arsse::$db)->tokenRevoke("jane.doe@example.com", "fever.login"); \Phake::when(Arsse::$db)->tokenRevoke->thenReturn(0); - $this->assertFalse(API::unregisterUser("john.doe@example.com")); + $this->assertFalse(API::userUnregister("john.doe@example.com")); \Phake::verify(Arsse::$db)->tokenRevoke("john.doe@example.com", "fever.login"); } } From 3b28634447e4eb959ebef75c7e11351e6f68e7d0 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Thu, 21 Mar 2019 11:00:07 -0400 Subject: [PATCH 050/142] Verify even in exceptional cases --- tests/cases/REST/Fever/TestAPI.php | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/tests/cases/REST/Fever/TestAPI.php b/tests/cases/REST/Fever/TestAPI.php index 8d7867ae..89254bfd 100644 --- a/tests/cases/REST/Fever/TestAPI.php +++ b/tests/cases/REST/Fever/TestAPI.php @@ -81,7 +81,7 @@ class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest { } /** @dataProvider provideAuthenticationRequests */ - public function testAuthenticateAUser(bool $httpRequired, bool $tokenEnforced, string $httpUser = null, array $dataPost, array $dataGet, ResponseInterface $exp) { + public function testAuthenticateAUserToken(bool $httpRequired, bool $tokenEnforced, string $httpUser = null, array $dataPost, array $dataGet, ResponseInterface $exp) { self::setConf([ 'userHTTPAuthRequired' => $httpRequired, 'userSessionEnforced' => $tokenEnforced, @@ -144,14 +144,17 @@ class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest { return $id ?? "RANDOM_TOKEN"; }); \Phake::when(Arsse::$db)->tokenCreate("john.doe@example.org", $this->anything(), $this->anything())->thenThrow(new UserException("doesNotExist")); - if ($exp instanceof \JKingWeb\Arsse\AbstractException) { - $this->assertException($exp); - API::userRegister($user, $password); - } else { - $this->assertSame($exp, API::userRegister($user, $password)); + try { + if ($exp instanceof \JKingWeb\Arsse\AbstractException) { + $this->assertException($exp); + API::userRegister($user, $password); + } else { + $this->assertSame($exp, API::userRegister($user, $password)); + } + } finally { + \Phake::verify(Arsse::$db)->tokenRevoke($user, "fever.login"); + \Phake::verify(Arsse::$db)->tokenCreate($user, "fever.login", md5($user.":".($password ?? "RANDOM_PASSWORD"))); } - \Phake::verify(Arsse::$db)->tokenRevoke($user, "fever.login"); - \Phake::verify(Arsse::$db)->tokenCreate($user, "fever.login", md5($user.":".($password ?? "RANDOM_PASSWORD"))); } public function providePasswordCreations() { From fe008d4343bcafd7dde410c3c6bc452377afe89d Mon Sep 17 00:00:00 2001 From: "J. King" Date: Thu, 21 Mar 2019 13:49:55 -0400 Subject: [PATCH 051/142] A few more Fever authentication tests --- tests/cases/REST/Fever/TestAPI.php | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/cases/REST/Fever/TestAPI.php b/tests/cases/REST/Fever/TestAPI.php index 89254bfd..08d2f89e 100644 --- a/tests/cases/REST/Fever/TestAPI.php +++ b/tests/cases/REST/Fever/TestAPI.php @@ -110,6 +110,14 @@ class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest { [false, false, null, [], ['api' => null, 'api_key' => "validToken"], $failure], [true, true, null, [], ['api' => null, 'api_key' => "validToken"], $denied], [true, false, null, [], ['api' => null, 'api_key' => "validToken"], $denied], + [false, true, "", [], ['api' => null, 'api_key' => "validToken"], $denied], + [false, false, "", [], ['api' => null, 'api_key' => "validToken"], $denied], + [true, true, "", [], ['api' => null, 'api_key' => "validToken"], $denied], + [true, false, "", [], ['api' => null, 'api_key' => "validToken"], $denied], + [false, true, "validUser", [], ['api' => null, 'api_key' => "validToken"], $failure], + [false, false, "validUser", [], ['api' => null, 'api_key' => "validToken"], $success], + [true, true, "validUser", [], ['api' => null, 'api_key' => "validToken"], $failure], + [true, false, "validUser", [], ['api' => null, 'api_key' => "validToken"], $success], [false, true, null, ['api_key' => "validToken"], ['api' => null], $success], [false, false, null, ['api_key' => "validToken"], ['api' => null], $success], [true, true, null, ['api_key' => "validToken"], ['api' => null], $denied], From 94314f3e6d6cccdc7c1fc4d426a34ebcd37196d2 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Sat, 16 Mar 2019 17:48:48 -0400 Subject: [PATCH 052/142] Fix test errors when PostgreSQL or MySQL are not available --- tests/cases/Database/Base.php | 10 ++++++---- tests/cases/DatabaseDrivers/MySQL.php | 2 +- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/tests/cases/Database/Base.php b/tests/cases/Database/Base.php index 47803ffd..de6d39e2 100644 --- a/tests/cases/Database/Base.php +++ b/tests/cases/Database/Base.php @@ -102,10 +102,12 @@ abstract class Base extends \JKingWeb\Arsse\Test\AbstractTest { } public static function tearDownAfterClass() { - // wipe the database absolutely clean - static::dbRaze(static::$drv); - // clean up - static::$drv = null; + if (static::$drv) { + // wipe the database absolutely clean + static::dbRaze(static::$drv); + // clean up + static::$drv = null; + } static::$failureReason = ""; static::clearData(); } diff --git a/tests/cases/DatabaseDrivers/MySQL.php b/tests/cases/DatabaseDrivers/MySQL.php index 27dcb4af..3d14d2eb 100644 --- a/tests/cases/DatabaseDrivers/MySQL.php +++ b/tests/cases/DatabaseDrivers/MySQL.php @@ -18,7 +18,7 @@ trait MySQL { protected static $stringOutput = true; public static function dbInterface() { - $d = new \mysqli(Arsse::$conf->dbMySQLHost, Arsse::$conf->dbMySQLUser, Arsse::$conf->dbMySQLPass, Arsse::$conf->dbMySQLDb, Arsse::$conf->dbMySQLPort); + $d = @new \mysqli(Arsse::$conf->dbMySQLHost, Arsse::$conf->dbMySQLUser, Arsse::$conf->dbMySQLPass, Arsse::$conf->dbMySQLDb, Arsse::$conf->dbMySQLPort); if ($d->connect_errno) { return; } From 5bf0b67ec3e5d77c9a7946799c176c8070c3f0ad Mon Sep 17 00:00:00 2001 From: "J. King" Date: Sun, 24 Mar 2019 14:41:17 -0400 Subject: [PATCH 053/142] Increase file descriptor limit for Robo on Linux --- robo | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/robo b/robo index d6af8dfa..0b3be08f 100755 --- a/robo +++ b/robo @@ -1,8 +1,9 @@ #! /bin/sh base=`dirname "$0"` roboCommand="$1" - shift + +ulimit -n 2048 if [ "$1" = "clean" ]; then "$base/vendor/bin/robo" "$roboCommand" $* else From e45ba3f0eaa398a159eeac278d9491c6ad11f08a Mon Sep 17 00:00:00 2001 From: "J. King" Date: Sun, 24 Mar 2019 14:42:23 -0400 Subject: [PATCH 054/142] Add means of unsetting a password in the backend --- lib/Database.php | 10 +++--- lib/User.php | 13 ++++++++ lib/User/Driver.php | 2 ++ lib/User/Internal/Driver.php | 15 ++++++++- tests/cases/Database/SeriesUser.php | 7 ++++ tests/cases/User/TestInternal.php | 52 ++++++++++++++++++++--------- tests/cases/User/TestUser.php | 38 +++++++++++++++++++++ 7 files changed, 115 insertions(+), 22 deletions(-) diff --git a/lib/Database.php b/lib/Database.php index df614b5a..bfbcfee1 100644 --- a/lib/Database.php +++ b/lib/Database.php @@ -289,27 +289,27 @@ class Database { } /** Retrieves the hashed password of a user */ - public function userPasswordGet(string $user): string { + public function userPasswordGet(string $user) { 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]); } - return (string) $this->db->prepare("SELECT password from arsse_users where id = ?", "str")->run($user)->getValue(); + return $this->db->prepare("SELECT password from arsse_users where id = ?", "str")->run($user)->getValue(); } /** 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 + * @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): bool { + 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)) { throw new User\Exception("doesNotExist", ["action" => __FUNCTION__, "user" => $user]); } - $hash = (strlen($password) > 0) ? password_hash($password, \PASSWORD_DEFAULT) : ""; + $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); return true; } diff --git a/lib/User.php b/lib/User.php index 82e8d3dd..4f529803 100644 --- a/lib/User.php +++ b/lib/User.php @@ -114,6 +114,19 @@ class User { return $out; } + 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 + Arsse::$db->userPasswordSet($user, null); + } + return $out; + } + public function generatePassword(): string { return (new PassGen)->length(Arsse::$conf->userTempPasswordLength)->get(); } diff --git a/lib/User/Driver.php b/lib/User/Driver.php index 50ef8f3b..b5657ac9 100644 --- a/lib/User/Driver.php +++ b/lib/User/Driver.php @@ -29,4 +29,6 @@ interface Driver { 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 + public function userPasswordUnset(string $user, string $oldPassword = null): bool; } diff --git a/lib/User/Internal/Driver.php b/lib/User/Internal/Driver.php index 4c730257..d50777a1 100644 --- a/lib/User/Internal/Driver.php +++ b/lib/User/Internal/Driver.php @@ -20,6 +20,9 @@ class Driver implements \JKingWeb\Arsse\User\Driver { public function auth(string $user, string $password): bool { try { $hash = $this->userPasswordGet($user); + if (is_null($hash)) { + return false; + } } catch (Exception $e) { return false; } @@ -58,7 +61,17 @@ class Driver implements \JKingWeb\Arsse\User\Driver { return $newPassword; } - protected function userPasswordGet(string $user): string { + 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 Exception("doesNotExist", ['action' => "userPasswordUnset", 'user' => $user]); + } else { + return true; + } + } + + protected function userPasswordGet(string $user) { return Arsse::$db->userPasswordGet($user); } } diff --git a/tests/cases/Database/SeriesUser.php b/tests/cases/Database/SeriesUser.php index 991577a0..8036beee 100644 --- a/tests/cases/Database/SeriesUser.php +++ b/tests/cases/Database/SeriesUser.php @@ -127,6 +127,13 @@ trait SeriesUser { $this->assertTrue(password_verify($pass, $hash), "Failed verifying password of $user '$pass' against hash '$hash'."); } + public function testUnsetAPassword() { + $user = "john.doe@example.com"; + $this->assertEquals("", Arsse::$db->userPasswordGet($user)); + $this->assertTrue(Arsse::$db->userPasswordSet($user, null)); + $this->assertNull(Arsse::$db->userPasswordGet($user)); + } + public function testSetThePasswordOfAMissingUser() { $this->assertException("doesNotExist", "User"); Arsse::$db->userPasswordSet("john.doe@example.org", "secret"); diff --git a/tests/cases/User/TestInternal.php b/tests/cases/User/TestInternal.php index f7f042dd..29d99233 100644 --- a/tests/cases/User/TestInternal.php +++ b/tests/cases/User/TestInternal.php @@ -37,12 +37,13 @@ class TestInternal extends \JKingWeb\Arsse\Test\AbstractTest { * @dataProvider provideAuthentication * @group slow */ - public function testAuthenticateAUser(bool $authorized, string $user, string $password, bool $exp) { + public function testAuthenticateAUser(bool $authorized, string $user, $password, bool $exp) { 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")); } @@ -54,22 +55,26 @@ class TestInternal extends \JKingWeb\Arsse\Test\AbstractTest { $jane = "jane.doe@example.com"; $owen = "owen.hardy@example.com"; $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], - [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], + [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], ]; } @@ -133,4 +138,19 @@ class TestInternal extends \JKingWeb\Arsse\Test\AbstractTest { $this->assertSame("superman", (new Driver)->userPasswordSet($john, "superman")); $this->assertSame(null, (new Driver)->userPasswordSet($john, null)); } + + public function testUnsetAPassword() { + $drv = \Phake::partialMock(Driver::class); + \Phake::when($drv)->userExists->thenReturn(true); + Phake::verifyNoFurtherInteraction(Arsse::$db); + $this->assertTrue($drv->userPasswordUnset("john.doe@example.com")); + } + + public function testUnsetAPasswordForAMssingUser() { + $drv = \Phake::partialMock(Driver::class); + \Phake::when($drv)->userExists->thenReturn(false); + Phake::verifyNoFurtherInteraction(Arsse::$db); + $this->assertException("doesNotExist", "User"); + $drv->userPasswordUnset("john.doe@example.com"); + } } diff --git a/tests/cases/User/TestUser.php b/tests/cases/User/TestUser.php index 9496c412..3584f1e3 100644 --- a/tests/cases/User/TestUser.php +++ b/tests/cases/User/TestUser.php @@ -297,4 +297,42 @@ class TestUser extends \JKingWeb\Arsse\Test\AbstractTest { [true, $jane, "secret", true, new \JKingWeb\Arsse\User\Exception("doesNotExist")], ]; } + + /** @dataProvider providePasswordClearings */ + public function testClearAPassword(bool $authorized, bool $exists, string $user, $exp) { + Phake::when($this->drv)->authorize->thenReturn($authorized); + 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); + $u = new User($this->drv); + try { + if ($exp instanceof \JKingWeb\Arsse\AbstractException) { + $this->assertException($exp); + $u->passwordUnset($user); + } else { + $this->assertSame($exp, $u->passwordUnset($user)); + } + } finally { + Phake::verify(Arsse::$db, Phake::times((int) ($authorized && $exists && is_bool($exp))))->userPasswordSet($user, null); + } + } + + public function providePasswordClearings() { + $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], + ]; + } } From 1ce95ef4d9324e8503fc7c55e48186a46ccbffa1 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Sun, 24 Mar 2019 15:05:21 -0400 Subject: [PATCH 055/142] Add means of testing Fever authentication --- lib/REST/Fever/API.php | 8 ++++++++ tests/cases/REST/Fever/TestAPI.php | 21 +++++++++++++++++++-- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/lib/REST/Fever/API.php b/lib/REST/Fever/API.php index 1c0c6696..b80fe8d4 100644 --- a/lib/REST/Fever/API.php +++ b/lib/REST/Fever/API.php @@ -111,4 +111,12 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { public static function userUnregister(string $user): bool { return (bool) Arsse::$db->tokenRevoke($user, "fever.login"); } + + public static function userAuthenticate(string $user, string $password): bool { + try { + return (bool) Arsse::$db->tokenLookup("fever.login", md5("$user:$password")); + } catch (ExceptionInput $e) { + return false; + } + } } diff --git a/tests/cases/REST/Fever/TestAPI.php b/tests/cases/REST/Fever/TestAPI.php index 08d2f89e..b1c6901b 100644 --- a/tests/cases/REST/Fever/TestAPI.php +++ b/tests/cases/REST/Fever/TestAPI.php @@ -80,7 +80,7 @@ class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest { self::clearData(); } - /** @dataProvider provideAuthenticationRequests */ + /** @dataProvider provideTokenAuthenticationRequests */ public function testAuthenticateAUserToken(bool $httpRequired, bool $tokenEnforced, string $httpUser = null, array $dataPost, array $dataGet, ResponseInterface $exp) { self::setConf([ 'userHTTPAuthRequired' => $httpRequired, @@ -93,7 +93,7 @@ class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest { $this->assertMessage($exp, $act); } - public function provideAuthenticationRequests() { + public function provideTokenAuthenticationRequests() { $success = new JsonResponse(['api_version' => API::LEVEL, 'auth' => 1]); $failure = new JsonResponse(['api_version' => API::LEVEL, 'auth' => 0]); $denied = new EmptyResponse(401); @@ -184,4 +184,21 @@ class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest { $this->assertFalse(API::userUnregister("john.doe@example.com")); \Phake::verify(Arsse::$db)->tokenRevoke("john.doe@example.com", "fever.login"); } + + /** @dataProvider provideUserAuthenticationRequests */ + public function testAuthenticateAUserName(string $user, string $password, bool $exp) { + \Phake::when(Arsse::$db)->tokenLookup->thenThrow(new ExceptionInput("constraintViolation")); + \Phake::when(Arsse::$db)->tokenLookup("fever.login", md5("jane.doe@example.com:secret"))->thenReturn(['user' => "jane.doe@example.com"]); + \Phake::when(Arsse::$db)->tokenLookup("fever.login", md5("john.doe@example.com:superman"))->thenReturn(['user' => "john.doe@example.com"]); + $this->assertSame($exp, API::userAuthenticate($user, $password)); + } + + public function provideUserAuthenticationRequests() { + return [ + ["jane.doe@example.com", "secret", true], + ["jane.doe@example.com", "superman", false], + ["john.doe@example.com", "secret", false], + ["john.doe@example.com", "superman", true], + ]; + } } From 7d95e8fc0956a27993f801970d082fa026c0c334 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Mon, 25 Mar 2019 08:31:49 -0400 Subject: [PATCH 056/142] Split Fever user management from protocol handler --- lib/REST/Fever/API.php | 22 ------- lib/REST/Fever/User.php | 34 +++++++++++ tests/cases/REST/Fever/TestAPI.php | 57 ----------------- tests/cases/REST/Fever/TestUser.php | 94 +++++++++++++++++++++++++++++ tests/phpunit.xml | 1 + 5 files changed, 129 insertions(+), 79 deletions(-) create mode 100644 lib/REST/Fever/User.php create mode 100644 tests/cases/REST/Fever/TestUser.php diff --git a/lib/REST/Fever/API.php b/lib/REST/Fever/API.php index b80fe8d4..c4ad36b4 100644 --- a/lib/REST/Fever/API.php +++ b/lib/REST/Fever/API.php @@ -97,26 +97,4 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { Arsse::$user->id = $s['user']; return true; } - - public static function userRegister(string $user, string $password = null): string { - $password = $password ?? Arsse::$user->generatePassword(); - $hash = md5("$user:$password"); - $tr = Arsse::$db->begin(); - Arsse::$db->tokenRevoke($user, "fever.login"); - Arsse::$db->tokenCreate($user, "fever.login", $hash); - $tr->commit(); - return $password; - } - - public static function userUnregister(string $user): bool { - return (bool) Arsse::$db->tokenRevoke($user, "fever.login"); - } - - public static function userAuthenticate(string $user, string $password): bool { - try { - return (bool) Arsse::$db->tokenLookup("fever.login", md5("$user:$password")); - } catch (ExceptionInput $e) { - return false; - } - } } diff --git a/lib/REST/Fever/User.php b/lib/REST/Fever/User.php new file mode 100644 index 00000000..ac3cf696 --- /dev/null +++ b/lib/REST/Fever/User.php @@ -0,0 +1,34 @@ +generatePassword(); + $hash = md5("$user:$password"); + $tr = Arsse::$db->begin(); + Arsse::$db->tokenRevoke($user, "fever.login"); + Arsse::$db->tokenCreate($user, "fever.login", $hash); + $tr->commit(); + return $password; + } + + public static function unregister(string $user): bool { + return (bool) Arsse::$db->tokenRevoke($user, "fever.login"); + } + + public static function authenticate(string $user, string $password): bool { + try { + return (bool) Arsse::$db->tokenLookup("fever.login", md5("$user:$password")); + } catch (ExceptionInput $e) { + return false; + } + } +} diff --git a/tests/cases/REST/Fever/TestAPI.php b/tests/cases/REST/Fever/TestAPI.php index b1c6901b..c76d567f 100644 --- a/tests/cases/REST/Fever/TestAPI.php +++ b/tests/cases/REST/Fever/TestAPI.php @@ -144,61 +144,4 @@ class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest { [true, false, "validUser", ['api_key' => "invalidToken"], ['api' => null], $success], ]; } - - /** @dataProvider providePasswordCreations */ - public function testRegisterAUserPassword(string $user, string $password = null, $exp) { - \Phake::when(Arsse::$user)->generatePassword->thenReturn("RANDOM_PASSWORD"); - \Phake::when(Arsse::$db)->tokenCreate->thenReturnCallback(function($user, $class, $id = null) { - return $id ?? "RANDOM_TOKEN"; - }); - \Phake::when(Arsse::$db)->tokenCreate("john.doe@example.org", $this->anything(), $this->anything())->thenThrow(new UserException("doesNotExist")); - try { - if ($exp instanceof \JKingWeb\Arsse\AbstractException) { - $this->assertException($exp); - API::userRegister($user, $password); - } else { - $this->assertSame($exp, API::userRegister($user, $password)); - } - } finally { - \Phake::verify(Arsse::$db)->tokenRevoke($user, "fever.login"); - \Phake::verify(Arsse::$db)->tokenCreate($user, "fever.login", md5($user.":".($password ?? "RANDOM_PASSWORD"))); - } - } - - public function providePasswordCreations() { - return [ - ["jane.doe@example.com", "secret", "secret"], - ["jane.doe@example.com", "superman", "superman"], - ["jane.doe@example.com", null, "RANDOM_PASSWORD"], - ["john.doe@example.org", null, new UserException("doesNotExist")], - ["john.doe@example.net", null, "RANDOM_PASSWORD"], - ["john.doe@example.net", "secret", "secret"], - ]; - } - - public function testUnregisterAUser() { - \Phake::when(Arsse::$db)->tokenRevoke->thenReturn(3); - $this->assertTrue(API::userUnregister("jane.doe@example.com")); - \Phake::verify(Arsse::$db)->tokenRevoke("jane.doe@example.com", "fever.login"); - \Phake::when(Arsse::$db)->tokenRevoke->thenReturn(0); - $this->assertFalse(API::userUnregister("john.doe@example.com")); - \Phake::verify(Arsse::$db)->tokenRevoke("john.doe@example.com", "fever.login"); - } - - /** @dataProvider provideUserAuthenticationRequests */ - public function testAuthenticateAUserName(string $user, string $password, bool $exp) { - \Phake::when(Arsse::$db)->tokenLookup->thenThrow(new ExceptionInput("constraintViolation")); - \Phake::when(Arsse::$db)->tokenLookup("fever.login", md5("jane.doe@example.com:secret"))->thenReturn(['user' => "jane.doe@example.com"]); - \Phake::when(Arsse::$db)->tokenLookup("fever.login", md5("john.doe@example.com:superman"))->thenReturn(['user' => "john.doe@example.com"]); - $this->assertSame($exp, API::userAuthenticate($user, $password)); - } - - public function provideUserAuthenticationRequests() { - return [ - ["jane.doe@example.com", "secret", true], - ["jane.doe@example.com", "superman", false], - ["john.doe@example.com", "secret", false], - ["john.doe@example.com", "superman", true], - ]; - } } diff --git a/tests/cases/REST/Fever/TestUser.php b/tests/cases/REST/Fever/TestUser.php new file mode 100644 index 00000000..c1856472 --- /dev/null +++ b/tests/cases/REST/Fever/TestUser.php @@ -0,0 +1,94 @@ + */ +class TestUser extends \JKingWeb\Arsse\Test\AbstractTest { + protected $u; + + public function setUp() { + self::clearData(); + self::setConf(); + // create a mock user manager + Arsse::$user = \Phake::mock(User::class); + \Phake::when(Arsse::$user)->auth->thenReturn(true); + // create a mock database interface + Arsse::$db = \Phake::mock(Database::class); + \Phake::when(Arsse::$db)->begin->thenReturn(\Phake::mock(Transaction::class)); + // instantiate the handler + $this->u = new FeverUser(); + } + + public function tearDown() { + self::clearData(); + } + + /** @dataProvider providePasswordCreations */ + public function testRegisterAUserPassword(string $user, string $password = null, $exp) { + \Phake::when(Arsse::$user)->generatePassword->thenReturn("RANDOM_PASSWORD"); + \Phake::when(Arsse::$db)->tokenCreate->thenReturnCallback(function($user, $class, $id = null) { + return $id ?? "RANDOM_TOKEN"; + }); + \Phake::when(Arsse::$db)->tokenCreate("john.doe@example.org", $this->anything(), $this->anything())->thenThrow(new UserException("doesNotExist")); + try { + if ($exp instanceof \JKingWeb\Arsse\AbstractException) { + $this->assertException($exp); + $this->u->register($user, $password); + } else { + $this->assertSame($exp, $this->u->register($user, $password)); + } + } finally { + \Phake::verify(Arsse::$db)->tokenRevoke($user, "fever.login"); + \Phake::verify(Arsse::$db)->tokenCreate($user, "fever.login", md5($user.":".($password ?? "RANDOM_PASSWORD"))); + } + } + + public function providePasswordCreations() { + return [ + ["jane.doe@example.com", "secret", "secret"], + ["jane.doe@example.com", "superman", "superman"], + ["jane.doe@example.com", null, "RANDOM_PASSWORD"], + ["john.doe@example.org", null, new UserException("doesNotExist")], + ["john.doe@example.net", null, "RANDOM_PASSWORD"], + ["john.doe@example.net", "secret", "secret"], + ]; + } + + public function testUnregisterAUser() { + \Phake::when(Arsse::$db)->tokenRevoke->thenReturn(3); + $this->assertTrue($this->u->unregister("jane.doe@example.com")); + \Phake::verify(Arsse::$db)->tokenRevoke("jane.doe@example.com", "fever.login"); + \Phake::when(Arsse::$db)->tokenRevoke->thenReturn(0); + $this->assertFalse($this->u->unregister("john.doe@example.com")); + \Phake::verify(Arsse::$db)->tokenRevoke("john.doe@example.com", "fever.login"); + } + + /** @dataProvider provideUserAuthenticationRequests */ + public function testAuthenticateAUserName(string $user, string $password, bool $exp) { + \Phake::when(Arsse::$db)->tokenLookup->thenThrow(new ExceptionInput("constraintViolation")); + \Phake::when(Arsse::$db)->tokenLookup("fever.login", md5("jane.doe@example.com:secret"))->thenReturn(['user' => "jane.doe@example.com"]); + \Phake::when(Arsse::$db)->tokenLookup("fever.login", md5("john.doe@example.com:superman"))->thenReturn(['user' => "john.doe@example.com"]); + $this->assertSame($exp, $this->u->authenticate($user, $password)); + } + + public function provideUserAuthenticationRequests() { + return [ + ["jane.doe@example.com", "secret", true], + ["jane.doe@example.com", "superman", false], + ["john.doe@example.com", "secret", false], + ["john.doe@example.com", "superman", true], + ]; + } +} diff --git a/tests/phpunit.xml b/tests/phpunit.xml index 9200dd8f..7c698ab7 100644 --- a/tests/phpunit.xml +++ b/tests/phpunit.xml @@ -105,6 +105,7 @@ cases/REST/TinyTinyRSS/PDO/TestAPI.php + cases/REST/Fever/TestUser.php cases/REST/Fever/TestAPI.php cases/REST/Fever/PDO/TestAPI.php From f4d4feb69c9d1ffdf51cba9249874c77b49367d4 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Mon, 25 Mar 2019 09:53:06 -0400 Subject: [PATCH 057/142] Suppress TLS error from mock server --- RoboFile.php | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/RoboFile.php b/RoboFile.php index 8df789ba..a938b164 100644 --- a/RoboFile.php +++ b/RoboFile.php @@ -98,6 +98,11 @@ class RoboFile extends \Robo\Tasks { return defined("PHP_WINDOWS_VERSION_MAJOR"); } + protected function blackhole(bool $all = false): string { + $hole = $this->isWindows() ? "nul" : "/dev/null"; + return $all ? ">$hole 2>&1" : "2>$hole"; + } + protected function runTests(string $executor, string $set, array $args) : Result { switch ($set) { case "typical": @@ -117,7 +122,7 @@ class RoboFile extends \Robo\Tasks { } $execpath = realpath(self::BASE."vendor-bin/phpunit/vendor/phpunit/phpunit/phpunit"); $confpath = realpath(self::BASE_TEST."phpunit.xml"); - $this->taskServer(8000)->host("localhost")->dir(self::BASE_TEST."docroot")->rawArg("-n")->arg(self::BASE_TEST."server.php")->background()->run(); + $this->taskServer(8000)->host("localhost")->dir(self::BASE_TEST."docroot")->rawArg("-n")->arg(self::BASE_TEST."server.php")->rawArg($this->blackhole())->background()->run(); return $this->taskExec($executor)->arg($execpath)->option("-c", $confpath)->args(array_merge($set, $args))->run(); } From 22c2629078fc712f72a0b4cac6bde75144a2cbe2 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Mon, 25 Mar 2019 10:45:05 -0400 Subject: [PATCH 058/142] Partial tests for new CLI features --- lib/CLI.php | 38 ++++++++++++++++++++++++++++++------- lib/REST/Fever/User.php | 6 +++--- tests/cases/CLI/TestCLI.php | 24 +++++++++++++++++++---- 3 files changed, 54 insertions(+), 14 deletions(-) diff --git a/lib/CLI.php b/lib/CLI.php index efb1f997..85ee8044 100644 --- a/lib/CLI.php +++ b/lib/CLI.php @@ -6,7 +6,7 @@ declare(strict_types=1); namespace JKingWeb\Arsse; -use Docopt\Response as Opts; +use JKingWeb\Arsse\REST\Fever\User as Fever; class CLI { const USAGE = << [] arsse.php user remove - arsse.php user set-pass [--oldpass=] [] - arsse.php user auth + arsse.php user set-pass [] + [--oldpass=] [--fever] + arsse.php user unset-pass + [--oldpass=] [--fever] + arsse.php user auth [--fever] arsse.php --version arsse.php --help | -h @@ -106,16 +109,36 @@ USAGE_TEXT; return new Conf; } + /** @codeCoverageIgnore */ + protected function getFever(): Fever { + return new Fever; + } + protected function userManage($args): int { switch ($this->command(["add", "remove", "set-pass", "list", "auth"], $args)) { case "add": return $this->userAddOrSetPassword("add", $args[""], $args[""]); case "set-pass": - return $this->userAddOrSetPassword("passwordSet", $args[""], $args[""], $args["--oldpass"]); + if ($args['--fever']) { + $passwd = $this->getFever()->register($args[""], $args[""]); + if (is_null($args[""])) { + echo $passwd.\PHP_EOL; + } + return 0; + } else { + return $this->userAddOrSetPassword("passwordSet", $args[""], $args[""], $args["--oldpass"]); + } + case "unset-pass": + if ($args['--fever']) { + $this->getFever()->unegister($args[""]); + } else { + Arsse::$user->passwordUnset($args[""], $args["--oldpass"]); + } + return 0; case "remove": return (int) !Arsse::$user->remove($args[""]); case "auth": - return $this->userAuthenticate($args[""], $args[""]); + return $this->userAuthenticate($args[""], $args[""], $args["--fever"]); case "list": case "": return $this->userList(); @@ -138,8 +161,9 @@ USAGE_TEXT; return 0; } - protected function userAuthenticate(string $user, string $password): int { - if (Arsse::$user->auth($user, $password)) { + protected function userAuthenticate(string $user, string $password, bool $fever = false): int { + $result = $fever ? $this->getFever()->authenticate($user, $password) : Arsse::$user->auth($user, $password); + if ($result) { echo Arsse::$lang->msg("CLI.Auth.Success").\PHP_EOL; return 0; } else { diff --git a/lib/REST/Fever/User.php b/lib/REST/Fever/User.php index ac3cf696..b702ae40 100644 --- a/lib/REST/Fever/User.php +++ b/lib/REST/Fever/User.php @@ -10,7 +10,7 @@ use JKingWeb\Arsse\Arsse; use JKingWeb\Arsse\Db\ExceptionInput; class User { - public static function register(string $user, string $password = null): string { + public function register(string $user, string $password = null): string { $password = $password ?? Arsse::$user->generatePassword(); $hash = md5("$user:$password"); $tr = Arsse::$db->begin(); @@ -20,11 +20,11 @@ class User { return $password; } - public static function unregister(string $user): bool { + public function unregister(string $user): bool { return (bool) Arsse::$db->tokenRevoke($user, "fever.login"); } - public static function authenticate(string $user, string $password): bool { + public function authenticate(string $user, string $password): bool { try { return (bool) Arsse::$db->tokenLookup("fever.login", md5("$user:$password")); } catch (ExceptionInput $e) { diff --git a/tests/cases/CLI/TestCLI.php b/tests/cases/CLI/TestCLI.php index 608eebce..2ca1ebe1 100644 --- a/tests/cases/CLI/TestCLI.php +++ b/tests/cases/CLI/TestCLI.php @@ -12,6 +12,7 @@ use JKingWeb\Arsse\User; use JKingWeb\Arsse\Database; use JKingWeb\Arsse\Service; use JKingWeb\Arsse\CLI; +use JKingWeb\Arsse\REST\Fever\User as FeverUser; use Phake; /** @covers \JKingWeb\Arsse\CLI */ @@ -174,16 +175,27 @@ class TestCLI extends \JKingWeb\Arsse\Test\AbstractTest { ($user === "jane.doe@example.com" && $pass === "superman") ); })); + $fever = \Phake::mock(FeverUser::class); + \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", "thx1388")->thenReturn(true); + \Phake::when($this->cli)->getFever->thenReturn($fever); $this->assertConsole($this->cli, $cmd, $exitStatus, $output); } public function provideUserAuthentication() { $l = new \JKingWeb\Arsse\Lang; + $success = $l("CLI.Auth.Success"); + $failure = $l("CLI.Auth.Failure"); return [ - ["arsse.php user auth john.doe@example.com secret", 0, $l("CLI.Auth.Success")], - ["arsse.php user auth john.doe@example.com superman", 1, $l("CLI.Auth.Failure")], - ["arsse.php user auth jane.doe@example.com secret", 1, $l("CLI.Auth.Failure")], - ["arsse.php user auth jane.doe@example.com superman", 0, $l("CLI.Auth.Success")], + ["arsse.php user auth john.doe@example.com secret", 0, $success], + ["arsse.php user auth john.doe@example.com superman", 1, $failure], + ["arsse.php user auth jane.doe@example.com secret", 1, $failure], + ["arsse.php user auth jane.doe@example.com superman", 0, $success], + ["arsse.php user auth john.doe@example.com ashalla --fever", 0, $success], + ["arsse.php user auth john.doe@example.com thx1138 --fever", 1, $failure], + ["arsse.php user auth --fever jane.doe@example.com ashalla", 1, $failure], + ["arsse.php user auth --fever jane.doe@example.com thx1138", 0, $success], ]; } @@ -229,4 +241,8 @@ class TestCLI extends \JKingWeb\Arsse\Test\AbstractTest { ["arsse.php user set-pass jane.doe@example.com", 10402, ""], ]; } + + public function testChangeAFeverPassword() { + $this->markTestIncomplete(); + } } From b8640d73f94cce3b4a6c76cc322f2ac54dff66fc Mon Sep 17 00:00:00 2001 From: "J. King" Date: Mon, 25 Mar 2019 10:47:06 -0400 Subject: [PATCH 059/142] Update PHPUnit --- vendor-bin/phpunit/composer.json | 2 +- vendor-bin/phpunit/composer.lock | 89 +++++++++++++++++--------------- 2 files changed, 48 insertions(+), 43 deletions(-) diff --git a/vendor-bin/phpunit/composer.json b/vendor-bin/phpunit/composer.json index e0854a62..d6c1f867 100644 --- a/vendor-bin/phpunit/composer.json +++ b/vendor-bin/phpunit/composer.json @@ -1,6 +1,6 @@ { "require": { - "phpunit/phpunit": "*", + "phpunit/phpunit": "7.*", "phake/phake": "^3.0", "clue/arguments": "^2.0", "mikey179/vfsStream": "^1.6", diff --git a/vendor-bin/phpunit/composer.lock b/vendor-bin/phpunit/composer.lock index 88d83a55..6e00b3cd 100644 --- a/vendor-bin/phpunit/composer.lock +++ b/vendor-bin/phpunit/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "5c03bb6fb595eebc1bb3e5fe9ea7c4a0", + "content-hash": "e69de7425d904e9dadfed81536ecd712", "packages": [ { "name": "clue/arguments", @@ -58,27 +58,29 @@ }, { "name": "doctrine/instantiator", - "version": "1.1.0", + "version": "1.2.0", "source": { "type": "git", "url": "https://github.com/doctrine/instantiator.git", - "reference": "185b8868aa9bf7159f5f953ed5afb2d7fcdc3bda" + "reference": "a2c590166b2133a4633738648b6b064edae0814a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/instantiator/zipball/185b8868aa9bf7159f5f953ed5afb2d7fcdc3bda", - "reference": "185b8868aa9bf7159f5f953ed5afb2d7fcdc3bda", + "url": "https://api.github.com/repos/doctrine/instantiator/zipball/a2c590166b2133a4633738648b6b064edae0814a", + "reference": "a2c590166b2133a4633738648b6b064edae0814a", "shasum": "" }, "require": { "php": "^7.1" }, "require-dev": { - "athletic/athletic": "~0.1.8", + "doctrine/coding-standard": "^6.0", "ext-pdo": "*", "ext-phar": "*", - "phpunit/phpunit": "^6.2.3", - "squizlabs/php_codesniffer": "^3.0.2" + "phpbench/phpbench": "^0.13", + "phpstan/phpstan-phpunit": "^0.11", + "phpstan/phpstan-shim": "^0.11", + "phpunit/phpunit": "^7.0" }, "type": "library", "extra": { @@ -103,12 +105,12 @@ } ], "description": "A small, lightweight utility to instantiate objects in PHP without invoking their constructors", - "homepage": "https://github.com/doctrine/instantiator", + "homepage": "https://www.doctrine-project.org/projects/instantiator.html", "keywords": [ "constructor", "instantiate" ], - "time": "2017-07-22T11:58:36+00:00" + "time": "2019-03-17T17:37:11+00:00" }, { "name": "mikey179/vfsStream", @@ -735,16 +737,16 @@ }, { "name": "phpunit/php-timer", - "version": "2.0.0", + "version": "2.1.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-timer.git", - "reference": "8b8454ea6958c3dee38453d3bd571e023108c91f" + "reference": "8b389aebe1b8b0578430bda0c7c95a829608e059" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/8b8454ea6958c3dee38453d3bd571e023108c91f", - "reference": "8b8454ea6958c3dee38453d3bd571e023108c91f", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/8b389aebe1b8b0578430bda0c7c95a829608e059", + "reference": "8b389aebe1b8b0578430bda0c7c95a829608e059", "shasum": "" }, "require": { @@ -756,7 +758,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "2.0-dev" + "dev-master": "2.1-dev" } }, "autoload": { @@ -780,7 +782,7 @@ "keywords": [ "timer" ], - "time": "2018-02-01T13:07:23+00:00" + "time": "2019-02-20T10:12:59+00:00" }, { "name": "phpunit/php-token-stream", @@ -833,16 +835,16 @@ }, { "name": "phpunit/phpunit", - "version": "7.5.2", + "version": "7.5.7", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "7c89093bd00f7d5ddf0ab81dee04f801416b4944" + "reference": "eb343b86753d26de07ecba7868fa983104361948" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/7c89093bd00f7d5ddf0ab81dee04f801416b4944", - "reference": "7c89093bd00f7d5ddf0ab81dee04f801416b4944", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/eb343b86753d26de07ecba7868fa983104361948", + "reference": "eb343b86753d26de07ecba7868fa983104361948", "shasum": "" }, "require": { @@ -860,7 +862,7 @@ "phpunit/php-code-coverage": "^6.0.7", "phpunit/php-file-iterator": "^2.0.1", "phpunit/php-text-template": "^1.2.1", - "phpunit/php-timer": "^2.0", + "phpunit/php-timer": "^2.1", "sebastian/comparator": "^3.0", "sebastian/diff": "^3.0", "sebastian/environment": "^4.0", @@ -913,7 +915,7 @@ "testing", "xunit" ], - "time": "2019-01-15T08:19:08+00:00" + "time": "2019-03-16T07:31:17+00:00" }, { "name": "sebastian/code-unit-reverse-lookup", @@ -1026,23 +1028,23 @@ }, { "name": "sebastian/diff", - "version": "3.0.1", + "version": "3.0.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/diff.git", - "reference": "366541b989927187c4ca70490a35615d3fef2dce" + "reference": "720fcc7e9b5cf384ea68d9d930d480907a0c1a29" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/366541b989927187c4ca70490a35615d3fef2dce", - "reference": "366541b989927187c4ca70490a35615d3fef2dce", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/720fcc7e9b5cf384ea68d9d930d480907a0c1a29", + "reference": "720fcc7e9b5cf384ea68d9d930d480907a0c1a29", "shasum": "" }, "require": { "php": "^7.1" }, "require-dev": { - "phpunit/phpunit": "^7.0", + "phpunit/phpunit": "^7.5 || ^8.0", "symfony/process": "^2 || ^3.3 || ^4" }, "type": "library", @@ -1078,32 +1080,35 @@ "unidiff", "unified diff" ], - "time": "2018-06-10T07:54:39+00:00" + "time": "2019-02-04T06:01:07+00:00" }, { "name": "sebastian/environment", - "version": "4.0.1", + "version": "4.1.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/environment.git", - "reference": "febd209a219cea7b56ad799b30ebbea34b71eb8f" + "reference": "6fda8ce1974b62b14935adc02a9ed38252eca656" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/febd209a219cea7b56ad799b30ebbea34b71eb8f", - "reference": "febd209a219cea7b56ad799b30ebbea34b71eb8f", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/6fda8ce1974b62b14935adc02a9ed38252eca656", + "reference": "6fda8ce1974b62b14935adc02a9ed38252eca656", "shasum": "" }, "require": { "php": "^7.1" }, "require-dev": { - "phpunit/phpunit": "^7.4" + "phpunit/phpunit": "^7.5" + }, + "suggest": { + "ext-posix": "*" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "4.0-dev" + "dev-master": "4.1-dev" } }, "autoload": { @@ -1128,7 +1133,7 @@ "environment", "hhvm" ], - "time": "2018-11-25T09:31:21+00:00" + "time": "2019-02-01T05:27:49+00:00" }, { "name": "sebastian/exporter", @@ -1480,16 +1485,16 @@ }, { "name": "symfony/polyfill-ctype", - "version": "v1.10.0", + "version": "v1.11.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-ctype.git", - "reference": "e3d826245268269cd66f8326bd8bc066687b4a19" + "reference": "82ebae02209c21113908c229e9883c419720738a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/e3d826245268269cd66f8326bd8bc066687b4a19", - "reference": "e3d826245268269cd66f8326bd8bc066687b4a19", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/82ebae02209c21113908c229e9883c419720738a", + "reference": "82ebae02209c21113908c229e9883c419720738a", "shasum": "" }, "require": { @@ -1501,7 +1506,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "1.9-dev" + "dev-master": "1.11-dev" } }, "autoload": { @@ -1523,7 +1528,7 @@ }, { "name": "Gert de Pagter", - "email": "backendtea@gmail.com" + "email": "BackEndTea@gmail.com" } ], "description": "Symfony polyfill for ctype functions", @@ -1534,7 +1539,7 @@ "polyfill", "portable" ], - "time": "2018-08-06T14:22:27+00:00" + "time": "2019-02-06T07:57:58+00:00" }, { "name": "theseer/tokenizer", From 802045782005504460a785ca93ca43f3f015c2e4 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Mon, 25 Mar 2019 11:28:15 -0400 Subject: [PATCH 060/142] Update dependencies --- vendor-bin/csfixer/composer.lock | 163 ++++++++++---------- vendor-bin/phpunit/composer.lock | 2 +- vendor-bin/robo/composer.lock | 249 ++++++++++++++++++++----------- 3 files changed, 242 insertions(+), 172 deletions(-) diff --git a/vendor-bin/csfixer/composer.lock b/vendor-bin/csfixer/composer.lock index f522cb4e..155169e4 100644 --- a/vendor-bin/csfixer/composer.lock +++ b/vendor-bin/csfixer/composer.lock @@ -8,16 +8,16 @@ "packages": [ { "name": "composer/semver", - "version": "1.4.2", + "version": "1.5.0", "source": { "type": "git", "url": "https://github.com/composer/semver.git", - "reference": "c7cb9a2095a074d131b65a8a0cd294479d785573" + "reference": "46d9139568ccb8d9e7cdd4539cab7347568a5e2e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/semver/zipball/c7cb9a2095a074d131b65a8a0cd294479d785573", - "reference": "c7cb9a2095a074d131b65a8a0cd294479d785573", + "url": "https://api.github.com/repos/composer/semver/zipball/46d9139568ccb8d9e7cdd4539cab7347568a5e2e", + "reference": "46d9139568ccb8d9e7cdd4539cab7347568a5e2e", "shasum": "" }, "require": { @@ -66,20 +66,20 @@ "validation", "versioning" ], - "time": "2016-08-30T16:08:34+00:00" + "time": "2019-03-19T17:25:45+00:00" }, { "name": "composer/xdebug-handler", - "version": "1.3.1", + "version": "1.3.2", "source": { "type": "git", "url": "https://github.com/composer/xdebug-handler.git", - "reference": "dc523135366eb68f22268d069ea7749486458562" + "reference": "d17708133b6c276d6e42ef887a877866b909d892" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/xdebug-handler/zipball/dc523135366eb68f22268d069ea7749486458562", - "reference": "dc523135366eb68f22268d069ea7749486458562", + "url": "https://api.github.com/repos/composer/xdebug-handler/zipball/d17708133b6c276d6e42ef887a877866b909d892", + "reference": "d17708133b6c276d6e42ef887a877866b909d892", "shasum": "" }, "require": { @@ -110,7 +110,7 @@ "Xdebug", "performance" ], - "time": "2018-11-29T10:59:02+00:00" + "time": "2019-01-28T20:25:53+00:00" }, { "name": "doctrine/annotations", @@ -236,16 +236,16 @@ }, { "name": "friendsofphp/php-cs-fixer", - "version": "v2.14.0", + "version": "v2.14.2", "source": { "type": "git", "url": "https://github.com/FriendsOfPHP/PHP-CS-Fixer.git", - "reference": "b788ea0af899cedc8114dca7db119c93b6685da2" + "reference": "ff401e58261ffc5934a58f795b3f95b355e276cb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/FriendsOfPHP/PHP-CS-Fixer/zipball/b788ea0af899cedc8114dca7db119c93b6685da2", - "reference": "b788ea0af899cedc8114dca7db119c93b6685da2", + "url": "https://api.github.com/repos/FriendsOfPHP/PHP-CS-Fixer/zipball/ff401e58261ffc5934a58f795b3f95b355e276cb", + "reference": "ff401e58261ffc5934a58f795b3f95b355e276cb", "shasum": "" }, "require": { @@ -266,9 +266,6 @@ "symfony/process": "^3.0 || ^4.0", "symfony/stopwatch": "^3.0 || ^4.0" }, - "conflict": { - "hhvm": "*" - }, "require-dev": { "johnkary/phpunit-speedtrap": "^1.1 || ^2.0 || ^3.0", "justinrainbow/json-schema": "^5.0", @@ -292,11 +289,6 @@ "php-cs-fixer" ], "type": "application", - "extra": { - "branch-alias": { - "dev-master": "2.14-dev" - } - }, "autoload": { "psr-4": { "PhpCsFixer\\": "src/" @@ -328,7 +320,7 @@ } ], "description": "A tool to automatically fix PHP code style", - "time": "2019-01-04T18:29:47+00:00" + "time": "2019-02-17T17:44:13+00:00" }, { "name": "paragonie/random_compat", @@ -475,16 +467,16 @@ }, { "name": "symfony/console", - "version": "v4.2.2", + "version": "v4.2.4", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "b0a03c1bb0fcbe288629956cf2f1dd3f1dc97522" + "reference": "9dc2299a016497f9ee620be94524e6c0af0280a9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/b0a03c1bb0fcbe288629956cf2f1dd3f1dc97522", - "reference": "b0a03c1bb0fcbe288629956cf2f1dd3f1dc97522", + "url": "https://api.github.com/repos/symfony/console/zipball/9dc2299a016497f9ee620be94524e6c0af0280a9", + "reference": "9dc2299a016497f9ee620be94524e6c0af0280a9", "shasum": "" }, "require": { @@ -496,6 +488,9 @@ "symfony/dependency-injection": "<3.4", "symfony/process": "<3.3" }, + "provide": { + "psr/log-implementation": "1.0" + }, "require-dev": { "psr/log": "~1.0", "symfony/config": "~3.4|~4.0", @@ -505,7 +500,7 @@ "symfony/process": "~3.4|~4.0" }, "suggest": { - "psr/log-implementation": "For using the console logger", + "psr/log": "For using the console logger", "symfony/event-dispatcher": "", "symfony/lock": "", "symfony/process": "" @@ -540,7 +535,7 @@ ], "description": "Symfony Console Component", "homepage": "https://symfony.com", - "time": "2019-01-04T15:13:53+00:00" + "time": "2019-02-23T15:17:42+00:00" }, { "name": "symfony/contracts", @@ -612,16 +607,16 @@ }, { "name": "symfony/event-dispatcher", - "version": "v4.2.2", + "version": "v4.2.4", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher.git", - "reference": "887de6d34c86cf0cb6cbf910afb170cdb743cb5e" + "reference": "3354d2e6af986dd71f68b4e5cf4a933ab58697fb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/887de6d34c86cf0cb6cbf910afb170cdb743cb5e", - "reference": "887de6d34c86cf0cb6cbf910afb170cdb743cb5e", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/3354d2e6af986dd71f68b4e5cf4a933ab58697fb", + "reference": "3354d2e6af986dd71f68b4e5cf4a933ab58697fb", "shasum": "" }, "require": { @@ -672,20 +667,20 @@ ], "description": "Symfony EventDispatcher Component", "homepage": "https://symfony.com", - "time": "2019-01-05T16:37:49+00:00" + "time": "2019-02-23T15:17:42+00:00" }, { "name": "symfony/filesystem", - "version": "v4.2.2", + "version": "v4.2.4", "source": { "type": "git", "url": "https://github.com/symfony/filesystem.git", - "reference": "c2ffd9a93f2d6c5be2f68a0aa7953cc229f871f8" + "reference": "e16b9e471703b2c60b95f14d31c1239f68f11601" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/c2ffd9a93f2d6c5be2f68a0aa7953cc229f871f8", - "reference": "c2ffd9a93f2d6c5be2f68a0aa7953cc229f871f8", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/e16b9e471703b2c60b95f14d31c1239f68f11601", + "reference": "e16b9e471703b2c60b95f14d31c1239f68f11601", "shasum": "" }, "require": { @@ -722,20 +717,20 @@ ], "description": "Symfony Filesystem Component", "homepage": "https://symfony.com", - "time": "2019-01-03T09:07:35+00:00" + "time": "2019-02-07T11:40:08+00:00" }, { "name": "symfony/finder", - "version": "v4.2.2", + "version": "v4.2.4", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "9094d69e8c6ee3fe186a0ec5a4f1401e506071ce" + "reference": "267b7002c1b70ea80db0833c3afe05f0fbde580a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/9094d69e8c6ee3fe186a0ec5a4f1401e506071ce", - "reference": "9094d69e8c6ee3fe186a0ec5a4f1401e506071ce", + "url": "https://api.github.com/repos/symfony/finder/zipball/267b7002c1b70ea80db0833c3afe05f0fbde580a", + "reference": "267b7002c1b70ea80db0833c3afe05f0fbde580a", "shasum": "" }, "require": { @@ -771,20 +766,20 @@ ], "description": "Symfony Finder Component", "homepage": "https://symfony.com", - "time": "2019-01-03T09:07:35+00:00" + "time": "2019-02-23T15:42:05+00:00" }, { "name": "symfony/options-resolver", - "version": "v4.2.2", + "version": "v4.2.4", "source": { "type": "git", "url": "https://github.com/symfony/options-resolver.git", - "reference": "fbcb106aeee72f3450298bf73324d2cc00d083d1" + "reference": "3896e5a7d06fd15fa4947694c8dcdd371ff147d1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/options-resolver/zipball/fbcb106aeee72f3450298bf73324d2cc00d083d1", - "reference": "fbcb106aeee72f3450298bf73324d2cc00d083d1", + "url": "https://api.github.com/repos/symfony/options-resolver/zipball/3896e5a7d06fd15fa4947694c8dcdd371ff147d1", + "reference": "3896e5a7d06fd15fa4947694c8dcdd371ff147d1", "shasum": "" }, "require": { @@ -825,20 +820,20 @@ "configuration", "options" ], - "time": "2019-01-03T09:07:35+00:00" + "time": "2019-02-23T15:17:42+00:00" }, { "name": "symfony/polyfill-ctype", - "version": "v1.10.0", + "version": "v1.11.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-ctype.git", - "reference": "e3d826245268269cd66f8326bd8bc066687b4a19" + "reference": "82ebae02209c21113908c229e9883c419720738a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/e3d826245268269cd66f8326bd8bc066687b4a19", - "reference": "e3d826245268269cd66f8326bd8bc066687b4a19", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/82ebae02209c21113908c229e9883c419720738a", + "reference": "82ebae02209c21113908c229e9883c419720738a", "shasum": "" }, "require": { @@ -850,7 +845,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "1.9-dev" + "dev-master": "1.11-dev" } }, "autoload": { @@ -872,7 +867,7 @@ }, { "name": "Gert de Pagter", - "email": "BackEndTea@gmail.com" + "email": "backendtea@gmail.com" } ], "description": "Symfony polyfill for ctype functions", @@ -883,20 +878,20 @@ "polyfill", "portable" ], - "time": "2018-08-06T14:22:27+00:00" + "time": "2019-02-06T07:57:58+00:00" }, { "name": "symfony/polyfill-mbstring", - "version": "v1.10.0", + "version": "v1.11.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-mbstring.git", - "reference": "c79c051f5b3a46be09205c73b80b346e4153e494" + "reference": "fe5e94c604826c35a32fa832f35bd036b6799609" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/c79c051f5b3a46be09205c73b80b346e4153e494", - "reference": "c79c051f5b3a46be09205c73b80b346e4153e494", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/fe5e94c604826c35a32fa832f35bd036b6799609", + "reference": "fe5e94c604826c35a32fa832f35bd036b6799609", "shasum": "" }, "require": { @@ -908,7 +903,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "1.9-dev" + "dev-master": "1.11-dev" } }, "autoload": { @@ -942,20 +937,20 @@ "portable", "shim" ], - "time": "2018-09-21T13:07:52+00:00" + "time": "2019-02-06T07:57:58+00:00" }, { "name": "symfony/polyfill-php70", - "version": "v1.10.0", + "version": "v1.11.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php70.git", - "reference": "6b88000cdd431cd2e940caa2cb569201f3f84224" + "reference": "bc4858fb611bda58719124ca079baff854149c89" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php70/zipball/6b88000cdd431cd2e940caa2cb569201f3f84224", - "reference": "6b88000cdd431cd2e940caa2cb569201f3f84224", + "url": "https://api.github.com/repos/symfony/polyfill-php70/zipball/bc4858fb611bda58719124ca079baff854149c89", + "reference": "bc4858fb611bda58719124ca079baff854149c89", "shasum": "" }, "require": { @@ -965,7 +960,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "1.9-dev" + "dev-master": "1.11-dev" } }, "autoload": { @@ -1001,20 +996,20 @@ "portable", "shim" ], - "time": "2018-09-21T06:26:08+00:00" + "time": "2019-02-06T07:57:58+00:00" }, { "name": "symfony/polyfill-php72", - "version": "v1.10.0", + "version": "v1.11.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php72.git", - "reference": "9050816e2ca34a8e916c3a0ae8b9c2fccf68b631" + "reference": "ab50dcf166d5f577978419edd37aa2bb8eabce0c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php72/zipball/9050816e2ca34a8e916c3a0ae8b9c2fccf68b631", - "reference": "9050816e2ca34a8e916c3a0ae8b9c2fccf68b631", + "url": "https://api.github.com/repos/symfony/polyfill-php72/zipball/ab50dcf166d5f577978419edd37aa2bb8eabce0c", + "reference": "ab50dcf166d5f577978419edd37aa2bb8eabce0c", "shasum": "" }, "require": { @@ -1023,7 +1018,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "1.9-dev" + "dev-master": "1.11-dev" } }, "autoload": { @@ -1056,20 +1051,20 @@ "portable", "shim" ], - "time": "2018-09-21T13:07:52+00:00" + "time": "2019-02-06T07:57:58+00:00" }, { "name": "symfony/process", - "version": "v4.2.2", + "version": "v4.2.4", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "ea043ab5d8ed13b467a9087d81cb876aee7f689a" + "reference": "6c05edb11fbeff9e2b324b4270ecb17911a8b7ad" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/ea043ab5d8ed13b467a9087d81cb876aee7f689a", - "reference": "ea043ab5d8ed13b467a9087d81cb876aee7f689a", + "url": "https://api.github.com/repos/symfony/process/zipball/6c05edb11fbeff9e2b324b4270ecb17911a8b7ad", + "reference": "6c05edb11fbeff9e2b324b4270ecb17911a8b7ad", "shasum": "" }, "require": { @@ -1105,20 +1100,20 @@ ], "description": "Symfony Process Component", "homepage": "https://symfony.com", - "time": "2019-01-03T14:48:52+00:00" + "time": "2019-01-24T22:05:03+00:00" }, { "name": "symfony/stopwatch", - "version": "v4.2.2", + "version": "v4.2.4", "source": { "type": "git", "url": "https://github.com/symfony/stopwatch.git", - "reference": "af62b35760fc92c8dbdce659b4eebdfe0e6a0472" + "reference": "b1a5f646d56a3290230dbc8edf2a0d62cda23f67" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/stopwatch/zipball/af62b35760fc92c8dbdce659b4eebdfe0e6a0472", - "reference": "af62b35760fc92c8dbdce659b4eebdfe0e6a0472", + "url": "https://api.github.com/repos/symfony/stopwatch/zipball/b1a5f646d56a3290230dbc8edf2a0d62cda23f67", + "reference": "b1a5f646d56a3290230dbc8edf2a0d62cda23f67", "shasum": "" }, "require": { @@ -1155,7 +1150,7 @@ ], "description": "Symfony Stopwatch Component", "homepage": "https://symfony.com", - "time": "2019-01-03T09:07:35+00:00" + "time": "2019-01-16T20:31:39+00:00" } ], "packages-dev": [], diff --git a/vendor-bin/phpunit/composer.lock b/vendor-bin/phpunit/composer.lock index 6e00b3cd..016ad853 100644 --- a/vendor-bin/phpunit/composer.lock +++ b/vendor-bin/phpunit/composer.lock @@ -1528,7 +1528,7 @@ }, { "name": "Gert de Pagter", - "email": "BackEndTea@gmail.com" + "email": "backendtea@gmail.com" } ], "description": "Symfony polyfill for ctype functions", diff --git a/vendor-bin/robo/composer.lock b/vendor-bin/robo/composer.lock index 123dcebc..8458df82 100644 --- a/vendor-bin/robo/composer.lock +++ b/vendor-bin/robo/composer.lock @@ -8,21 +8,21 @@ "packages": [ { "name": "consolidation/annotated-command", - "version": "2.11.0", + "version": "2.12.0", "source": { "type": "git", "url": "https://github.com/consolidation/annotated-command.git", - "reference": "edea407f57104ed518cc3c3b47d5b84403ee267a" + "reference": "512a2e54c98f3af377589de76c43b24652bcb789" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/consolidation/annotated-command/zipball/edea407f57104ed518cc3c3b47d5b84403ee267a", - "reference": "edea407f57104ed518cc3c3b47d5b84403ee267a", + "url": "https://api.github.com/repos/consolidation/annotated-command/zipball/512a2e54c98f3af377589de76c43b24652bcb789", + "reference": "512a2e54c98f3af377589de76c43b24652bcb789", "shasum": "" }, "require": { "consolidation/output-formatters": "^3.4", - "php": ">=5.4.0", + "php": ">=5.4.5", "psr/log": "^1", "symfony/console": "^2.8|^3|^4", "symfony/event-dispatcher": "^2.5|^3|^4", @@ -100,20 +100,20 @@ } ], "description": "Initialize Symfony Console commands from annotated command class methods.", - "time": "2018-12-29T04:43:17+00:00" + "time": "2019-03-08T16:55:03+00:00" }, { "name": "consolidation/config", - "version": "1.1.1", + "version": "1.2.1", "source": { "type": "git", "url": "https://github.com/consolidation/config.git", - "reference": "925231dfff32f05b787e1fddb265e789b939cf4c" + "reference": "cac1279bae7efb5c7fb2ca4c3ba4b8eb741a96c1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/consolidation/config/zipball/925231dfff32f05b787e1fddb265e789b939cf4c", - "reference": "925231dfff32f05b787e1fddb265e789b939cf4c", + "url": "https://api.github.com/repos/consolidation/config/zipball/cac1279bae7efb5c7fb2ca4c3ba4b8eb741a96c1", + "reference": "cac1279bae7efb5c7fb2ca4c3ba4b8eb741a96c1", "shasum": "" }, "require": { @@ -122,9 +122,9 @@ "php": ">=5.4.0" }, "require-dev": { - "g1a/composer-test-scenarios": "^1", + "g1a/composer-test-scenarios": "^3", + "php-coveralls/php-coveralls": "^1", "phpunit/phpunit": "^5", - "satooshi/php-coveralls": "^1.0", "squizlabs/php_codesniffer": "2.*", "symfony/console": "^2.5|^3|^4", "symfony/yaml": "^2.8.11|^3|^4" @@ -134,6 +134,33 @@ }, "type": "library", "extra": { + "scenarios": { + "symfony4": { + "require-dev": { + "symfony/console": "^4.0" + }, + "config": { + "platform": { + "php": "7.1.3" + } + } + }, + "symfony2": { + "require-dev": { + "symfony/console": "^2.8", + "symfony/event-dispatcher": "^2.8", + "phpunit/phpunit": "^4.8.36" + }, + "remove": [ + "php-coveralls/php-coveralls" + ], + "config": { + "platform": { + "php": "5.4.8" + } + } + } + }, "branch-alias": { "dev-master": "1.x-dev" } @@ -154,7 +181,7 @@ } ], "description": "Provide configuration services for a commandline tool.", - "time": "2018-10-24T17:55:35+00:00" + "time": "2019-03-03T19:37:04+00:00" }, { "name": "consolidation/log", @@ -248,16 +275,16 @@ }, { "name": "consolidation/output-formatters", - "version": "3.4.0", + "version": "3.4.1", "source": { "type": "git", "url": "https://github.com/consolidation/output-formatters.git", - "reference": "a942680232094c4a5b21c0b7e54c20cce623ae19" + "reference": "0881112642ad9059071f13f397f571035b527cb9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/consolidation/output-formatters/zipball/a942680232094c4a5b21c0b7e54c20cce623ae19", - "reference": "a942680232094c4a5b21c0b7e54c20cce623ae19", + "url": "https://api.github.com/repos/consolidation/output-formatters/zipball/0881112642ad9059071f13f397f571035b527cb9", + "reference": "0881112642ad9059071f13f397f571035b527cb9", "shasum": "" }, "require": { @@ -267,11 +294,10 @@ "symfony/finder": "^2.5|^3|^4" }, "require-dev": { - "g1a/composer-test-scenarios": "^2", + "g1a/composer-test-scenarios": "^3", + "php-coveralls/php-coveralls": "^1", "phpunit/phpunit": "^5.7.27", - "satooshi/php-coveralls": "^2", "squizlabs/php_codesniffer": "^2.7", - "symfony/console": "3.2.3", "symfony/var-dumper": "^2.8|^3|^4", "victorjonsson/markdowndocs": "^1.3" }, @@ -280,6 +306,52 @@ }, "type": "library", "extra": { + "scenarios": { + "symfony4": { + "require": { + "symfony/console": "^4.0" + }, + "require-dev": { + "phpunit/phpunit": "^6" + }, + "config": { + "platform": { + "php": "7.1.3" + } + } + }, + "symfony3": { + "require": { + "symfony/console": "^3.4", + "symfony/finder": "^3.4", + "symfony/var-dumper": "^3.4" + }, + "config": { + "platform": { + "php": "5.6.32" + } + } + }, + "symfony2": { + "require": { + "symfony/console": "^2.8" + }, + "require-dev": { + "phpunit/phpunit": "^4.8.36" + }, + "remove": [ + "php-coveralls/php-coveralls" + ], + "config": { + "platform": { + "php": "5.4.8" + } + }, + "scenario-options": { + "create-lockfile": "false" + } + } + }, "branch-alias": { "dev-master": "3.x-dev" } @@ -300,25 +372,25 @@ } ], "description": "Format text by applying transformations provided by plug-in formatters.", - "time": "2018-10-19T22:35:38+00:00" + "time": "2019-03-14T03:45:44+00:00" }, { "name": "consolidation/robo", - "version": "1.4.3", + "version": "1.4.9", "source": { "type": "git", "url": "https://github.com/consolidation/Robo.git", - "reference": "d0b6f516ec940add7abed4f1432d30cca5f8ae0c" + "reference": "5c6b3840a45afda1cbffbb3bb1f94dd5f9f83345" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/consolidation/Robo/zipball/d0b6f516ec940add7abed4f1432d30cca5f8ae0c", - "reference": "d0b6f516ec940add7abed4f1432d30cca5f8ae0c", + "url": "https://api.github.com/repos/consolidation/Robo/zipball/5c6b3840a45afda1cbffbb3bb1f94dd5f9f83345", + "reference": "5c6b3840a45afda1cbffbb3bb1f94dd5f9f83345", "shasum": "" }, "require": { "consolidation/annotated-command": "^2.10.2", - "consolidation/config": "^1.0.10", + "consolidation/config": "^1.2", "consolidation/log": "~1", "consolidation/output-formatters": "^3.1.13", "consolidation/self-update": "^1", @@ -344,7 +416,7 @@ "natxet/cssmin": "3.0.4", "nikic/php-parser": "^3.1.5", "patchwork/jsqueeze": "~2", - "pear/archive_tar": "^1.4.2", + "pear/archive_tar": "^1.4.4", "php-coveralls/php-coveralls": "^1", "phpunit/php-code-coverage": "~2|~4", "squizlabs/php_codesniffer": "^2.8" @@ -408,7 +480,7 @@ } ], "description": "Modern task runner", - "time": "2019-01-02T21:33:28+00:00" + "time": "2019-03-19T18:07:19+00:00" }, { "name": "consolidation/self-update", @@ -712,16 +784,16 @@ }, { "name": "pear/archive_tar", - "version": "1.4.5", + "version": "1.4.6", "source": { "type": "git", "url": "https://github.com/pear/Archive_Tar.git", - "reference": "ff716ca697c5e9e8593212cb785ffd03ee11b01f" + "reference": "b8e33f9063a7cd1d20f079014f8382b3a7aee47e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/pear/Archive_Tar/zipball/ff716ca697c5e9e8593212cb785ffd03ee11b01f", - "reference": "ff716ca697c5e9e8593212cb785ffd03ee11b01f", + "url": "https://api.github.com/repos/pear/Archive_Tar/zipball/b8e33f9063a7cd1d20f079014f8382b3a7aee47e", + "reference": "b8e33f9063a7cd1d20f079014f8382b3a7aee47e", "shasum": "" }, "require": { @@ -774,20 +846,20 @@ "archive", "tar" ], - "time": "2019-01-02T21:45:13+00:00" + "time": "2019-02-01T11:10:38+00:00" }, { "name": "pear/console_getopt", - "version": "v1.4.1", + "version": "v1.4.2", "source": { "type": "git", "url": "https://github.com/pear/Console_Getopt.git", - "reference": "82f05cd1aa3edf34e19aa7c8ca312ce13a6a577f" + "reference": "6c77aeb625b32bd752e89ee17972d103588b90c0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/pear/Console_Getopt/zipball/82f05cd1aa3edf34e19aa7c8ca312ce13a6a577f", - "reference": "82f05cd1aa3edf34e19aa7c8ca312ce13a6a577f", + "url": "https://api.github.com/repos/pear/Console_Getopt/zipball/6c77aeb625b32bd752e89ee17972d103588b90c0", + "reference": "6c77aeb625b32bd752e89ee17972d103588b90c0", "shasum": "" }, "type": "library", @@ -821,20 +893,20 @@ } ], "description": "More info available on: http://pear.php.net/package/Console_Getopt", - "time": "2015-07-20T20:28:12+00:00" + "time": "2019-02-06T16:52:33+00:00" }, { "name": "pear/pear-core-minimal", - "version": "v1.10.7", + "version": "v1.10.9", "source": { "type": "git", "url": "https://github.com/pear/pear-core-minimal.git", - "reference": "19a3e0fcd50492c4357372f623f55f1b144346da" + "reference": "742be8dd68c746a01e4b0a422258e9c9cae1c37f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/pear/pear-core-minimal/zipball/19a3e0fcd50492c4357372f623f55f1b144346da", - "reference": "19a3e0fcd50492c4357372f623f55f1b144346da", + "url": "https://api.github.com/repos/pear/pear-core-minimal/zipball/742be8dd68c746a01e4b0a422258e9c9cae1c37f", + "reference": "742be8dd68c746a01e4b0a422258e9c9cae1c37f", "shasum": "" }, "require": { @@ -865,7 +937,7 @@ } ], "description": "Minimal set of PEAR core files to be used as composer dependency", - "time": "2018-12-05T20:03:52+00:00" + "time": "2019-03-13T18:15:44+00:00" }, { "name": "pear/pear_exception", @@ -1020,16 +1092,16 @@ }, { "name": "symfony/console", - "version": "v4.2.2", + "version": "v4.2.4", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "b0a03c1bb0fcbe288629956cf2f1dd3f1dc97522" + "reference": "9dc2299a016497f9ee620be94524e6c0af0280a9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/b0a03c1bb0fcbe288629956cf2f1dd3f1dc97522", - "reference": "b0a03c1bb0fcbe288629956cf2f1dd3f1dc97522", + "url": "https://api.github.com/repos/symfony/console/zipball/9dc2299a016497f9ee620be94524e6c0af0280a9", + "reference": "9dc2299a016497f9ee620be94524e6c0af0280a9", "shasum": "" }, "require": { @@ -1041,6 +1113,9 @@ "symfony/dependency-injection": "<3.4", "symfony/process": "<3.3" }, + "provide": { + "psr/log-implementation": "1.0" + }, "require-dev": { "psr/log": "~1.0", "symfony/config": "~3.4|~4.0", @@ -1050,7 +1125,7 @@ "symfony/process": "~3.4|~4.0" }, "suggest": { - "psr/log-implementation": "For using the console logger", + "psr/log": "For using the console logger", "symfony/event-dispatcher": "", "symfony/lock": "", "symfony/process": "" @@ -1085,7 +1160,7 @@ ], "description": "Symfony Console Component", "homepage": "https://symfony.com", - "time": "2019-01-04T15:13:53+00:00" + "time": "2019-02-23T15:17:42+00:00" }, { "name": "symfony/contracts", @@ -1157,16 +1232,16 @@ }, { "name": "symfony/event-dispatcher", - "version": "v4.2.2", + "version": "v4.2.4", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher.git", - "reference": "887de6d34c86cf0cb6cbf910afb170cdb743cb5e" + "reference": "3354d2e6af986dd71f68b4e5cf4a933ab58697fb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/887de6d34c86cf0cb6cbf910afb170cdb743cb5e", - "reference": "887de6d34c86cf0cb6cbf910afb170cdb743cb5e", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/3354d2e6af986dd71f68b4e5cf4a933ab58697fb", + "reference": "3354d2e6af986dd71f68b4e5cf4a933ab58697fb", "shasum": "" }, "require": { @@ -1217,20 +1292,20 @@ ], "description": "Symfony EventDispatcher Component", "homepage": "https://symfony.com", - "time": "2019-01-05T16:37:49+00:00" + "time": "2019-02-23T15:17:42+00:00" }, { "name": "symfony/filesystem", - "version": "v4.2.2", + "version": "v4.2.4", "source": { "type": "git", "url": "https://github.com/symfony/filesystem.git", - "reference": "c2ffd9a93f2d6c5be2f68a0aa7953cc229f871f8" + "reference": "e16b9e471703b2c60b95f14d31c1239f68f11601" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/c2ffd9a93f2d6c5be2f68a0aa7953cc229f871f8", - "reference": "c2ffd9a93f2d6c5be2f68a0aa7953cc229f871f8", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/e16b9e471703b2c60b95f14d31c1239f68f11601", + "reference": "e16b9e471703b2c60b95f14d31c1239f68f11601", "shasum": "" }, "require": { @@ -1267,20 +1342,20 @@ ], "description": "Symfony Filesystem Component", "homepage": "https://symfony.com", - "time": "2019-01-03T09:07:35+00:00" + "time": "2019-02-07T11:40:08+00:00" }, { "name": "symfony/finder", - "version": "v4.2.2", + "version": "v4.2.4", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "9094d69e8c6ee3fe186a0ec5a4f1401e506071ce" + "reference": "267b7002c1b70ea80db0833c3afe05f0fbde580a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/9094d69e8c6ee3fe186a0ec5a4f1401e506071ce", - "reference": "9094d69e8c6ee3fe186a0ec5a4f1401e506071ce", + "url": "https://api.github.com/repos/symfony/finder/zipball/267b7002c1b70ea80db0833c3afe05f0fbde580a", + "reference": "267b7002c1b70ea80db0833c3afe05f0fbde580a", "shasum": "" }, "require": { @@ -1316,20 +1391,20 @@ ], "description": "Symfony Finder Component", "homepage": "https://symfony.com", - "time": "2019-01-03T09:07:35+00:00" + "time": "2019-02-23T15:42:05+00:00" }, { "name": "symfony/polyfill-ctype", - "version": "v1.10.0", + "version": "v1.11.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-ctype.git", - "reference": "e3d826245268269cd66f8326bd8bc066687b4a19" + "reference": "82ebae02209c21113908c229e9883c419720738a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/e3d826245268269cd66f8326bd8bc066687b4a19", - "reference": "e3d826245268269cd66f8326bd8bc066687b4a19", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/82ebae02209c21113908c229e9883c419720738a", + "reference": "82ebae02209c21113908c229e9883c419720738a", "shasum": "" }, "require": { @@ -1341,7 +1416,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "1.9-dev" + "dev-master": "1.11-dev" } }, "autoload": { @@ -1363,7 +1438,7 @@ }, { "name": "Gert de Pagter", - "email": "BackEndTea@gmail.com" + "email": "backendtea@gmail.com" } ], "description": "Symfony polyfill for ctype functions", @@ -1374,20 +1449,20 @@ "polyfill", "portable" ], - "time": "2018-08-06T14:22:27+00:00" + "time": "2019-02-06T07:57:58+00:00" }, { "name": "symfony/polyfill-mbstring", - "version": "v1.10.0", + "version": "v1.11.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-mbstring.git", - "reference": "c79c051f5b3a46be09205c73b80b346e4153e494" + "reference": "fe5e94c604826c35a32fa832f35bd036b6799609" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/c79c051f5b3a46be09205c73b80b346e4153e494", - "reference": "c79c051f5b3a46be09205c73b80b346e4153e494", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/fe5e94c604826c35a32fa832f35bd036b6799609", + "reference": "fe5e94c604826c35a32fa832f35bd036b6799609", "shasum": "" }, "require": { @@ -1399,7 +1474,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "1.9-dev" + "dev-master": "1.11-dev" } }, "autoload": { @@ -1433,20 +1508,20 @@ "portable", "shim" ], - "time": "2018-09-21T13:07:52+00:00" + "time": "2019-02-06T07:57:58+00:00" }, { "name": "symfony/process", - "version": "v3.4.21", + "version": "v3.4.23", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "0d41dd7d95ed179aed6a13393b0f4f97bfa2d25c" + "reference": "009f8dda80930e89e8344a4e310b08f9ff07dd2e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/0d41dd7d95ed179aed6a13393b0f4f97bfa2d25c", - "reference": "0d41dd7d95ed179aed6a13393b0f4f97bfa2d25c", + "url": "https://api.github.com/repos/symfony/process/zipball/009f8dda80930e89e8344a4e310b08f9ff07dd2e", + "reference": "009f8dda80930e89e8344a4e310b08f9ff07dd2e", "shasum": "" }, "require": { @@ -1482,20 +1557,20 @@ ], "description": "Symfony Process Component", "homepage": "https://symfony.com", - "time": "2019-01-02T21:24:08+00:00" + "time": "2019-01-16T13:27:11+00:00" }, { "name": "symfony/yaml", - "version": "v4.2.2", + "version": "v4.2.4", "source": { "type": "git", "url": "https://github.com/symfony/yaml.git", - "reference": "d0aa6c0ea484087927b49fd513383a7d36190ca6" + "reference": "761fa560a937fd7686e5274ff89dcfa87a5047df" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/d0aa6c0ea484087927b49fd513383a7d36190ca6", - "reference": "d0aa6c0ea484087927b49fd513383a7d36190ca6", + "url": "https://api.github.com/repos/symfony/yaml/zipball/761fa560a937fd7686e5274ff89dcfa87a5047df", + "reference": "761fa560a937fd7686e5274ff89dcfa87a5047df", "shasum": "" }, "require": { @@ -1541,7 +1616,7 @@ ], "description": "Symfony Yaml Component", "homepage": "https://symfony.com", - "time": "2019-01-03T09:07:35+00:00" + "time": "2019-02-23T15:17:42+00:00" } ], "packages-dev": [], From 65f723c7d4ed2f53e3ad764713672d83ba8021bc Mon Sep 17 00:00:00 2001 From: "J. King" Date: Mon, 25 Mar 2019 11:30:35 -0400 Subject: [PATCH 061/142] Fix missing reference to author in TT-RSS. --- lib/REST/TinyTinyRSS/API.php | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/REST/TinyTinyRSS/API.php b/lib/REST/TinyTinyRSS/API.php index a3572ba3..8bf85bcc 100644 --- a/lib/REST/TinyTinyRSS/API.php +++ b/lib/REST/TinyTinyRSS/API.php @@ -1286,6 +1286,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { "id", "guid", "title", + "author", "url", "unread", "starred", From 1e83350dd0c1c91c34682c8eda17a067933d32b4 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Mon, 25 Mar 2019 11:57:31 -0400 Subject: [PATCH 062/142] Version bump --- CHANGELOG | 12 ++++++++++++ lib/Arsse.php | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/CHANGELOG b/CHANGELOG index 0bd9c397..f3baeb21 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,15 @@ +Version 0.7.1 (2019-03-25) +========================== + +Bug fixes: +- Correctly initialize new on-disk SQLite databases +- Retry queries on schema changes with PDO SQLite +- Correctly read author name from database in Tiny Tiny RSS +- Update internal version number to correct version + +Changes: +- Improve performance of lesser-used database queries + Version 0.7.0 (2019-03-02) ========================== diff --git a/lib/Arsse.php b/lib/Arsse.php index 7fbd1b2b..6de24256 100644 --- a/lib/Arsse.php +++ b/lib/Arsse.php @@ -7,7 +7,7 @@ declare(strict_types=1); namespace JKingWeb\Arsse; class Arsse { - const VERSION = "0.6.1"; + const VERSION = "0.7.1"; /** @var Lang */ public static $lang; From a7fe8791746e8d6453e967413b9746fa7d08ac40 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Mon, 25 Mar 2019 14:24:58 -0400 Subject: [PATCH 063/142] Fix CLI auth test --- tests/cases/CLI/TestCLI.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/cases/CLI/TestCLI.php b/tests/cases/CLI/TestCLI.php index 2ca1ebe1..f7f2a7cd 100644 --- a/tests/cases/CLI/TestCLI.php +++ b/tests/cases/CLI/TestCLI.php @@ -178,7 +178,7 @@ class TestCLI extends \JKingWeb\Arsse\Test\AbstractTest { $fever = \Phake::mock(FeverUser::class); \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", "thx1388")->thenReturn(true); + \Phake::when($fever)->authenticate("jane.doe@example.com", "thx1138")->thenReturn(true); \Phake::when($this->cli)->getFever->thenReturn($fever); $this->assertConsole($this->cli, $cmd, $exitStatus, $output); } From 54be5997d153d2cedc453840bf340c017b9f99c4 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Mon, 25 Mar 2019 15:03:41 -0400 Subject: [PATCH 064/142] CLI tests for password changing and clearing --- tests/cases/CLI/TestCLI.php | 49 ++++++++++++++++++++++++++++++------- 1 file changed, 40 insertions(+), 9 deletions(-) diff --git a/tests/cases/CLI/TestCLI.php b/tests/cases/CLI/TestCLI.php index f7f2a7cd..9a2d622c 100644 --- a/tests/cases/CLI/TestCLI.php +++ b/tests/cases/CLI/TestCLI.php @@ -221,28 +221,59 @@ class TestCLI extends \JKingWeb\Arsse\Test\AbstractTest { /** @dataProvider provideUserPasswordChanges */ public function testChangeAUserPassword(string $cmd, int $exitStatus, string $output) { - // 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("passwordSet")->will($this->returnCallback(function($user, $pass = null) { + $passwordChange = function($user, $pass = null) { switch ($user) { case "jane.doe@example.com": throw new \JKingWeb\Arsse\User\Exception("doesNotExist"); case "john.doe@example.com": return is_null($pass) ? "random password" : $pass; } - })); + }; + // 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("passwordSet")->will($this->returnCallback($passwordChange)); + $fever = \Phake::mock(FeverUser::class); + \Phake::when($fever)->register->thenReturnCallback($passwordChange); + \Phake::when($this->cli)->getFever->thenReturn($fever); $this->assertConsole($this->cli, $cmd, $exitStatus, $output); } public function provideUserPasswordChanges() { return [ - ["arsse.php user set-pass john.doe@example.com", 0, "random password"], - ["arsse.php user set-pass john.doe@example.com superman", 0, ""], - ["arsse.php user set-pass jane.doe@example.com", 10402, ""], + ["arsse.php user set-pass john.doe@example.com", 0, "random password"], + ["arsse.php user set-pass john.doe@example.com superman", 0, ""], + ["arsse.php user set-pass jane.doe@example.com", 10402, ""], + ["arsse.php user set-pass john.doe@example.com --fever", 0, "random password"], + ["arsse.php user set-pass --fever john.doe@example.com superman", 0, ""], + ["arsse.php user set-pass jane.doe@example.com --fever", 10402, ""], ]; } - public function testChangeAFeverPassword() { - $this->markTestIncomplete(); + /** @dataProvider provideUserPasswordClearings */ + public function testClearAUserPassword(string $cmd, int $exitStatus, string $output) { + $passwordClear = function($user) { + switch ($user) { + case "jane.doe@example.com": + throw new \JKingWeb\Arsse\User\Exception("doesNotExist"); + case "john.doe@example.com": + return true; + } + }; + // 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("passwordUnet")->will($this->returnCallback($passwordChange)); + $fever = \Phake::mock(FeverUser::class); + \Phake::when($fever)->unregister->thenReturnCallback($passwordChange); + \Phake::when($this->cli)->getFever->thenReturn($fever); + $this->assertConsole($this->cli, $cmd, $exitStatus, $output); + } + + public function provideUserPasswordClearings() { + return [ + ["arsse.php user unset-pass john.doe@example.com", 0, ""], + ["arsse.php user unset-pass jane.doe@example.com", 10402, ""], + ["arsse.php user unset-pass john.doe@example.com --fever", 0, ""], + ["arsse.php user unset-pass jane.doe@example.com --fever", 10402, ""], + ]; } } From 9c61f967e3ba7ab60abffda22543950cefb1a5dd Mon Sep 17 00:00:00 2001 From: "J. King" Date: Mon, 25 Mar 2019 17:07:28 -0400 Subject: [PATCH 065/142] Correct CLI password clearing --- CHANGELOG | 8 ++++++++ lib/CLI.php | 4 ++-- tests/cases/CLI/TestCLI.php | 4 ++-- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index f3baeb21..edc4b0ab 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,11 @@ +Version 0.8.0 (2019-??-??) +========================== + +New features: +- Support for the Fever protocol (see README.md for details) +- Command line functionality for clearing a password, disabling the account +- Command line options for dealing with Fever passwords + Version 0.7.1 (2019-03-25) ========================== diff --git a/lib/CLI.php b/lib/CLI.php index 85ee8044..96936980 100644 --- a/lib/CLI.php +++ b/lib/CLI.php @@ -115,7 +115,7 @@ USAGE_TEXT; } protected function userManage($args): int { - switch ($this->command(["add", "remove", "set-pass", "list", "auth"], $args)) { + switch ($this->command(["add", "remove", "set-pass", "unset-pass", "list", "auth"], $args)) { case "add": return $this->userAddOrSetPassword("add", $args[""], $args[""]); case "set-pass": @@ -130,7 +130,7 @@ USAGE_TEXT; } case "unset-pass": if ($args['--fever']) { - $this->getFever()->unegister($args[""]); + $this->getFever()->unregister($args[""]); } else { Arsse::$user->passwordUnset($args[""], $args["--oldpass"]); } diff --git a/tests/cases/CLI/TestCLI.php b/tests/cases/CLI/TestCLI.php index 9a2d622c..3f1c3d30 100644 --- a/tests/cases/CLI/TestCLI.php +++ b/tests/cases/CLI/TestCLI.php @@ -261,9 +261,9 @@ class TestCLI extends \JKingWeb\Arsse\Test\AbstractTest { }; // 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("passwordUnet")->will($this->returnCallback($passwordChange)); + Arsse::$user->method("passwordUnset")->will($this->returnCallback($passwordClear)); $fever = \Phake::mock(FeverUser::class); - \Phake::when($fever)->unregister->thenReturnCallback($passwordChange); + \Phake::when($fever)->unregister->thenReturnCallback($passwordClear); \Phake::when($this->cli)->getFever->thenReturn($fever); $this->assertConsole($this->cli, $cmd, $exitStatus, $output); } From acb3973149db12eeb95ad207d2f476ab37a9e148 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Tue, 26 Mar 2019 08:53:26 -0400 Subject: [PATCH 066/142] Prototype implementation of Fever groups and feeds --- lib/Database.php | 2 +- lib/REST/Fever/API.php | 65 ++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 64 insertions(+), 3 deletions(-) diff --git a/lib/Database.php b/lib/Database.php index bfbcfee1..86bb8b35 100644 --- a/lib/Database.php +++ b/lib/Database.php @@ -748,7 +748,7 @@ class Database { $q = new Query( "SELECT arsse_subscriptions.id as id, - arsse_subscriptions.feed, + arsse_subscriptions.feed as feed, url,favicon,source,folder,pinned,err_count,err_msg,order_type,added, arsse_feeds.updated as updated, topmost.top as top_folder, diff --git a/lib/REST/Fever/API.php b/lib/REST/Fever/API.php index c4ad36b4..0d24337a 100644 --- a/lib/REST/Fever/API.php +++ b/lib/REST/Fever/API.php @@ -12,9 +12,9 @@ use JKingWeb\Arsse\User; use JKingWeb\Arsse\Service; use JKingWeb\Arsse\Context\Context; use JKingWeb\Arsse\Misc\ValueInfo; +use JKingWeb\Arsse\Misc\Date; use JKingWeb\Arsse\AbstractException; use JKingWeb\Arsse\Db\ExceptionInput; -use JKingWeb\Arsse\Feed\Exception as FeedException; use JKingWeb\Arsse\REST\Target; use JKingWeb\Arsse\REST\Exception404; use JKingWeb\Arsse\REST\Exception405; @@ -65,7 +65,19 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { return $this->formatResponse($out, $xml); } // handle each possible parameter - # do stuff + if (array_key_exists("feeds", $inR) || array_key_exists("groups", $inR)) { + $groupData = (array) Arsse::$db->tagSummarize(Arsse::$user->id); + if (array_key_exists("groups", $inR)) { + $out['groups'] = $this->getGroups($groupData); + } + if (array_key_exists("feeds", $inR)) { + $out['feeds'] = $this->getFeeds(); + } + $out['feeds_groups'] = $this->getRelationships($groupData); + } + if (array_key_exists("favicons", $inR)) { + # deal with favicons + } // return the result return $this->formatResponse($out, $xml); break; @@ -97,4 +109,53 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { Arsse::$user->id = $s['user']; return true; } + + protected function getFeeds(): array { + $out = []; + foreach (arsse::$db->subscriptionList(Arsse::$user->id) as $sub) { + $out[] = [ + 'id' => (int) $sub['id'], + 'favicon_id' => (int) ($sub['favicon'] ? $sub['feed'] : 0), + 'title' => (string) $sub['title'], + 'url' => $sub['url'], + 'site_url' => $sub['source'], + 'is_spark' => 0, + 'lat_updated_on_time' => Date::transform($sub['updated'], "unix"), + ]; + } + return $out; + } + + protected function getGroups(array $data): array { + $out = []; + $seen = []; + foreach ($data as $member) { + if (!($seen[$member['id']] ?? false)) { + $seen[$member['id']] = true; + $out[] = [ + 'id' => (int) $member['id'], + 'title' => $member['name'], + ]; + } + } + return $out; + } + + protected function getRelationships(array $data): array { + $out = []; + $sets = []; + foreach ($data as $member) { + if (!isset($sets[$member['id']])) { + $sets[$member['id']] = []; + } + $sets[$member['id']][] = (int) $member['subscription']; + } + foreach ($sets as $id => $subs) { + $out[] = [ + 'group_id' => (int) $id, + 'feed_ids' => implode(",", $subs), + ]; + } + return $out; + } } From d8407330a0c478b4add975555492934747355f8b Mon Sep 17 00:00:00 2001 From: "J. King" Date: Tue, 26 Mar 2019 16:51:44 -0400 Subject: [PATCH 067/142] Add a function to get when feeds were last updated This is an optimization for Fever, which returns this information with every API call. --- lib/Database.php | 20 ++++++++++++++++- tests/cases/Database/SeriesSubscription.php | 24 ++++++++++++++++++--- 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/lib/Database.php b/lib/Database.php index 86bb8b35..24869078 100644 --- a/lib/Database.php +++ b/lib/Database.php @@ -10,7 +10,6 @@ use JKingWeb\DrUUID\UUID; use JKingWeb\Arsse\Db\Statement; use JKingWeb\Arsse\Misc\Query; use JKingWeb\Arsse\Context\Context; -use JKingWeb\Arsse\Context\ExclusionContext; use JKingWeb\Arsse\Misc\Date; use JKingWeb\Arsse\Misc\ValueInfo; @@ -751,6 +750,8 @@ class Database { 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, (articles - marked) as unread @@ -946,6 +947,23 @@ class Database { return (string) $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues())->getValue(); } + /** 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) { + 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) { + $q->setWhere("arsse_subscriptions.id = ?", "int", $id); + } + $out = $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues())->getValue(); + 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"); + } + /** 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/tests/cases/Database/SeriesSubscription.php b/tests/cases/Database/SeriesSubscription.php index 9756a281..d65fb3eb 100644 --- a/tests/cases/Database/SeriesSubscription.php +++ b/tests/cases/Database/SeriesSubscription.php @@ -47,6 +47,7 @@ trait SeriesSubscription { 'title' => "str", 'username' => "str", 'password' => "str", + 'updated' => "datetime", 'next_fetch' => "datetime", 'favicon' => "str", ], @@ -134,9 +135,9 @@ trait SeriesSubscription { ], ]; $this->data['arsse_feeds']['rows'] = [ - [1,"http://example.com/feed1", "Ook", "", "",strtotime("now"),''], - [2,"http://example.com/feed2", "eek", "", "",strtotime("now - 1 hour"),'http://example.com/favicon.ico'], - [3,"http://example.com/feed3", "Ack", "", "",strtotime("now + 1 hour"),''], + [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"),''], ]; // initialize a partial mock of the Database object to later manipulate the feedUpdate method Arsse::$db = Phake::partialMock(Database::class, static::$drv); @@ -491,4 +492,21 @@ trait SeriesSubscription { $this->assertException("notAuthorized", "User", "ExceptionAuthz"); Arsse::$db->subscriptionTagsGet("john.doe@example.com", 1); } + + public function testGetRefreshTimeOfASubscription() { + $user = "john.doe@example.com"; + $this->assertTime(strtotime("now + 1 hour"), Arsse::$db->subscriptionRefreshed($user)); + $this->assertTime(strtotime("now - 1 hour"), Arsse::$db->subscriptionRefreshed($user, 1)); + } + + public function testGetRefreshTimeOfAMissingSubscription() { + $this->assertException("subjectMissing", "Db", "ExceptionInput"); + $this->assertTime(strtotime("now - 1 hour"), Arsse::$db->subscriptionRefreshed("john.doe@example.com", 2)); + } + + public function testGetRefreshTimeOfASubscriptionWithoutAuthority() { + 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")); + } } From 7faec3b0db504a530e0205d6cad30b779931c1af Mon Sep 17 00:00:00 2001 From: "J. King" Date: Wed, 27 Mar 2019 11:54:47 -0400 Subject: [PATCH 068/142] Fever fixes - Ensure the last refresh time is included in authenticated requests - Use a partial mock in auth tests so that other processing does not get in the way of results - Make sure the group list includes unused groups - Make sure the update time of subscriptions is correct --- lib/REST/Fever/API.php | 57 +++++++++++++++--------------- tests/cases/REST/Fever/TestAPI.php | 15 +++++--- 2 files changed, 39 insertions(+), 33 deletions(-) diff --git a/lib/REST/Fever/API.php b/lib/REST/Fever/API.php index 0d24337a..c5a93a32 100644 --- a/lib/REST/Fever/API.php +++ b/lib/REST/Fever/API.php @@ -60,23 +60,9 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { // check that the user specified credentials if ($this->logIn(strtolower($inW['api_key'] ?? ""))) { $out['auth'] = 1; + $out = $this->processRequest($out, $inR, $inW); } else { $out['auth'] = 0; - return $this->formatResponse($out, $xml); - } - // handle each possible parameter - if (array_key_exists("feeds", $inR) || array_key_exists("groups", $inR)) { - $groupData = (array) Arsse::$db->tagSummarize(Arsse::$user->id); - if (array_key_exists("groups", $inR)) { - $out['groups'] = $this->getGroups($groupData); - } - if (array_key_exists("feeds", $inR)) { - $out['feeds'] = $this->getFeeds(); - } - $out['feeds_groups'] = $this->getRelationships($groupData); - } - if (array_key_exists("favicons", $inR)) { - # deal with favicons } // return the result return $this->formatResponse($out, $xml); @@ -86,6 +72,25 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { } } + protected function processRequest(array $out, array $G, array $P): array { + // add base metadata + $out['last_refreshed_on_time'] = Date::transform(Arsse::$db->subscriptionRefreshed(Arsse::$user->id), "unix"); + // handle each possible parameter + if (array_key_exists("feeds", $G) || array_key_exists("groups", $G)) { + if (array_key_exists("groups", $G)) { + $out['groups'] = $this->getGroups(); + } + if (array_key_exists("feeds", $G)) { + $out['feeds'] = $this->getFeeds(); + } + $out['feeds_groups'] = $this->getRelationships(); + } + if (array_key_exists("favicons", $G)) { + # deal with favicons + } + return $out; + } + protected function formatResponse(array $data, bool $xml): ResponseInterface { if ($xml) { throw \Exception("Not implemented yet"); @@ -120,31 +125,27 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { 'url' => $sub['url'], 'site_url' => $sub['source'], 'is_spark' => 0, - 'lat_updated_on_time' => Date::transform($sub['updated'], "unix"), + 'lat_updated_on_time' => Date::transform($sub['edited'], "unix", "sql"), ]; } return $out; } - protected function getGroups(array $data): array { + protected function getGroups(): array { $out = []; - $seen = []; - foreach ($data as $member) { - if (!($seen[$member['id']] ?? false)) { - $seen[$member['id']] = true; - $out[] = [ - 'id' => (int) $member['id'], - 'title' => $member['name'], - ]; - } + foreach (Arsse::$db->tagList(Arsse::$user->id) as $member) { + $out[] = [ + 'id' => (int) $member['id'], + 'title' => $member['name'], + ]; } return $out; } - protected function getRelationships(array $data): array { + protected function getRelationships(): array { $out = []; $sets = []; - foreach ($data as $member) { + foreach (Arsse::$db->tagSummarize(Arsse::$user->id) as $member) { if (!isset($sets[$member['id']])) { $sets[$member['id']] = []; } diff --git a/tests/cases/REST/Fever/TestAPI.php b/tests/cases/REST/Fever/TestAPI.php index c76d567f..be347125 100644 --- a/tests/cases/REST/Fever/TestAPI.php +++ b/tests/cases/REST/Fever/TestAPI.php @@ -23,7 +23,6 @@ use Psr\Http\Message\ResponseInterface; use Zend\Diactoros\ServerRequest; use Zend\Diactoros\Response\JsonResponse; use Zend\Diactoros\Response\EmptyResponse; -use Phake; /** @covers \JKingWeb\Arsse\REST\Fever\API */ class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest { @@ -66,12 +65,13 @@ class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest { self::clearData(); self::setConf(); // create a mock user manager - Arsse::$user = Phake::mock(User::class); - Phake::when(Arsse::$user)->auth->thenReturn(true); + Arsse::$user = \Phake::mock(User::class); + \Phake::when(Arsse::$user)->auth->thenReturn(true); Arsse::$user->id = "john.doe@example.com"; // create a mock database interface - Arsse::$db = Phake::mock(Database::class); - Phake::when(Arsse::$db)->begin->thenReturn(Phake::mock(Transaction::class)); + Arsse::$db = \Phake::mock(Database::class); + \Phake::when(Arsse::$db)->begin->thenReturn(\Phake::mock(Transaction::class)); + \Phake::when(Arsse::$db)->tokenLookup->thenReturn(['user' => "john.doe@example.com"]); // instantiate the handler $this->h = new API(); } @@ -89,6 +89,11 @@ class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest { Arsse::$user->id = null; \Phake::when(Arsse::$db)->tokenLookup->thenThrow(new ExceptionInput("subjectMissing")); \Phake::when(Arsse::$db)->tokenLookup("fever.login", "validtoken")->thenReturn(['user' => "jane.doe@example.com"]); + // use a partial mock to test only the authentication process + $this->h = \Phake::partialMock(API::class); + \Phake::when($this->h)->processRequest->thenReturnCallback(function($out, $G, $P) { + return $out; + }); $act = $this->req($dataGet, $dataPost, "POST", null, "", $httpUser); $this->assertMessage($exp, $act); } From de615c671ad60c79073a18d9275484bf86e16cbc Mon Sep 17 00:00:00 2001 From: "J. King" Date: Wed, 27 Mar 2019 15:09:04 -0400 Subject: [PATCH 069/142] Tests and fixed for Fever feeds and groups --- lib/REST/Fever/API.php | 46 +++++++++++------- tests/cases/REST/Fever/TestAPI.php | 78 +++++++++++++++++++++++++----- 2 files changed, 96 insertions(+), 28 deletions(-) diff --git a/lib/REST/Fever/API.php b/lib/REST/Fever/API.php index c5a93a32..47b4038f 100644 --- a/lib/REST/Fever/API.php +++ b/lib/REST/Fever/API.php @@ -30,8 +30,8 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { } public function dispatch(ServerRequestInterface $req): ResponseInterface { - $inR = $req->getQueryParams(); - $inW = $req->getParsedBody(); + $inR = $req->getQueryParams() ?? []; + $inW = $req->getParsedBody() ?? []; if (!array_key_exists("api", $inR)) { // the original would have shown the Fever UI in the absence of the "api" parameter, but we'll return 404 return new EmptyResponse(404); @@ -57,14 +57,13 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { // otherwise if HTTP authentication failed or is required, deny access at the HTTP level return new EmptyResponse(401); } - // check that the user specified credentials + // produce a full response if authenticated or a basic response otherwise if ($this->logIn(strtolower($inW['api_key'] ?? ""))) { - $out['auth'] = 1; - $out = $this->processRequest($out, $inR, $inW); + $out = $this->processRequest($this->baseResponse(true), $inR, $inW); } else { - $out['auth'] = 0; + $out = $this->baseResponse(false); } - // return the result + // return the result, possibly formatted as XML return $this->formatResponse($out, $xml); break; default: @@ -73,9 +72,6 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { } protected function processRequest(array $out, array $G, array $P): array { - // add base metadata - $out['last_refreshed_on_time'] = Date::transform(Arsse::$db->subscriptionRefreshed(Arsse::$user->id), "unix"); - // handle each possible parameter if (array_key_exists("feeds", $G) || array_key_exists("groups", $G)) { if (array_key_exists("groups", $G)) { $out['groups'] = $this->getGroups(); @@ -91,6 +87,18 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { return $out; } + protected function baseResponse(bool $authenticated): array { + $out = [ + 'api_version' => self::LEVEL, + 'auth' => (int) $authenticated, + ]; + if ($authenticated) { + // authenticated requests always include the most recent feed refresh + $out['last_refreshed_on_time'] = $this->getRefreshTime(); + } + return $out; + } + protected function formatResponse(array $data, bool $xml): ResponseInterface { if ($xml) { throw \Exception("Not implemented yet"); @@ -115,17 +123,21 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { return true; } + protected function getRefreshTime() { + return Date::transform(Arsse::$db->subscriptionRefreshed(Arsse::$user->id), "unix"); + } + protected function getFeeds(): array { $out = []; foreach (arsse::$db->subscriptionList(Arsse::$user->id) as $sub) { $out[] = [ - 'id' => (int) $sub['id'], - 'favicon_id' => (int) ($sub['favicon'] ? $sub['feed'] : 0), - 'title' => (string) $sub['title'], - 'url' => $sub['url'], - 'site_url' => $sub['source'], - 'is_spark' => 0, - 'lat_updated_on_time' => Date::transform($sub['edited'], "unix", "sql"), + 'id' => (int) $sub['id'], + 'favicon_id' => (int) ($sub['favicon'] ? $sub['feed'] : 0), + 'title' => (string) $sub['title'], + 'url' => $sub['url'], + 'site_url' => $sub['source'], + 'is_spark' => 0, + 'last_updated_on_time' => Date::transform($sub['edited'], "unix", "sql"), ]; } return $out; diff --git a/tests/cases/REST/Fever/TestAPI.php b/tests/cases/REST/Fever/TestAPI.php index be347125..272a25fc 100644 --- a/tests/cases/REST/Fever/TestAPI.php +++ b/tests/cases/REST/Fever/TestAPI.php @@ -31,7 +31,7 @@ class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest { return $value; } - protected function req($dataGet, $dataPost, string $method = "POST", string $type = null, string $url = "", string $user = null): ResponseInterface { + protected function req($dataGet, $dataPost = "", string $method = "POST", string $type = null, string $url = "", string $user = null): ResponseInterface { $url = "/fever/".$url; $server = [ 'REQUEST_METHOD' => $method, @@ -39,11 +39,10 @@ class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest { 'HTTP_CONTENT_TYPE' => $type ?? "application/x-www-form-urlencoded", ]; $req = new ServerRequest($server, [], $url, $method, "php://memory"); - if (is_array($dataGet)) { - $req = $req->withRequestTarget($url)->withQueryParams($dataGet); - } else { - $req = $req->withRequestTarget($url."?".http_build_query((string) $dataGet, "", "&", \PHP_QUERY_RFC3986)); + if (!is_array($dataGet)) { + parse_str($dataGet, $dataGet); } + $req = $req->withRequestTarget($url)->withQueryParams($dataGet); if (is_array($dataPost)) { $req = $req->withParsedBody($dataPost); } else { @@ -72,8 +71,9 @@ class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest { Arsse::$db = \Phake::mock(Database::class); \Phake::when(Arsse::$db)->begin->thenReturn(\Phake::mock(Transaction::class)); \Phake::when(Arsse::$db)->tokenLookup->thenReturn(['user' => "john.doe@example.com"]); - // instantiate the handler - $this->h = new API(); + // instantiate the handler as a partial mock to simplify testing + $this->h = \Phake::partialMock(API::class); + \Phake::when($this->h)->baseResponse->thenReturn([]); } public function tearDown() { @@ -89,8 +89,10 @@ class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest { Arsse::$user->id = null; \Phake::when(Arsse::$db)->tokenLookup->thenThrow(new ExceptionInput("subjectMissing")); \Phake::when(Arsse::$db)->tokenLookup("fever.login", "validtoken")->thenReturn(['user' => "jane.doe@example.com"]); - // use a partial mock to test only the authentication process - $this->h = \Phake::partialMock(API::class); + // test only the authentication process + \Phake::when($this->h)->baseResponse->thenReturnCallback(function(bool $authenticated) { + return ['auth' => (int) $authenticated]; + }); \Phake::when($this->h)->processRequest->thenReturnCallback(function($out, $G, $P) { return $out; }); @@ -99,8 +101,8 @@ class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest { } public function provideTokenAuthenticationRequests() { - $success = new JsonResponse(['api_version' => API::LEVEL, 'auth' => 1]); - $failure = new JsonResponse(['api_version' => API::LEVEL, 'auth' => 0]); + $success = new JsonResponse(['auth' => 1]); + $failure = new JsonResponse(['auth' => 0]); $denied = new EmptyResponse(401); return [ [false, true, null, [], ['api' => null], $failure], @@ -149,4 +151,58 @@ class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest { [true, false, "validUser", ['api_key' => "invalidToken"], ['api' => null], $success], ]; } + + public function testListGroups() { + \Phake::when(Arsse::$db)->tagList(Arsse::$user->id)->thenReturn(new Result([ + ['id' => 1, 'name' => "Fascinating", 'subscriptions' => 2], + ['id' => 2, 'name' => "Interesting", 'subscriptions' => 2], + ['id' => 3, 'name' => "Boring", 'subscriptions' => 0], + ])); + \Phake::when(Arsse::$db)->tagSummarize(Arsse::$user->id)->thenReturn(new Result([ + ['id' => 1, 'name' => "Fascinating", 'subscription' => 1], + ['id' => 1, 'name' => "Fascinating", 'subscription' => 2], + ['id' => 2, 'name' => "Interesting", 'subscription' => 1], + ['id' => 2, 'name' => "Interesting", 'subscription' => 3], + ])); + $exp = new JsonResponse([ + 'groups' => [ + ['id' => 1, 'title' => "Fascinating"], + ['id' => 2, 'title' => "Interesting"], + ['id' => 3, 'title' => "Boring"], + ], + 'feeds_groups' => [ + ['group_id' => 1, 'feed_ids' => "1,2"], + ['group_id' => 2, 'feed_ids' => "1,3"], + ], + ]); + $act = $this->req("api&groups"); + $this->assertMessage($exp, $act); + } + + public function testListFeeds() { + \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"], + ])); + \Phake::when(Arsse::$db)->tagSummarize(Arsse::$user->id)->thenReturn(new Result([ + ['id' => 1, 'name' => "Fascinating", 'subscription' => 1], + ['id' => 1, 'name' => "Fascinating", 'subscription' => 2], + ['id' => 2, 'name' => "Interesting", 'subscription' => 1], + ['id' => 2, 'name' => "Interesting", 'subscription' => 3], + ])); + $exp = new JsonResponse([ + 'feeds' => [ + ['id' => 1, 'favicon_id' => 5, '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' => 1, '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"], + ['group_id' => 2, 'feed_ids' => "1,3"], + ], + ]); + $act = $this->req("api&feeds"); + $this->assertMessage($exp, $act); + } } From 5d994f3dadad6d26134678afd919de25a3837454 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Thu, 28 Mar 2019 14:54:31 -0400 Subject: [PATCH 070/142] Normalize Fever input consistently Two parameters are undocumented, but other implementations consistently accept them from clients --- lib/REST/Fever/API.php | 80 +++++++++++++++++++++++++++++++++++------- 1 file changed, 67 insertions(+), 13 deletions(-) diff --git a/lib/REST/Fever/API.php b/lib/REST/Fever/API.php index 47b4038f..5dcb9b08 100644 --- a/lib/REST/Fever/API.php +++ b/lib/REST/Fever/API.php @@ -11,7 +11,7 @@ use JKingWeb\Arsse\Database; use JKingWeb\Arsse\User; use JKingWeb\Arsse\Service; use JKingWeb\Arsse\Context\Context; -use JKingWeb\Arsse\Misc\ValueInfo; +use JKingWeb\Arsse\Misc\ValueInfo as V; use JKingWeb\Arsse\Misc\Date; use JKingWeb\Arsse\AbstractException; use JKingWeb\Arsse\Db\ExceptionInput; @@ -26,17 +26,40 @@ use Zend\Diactoros\Response\EmptyResponse; class API extends \JKingWeb\Arsse\REST\AbstractHandler { const LEVEL = 3; + // GET parameters for which we only check presence: these will be converted to booleans + const PARAM_BOOL = ["groups", "feeds", "items", "favicons", "links", "unread_item_ids", "saved_item_ids"]; + // GET parameters which contain meaningful values + const PARAM_GET = [ + 'api' => V::T_STRING, // this parameter requires special handling + 'page' => V::T_INT, // parameter for hot links + 'range' => V::T_INT, // parameter for hot links + 'offset' => V::T_INT, // parameter for hot links + 'since_id' => V::T_INT, + 'max_id' => V::T_INT, + 'with_ids' => V::T_STRING, + 'group_ids' => V::T_STRING, // undocumented parameter for 'items' lookup + 'feed_ids' => V::T_STRING, // undocumented parameter for 'items' lookup + ]; + // POST parameters, all of which contain meaningful values + const PARAM_POST = [ + 'api_key' => V::T_STRING, + 'mark' => V::T_STRING, + 'as' => V::T_STRING, + 'id' => V::T_INT, + 'before' => V::T_DATE, + 'unread_recently_read' => V::T_BOOL, + ]; + public function __construct() { } public function dispatch(ServerRequestInterface $req): ResponseInterface { - $inR = $req->getQueryParams() ?? []; - $inW = $req->getParsedBody() ?? []; - if (!array_key_exists("api", $inR)) { + $G = $this->normalizeInputGet($req->getQueryParams() ?? []); + $P = $this->normalizeInputPost($req->getParsedBody() ?? []); + if (!isset($G['api'])) { // the original would have shown the Fever UI in the absence of the "api" parameter, but we'll return 404 return new EmptyResponse(404); } - $xml = $inR['api'] === "xml"; switch ($req->getMethod()) { case "OPTIONS": // do stuff @@ -58,32 +81,63 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { return new EmptyResponse(401); } // produce a full response if authenticated or a basic response otherwise - if ($this->logIn(strtolower($inW['api_key'] ?? ""))) { - $out = $this->processRequest($this->baseResponse(true), $inR, $inW); + if ($this->logIn(strtolower($P['api_key'] ?? ""))) { + $out = $this->processRequest($this->baseResponse(true), $G, $P); } else { $out = $this->baseResponse(false); } // return the result, possibly formatted as XML - return $this->formatResponse($out, $xml); - break; + return $this->formatResponse($out, ($G['api'] === "xml")); default: return new EmptyResponse(405, ['Allow' => "OPTIONS,POST"]); } } + protected function normalizeInputGet(array $data): array { + $out = []; + if (array_key_exists("api", $data)) { + // the "api" parameter must be handled specially as it a string, but null has special meaning + $data['api'] = $data['api'] ?? "json"; + } + foreach (self::PARAM_BOOL as $p) { + // first handle all the boolean parameters + $out[$p] = array_key_exists($p, $data); + } + foreach (self::PARAM_GET as $p => $t) { + $out[$p] = V::normalize($data[$p] ?? null, $t | V::M_DROP, "unix"); + } + return $out; + } + + protected function normalizeInputPost(array $data): array { + $out = []; + foreach (self::PARAM_POST as $p => $t) { + $out[$p] = V::normalize($data[$p] ?? null, $t | V::M_DROP, "unix"); + } + return $out; + } + protected function processRequest(array $out, array $G, array $P): array { - if (array_key_exists("feeds", $G) || array_key_exists("groups", $G)) { - if (array_key_exists("groups", $G)) { + if ($G['feeds'] || $G['groups']) { + if ($G['groups']) { $out['groups'] = $this->getGroups(); } - if (array_key_exists("feeds", $G)) { + if ($G['feeds']) { $out['feeds'] = $this->getFeeds(); } $out['feeds_groups'] = $this->getRelationships(); } - if (array_key_exists("favicons", $G)) { + if ($G['favicons']) { # deal with favicons } + if ($G['items']) { + $out['items'] = $this->getItems($G); + $out['total_items'] = Arsse::$db->articleCount(Arsse::$user->id); + } + if ($G['links']) { + // TODO: implement hot links + $out['inks'] = []; + } return $out; } From 25b7b47e0a9ca1448582aad7ea2d7ea03bd15699 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Thu, 28 Mar 2019 21:53:04 -0400 Subject: [PATCH 071/142] Prototype OPML exporter --- lib/ImportExport/OPML.php | 59 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 lib/ImportExport/OPML.php diff --git a/lib/ImportExport/OPML.php b/lib/ImportExport/OPML.php new file mode 100644 index 00000000..e3053a01 --- /dev/null +++ b/lib/ImportExport/OPML.php @@ -0,0 +1,59 @@ + null]; + $tags = []; + $document = new \DOMDocument("1.0", "utf-8"); + $document->formatOutput = true; + $document->appendChild($document->createElement("opml")); + $document->documentElement->setAttribute("version", "2.0"); + $document->documentElement->appendChild($document->createElement("head")); + // create the "root folder" node (the body node, in OPML terms) + $folders[0] = $document->createElement("body"); + $transaction = Arsse::$db->begin(); + foreach (Arsse::$db->tagSummarize($user) as $r) { + $sub = $r['subscription']; + $tag = $r['name']; + $tag = str_replace(",", "", $tag); + if (!isset($tags[$sub])) { + $tags[$sub] = []; + } + $tags[$sub][] = $tag; + } + if (!$flat) { + foreach (Arsse::$db->folderList($user) as $r) { + $parents[$r['id']] = $r['parent'] ?? 0; + $el = $document->createElement("outline"); + $el->setAttribute("text", $r['name']); + $folders[$r['id']] = $el; + } + } + foreach (Arsse::$db->subscriptionList($user) as $r) { + $el = $document->createElement(("outline")); + $el->setAttribute("text", $r['title']); + $el->setAttribute("type", "rss"); + $el->setAttribute("xmlUrl", $r['url']); + if (sizeof($tags[$r['id']])) { + $el->setAttribute("category", implode(",", $tags[$r['id']])); + } + ($folders[$r['folder'] ?? 0] ?? $folders[0])->appendChild($el); + } + $transaction->rollback(); + foreach ($folders as $id => $el) { + $parent = $parents[$id] ?? $document->documentElement; + $parent->appendChild($el); + } + // return the serialization + return $document->saveXML(); + } +} From d63edf541f77a736715039ec6689b01193993c7b Mon Sep 17 00:00:00 2001 From: "J. King" Date: Fri, 29 Mar 2019 09:02:39 -0400 Subject: [PATCH 072/142] Insert folders into OPML before subscriptions --- lib/ImportExport/OPML.php | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/lib/ImportExport/OPML.php b/lib/ImportExport/OPML.php index e3053a01..032d7532 100644 --- a/lib/ImportExport/OPML.php +++ b/lib/ImportExport/OPML.php @@ -10,9 +10,10 @@ use JKingWeb\Arsse\Arsse; class OPML { public function export(string $user, bool $flat = false): string { + $tags = []; $folders = []; $parents = [0 => null]; - $tags = []; + // create a base document $document = new \DOMDocument("1.0", "utf-8"); $document->formatOutput = true; $document->appendChild($document->createElement("opml")); @@ -20,10 +21,13 @@ class OPML { $document->documentElement->appendChild($document->createElement("head")); // create the "root folder" node (the body node, in OPML terms) $folders[0] = $document->createElement("body"); + // begin a transaction for read isolation $transaction = Arsse::$db->begin(); + // gather up the list of tags for each subscription foreach (Arsse::$db->tagSummarize($user) as $r) { $sub = $r['subscription']; $tag = $r['name']; + // strip out any commas in the tag name; sadly this is lossy as OPML has no escape mechanism $tag = str_replace(",", "", $tag); if (!isset($tags[$sub])) { $tags[$sub] = []; @@ -31,28 +35,36 @@ class OPML { $tags[$sub][] = $tag; } if (!$flat) { + // unless the output is requested flat, gather up the list of folders, using their database IDs as array indices foreach (Arsse::$db->folderList($user) as $r) { + // note the index of its parent folder for later tree construction $parents[$r['id']] = $r['parent'] ?? 0; + // create a DOM node for each folder; we don't insert it yet $el = $document->createElement("outline"); $el->setAttribute("text", $r['name']); $folders[$r['id']] = $el; } + // insert each folder into its parent node; for the root folder the parent is the document root node + foreach ($folders as $id => $el) { + $parent = $parents[$id] ?? $document->documentElement; + $parent->appendChild($el); + } } + // create a DOM node for each subscription and insert them directly into their folder DOM node foreach (Arsse::$db->subscriptionList($user) as $r) { $el = $document->createElement(("outline")); $el->setAttribute("text", $r['title']); $el->setAttribute("type", "rss"); $el->setAttribute("xmlUrl", $r['url']); + // include the category attribute only if there are tags if (sizeof($tags[$r['id']])) { $el->setAttribute("category", implode(",", $tags[$r['id']])); } + // if flat output was requested subscriptions are inserted into the root folder ($folders[$r['folder'] ?? 0] ?? $folders[0])->appendChild($el); } + // release the transaction $transaction->rollback(); - foreach ($folders as $id => $el) { - $parent = $parents[$id] ?? $document->documentElement; - $parent->appendChild($el); - } // return the serialization return $document->saveXML(); } From 17fd9093352fc9bd72a78f9615c8db9cfdb3fde6 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Fri, 29 Mar 2019 10:15:30 -0400 Subject: [PATCH 073/142] Add DOM extension as a direct dependency Previously it was already a dependency of PicoFeed, so there's effectively no change --- README.md | 4 ++-- composer.json | 1 + composer.lock | 17 +++++++++-------- vendor-bin/csfixer/composer.lock | 10 +++++----- vendor-bin/phpunit/composer.lock | 10 +++++----- 5 files changed, 22 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index ab7dc2ed..08dab43e 100644 --- a/README.md +++ b/README.md @@ -14,8 +14,8 @@ The Arsse has the following requirements: - A Linux server utilizing systemd and Nginx (tested on Ubuntu 16.04) - PHP 7.0.7 or later with the following extensions: - - [intl](http://php.net/manual/en/book.intl.php), [json](http://php.net/manual/en/book.json.php), [hash](http://php.net/manual/en/book.hash.php), and [pcre](http://php.net/manual/en/book.pcre.php) - - [dom](http://php.net/manual/en/book.dom.php), [simplexml](http://php.net/manual/en/book.simplexml.php), and [iconv](http://php.net/manual/en/book.iconv.php) (for picoFeed) + - [intl](http://php.net/manual/en/book.intl.php), [json](http://php.net/manual/en/book.json.php), [hash](http://php.net/manual/en/book.hash.php), and [dom](http://php.net/manual/en/book.dom.php) + - [simplexml](http://php.net/manual/en/book.simplexml.php), and [iconv](http://php.net/manual/en/book.iconv.php) (for picoFeed) - One of: - [sqlite3](http://php.net/manual/en/book.sqlite3.php) or [pdo_sqlite](http://php.net/manual/en/ref.pdo-sqlite.php) for SQLite databases - [pgsql](http://php.net/manual/en/book.pgsql.php) or [pdo_pgsql](http://php.net/manual/en/ref.pdo-pgsql.php) for PostgreSQL 10 or later databases diff --git a/composer.json b/composer.json index 0f5570c3..0669d657 100644 --- a/composer.json +++ b/composer.json @@ -22,6 +22,7 @@ "ext-intl": "*", "ext-json": "*", "ext-hash": "*", + "ext-dom": "*", "p3k/picofeed": "0.1.*", "hosteurope/password-generator": "^1.0", "docopt/docopt": "^1.0", diff --git a/composer.lock b/composer.lock index b5e5d385..986e47cc 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "d7a6a00be3d97c11d09ec4d4e56d36e0", + "content-hash": "f61a02cd168914d91847b89dcd00d464", "packages": [ { "name": "docopt/docopt", @@ -354,16 +354,16 @@ "packages-dev": [ { "name": "bamarni/composer-bin-plugin", - "version": "v1.2.0", + "version": "v1.3.0", "source": { "type": "git", "url": "https://github.com/bamarni/composer-bin-plugin.git", - "reference": "62fef740245a85f00665e81ea8f0aa0b72afe6e7" + "reference": "67f9d314dc7ecf7245b8637906e151ccc62b8d24" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/bamarni/composer-bin-plugin/zipball/62fef740245a85f00665e81ea8f0aa0b72afe6e7", - "reference": "62fef740245a85f00665e81ea8f0aa0b72afe6e7", + "url": "https://api.github.com/repos/bamarni/composer-bin-plugin/zipball/67f9d314dc7ecf7245b8637906e151ccc62b8d24", + "reference": "67f9d314dc7ecf7245b8637906e151ccc62b8d24", "shasum": "" }, "require": { @@ -371,7 +371,7 @@ }, "require-dev": { "composer/composer": "dev-master", - "symfony/console": "^2.5 || ^3.0" + "symfony/console": "^2.5 || ^3.0 || ^4.0" }, "type": "composer-plugin", "extra": { @@ -389,7 +389,7 @@ "license": [ "MIT" ], - "time": "2017-09-11T13:13:58+00:00" + "time": "2019-03-17T12:38:04+00:00" } ], "aliases": [], @@ -401,7 +401,8 @@ "php": "^7.0", "ext-intl": "*", "ext-json": "*", - "ext-hash": "*" + "ext-hash": "*", + "ext-dom": "*" }, "platform-dev": [] } diff --git a/vendor-bin/csfixer/composer.lock b/vendor-bin/csfixer/composer.lock index 155169e4..e48cd3d7 100644 --- a/vendor-bin/csfixer/composer.lock +++ b/vendor-bin/csfixer/composer.lock @@ -114,16 +114,16 @@ }, { "name": "doctrine/annotations", - "version": "v1.6.0", + "version": "v1.6.1", "source": { "type": "git", "url": "https://github.com/doctrine/annotations.git", - "reference": "c7f2050c68a9ab0bdb0f98567ec08d80ea7d24d5" + "reference": "53120e0eb10355388d6ccbe462f1fea34ddadb24" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/annotations/zipball/c7f2050c68a9ab0bdb0f98567ec08d80ea7d24d5", - "reference": "c7f2050c68a9ab0bdb0f98567ec08d80ea7d24d5", + "url": "https://api.github.com/repos/doctrine/annotations/zipball/53120e0eb10355388d6ccbe462f1fea34ddadb24", + "reference": "53120e0eb10355388d6ccbe462f1fea34ddadb24", "shasum": "" }, "require": { @@ -178,7 +178,7 @@ "docblock", "parser" ], - "time": "2017-12-06T07:11:42+00:00" + "time": "2019-03-25T19:12:02+00:00" }, { "name": "doctrine/lexer", diff --git a/vendor-bin/phpunit/composer.lock b/vendor-bin/phpunit/composer.lock index 016ad853..32cf6449 100644 --- a/vendor-bin/phpunit/composer.lock +++ b/vendor-bin/phpunit/composer.lock @@ -835,16 +835,16 @@ }, { "name": "phpunit/phpunit", - "version": "7.5.7", + "version": "7.5.8", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "eb343b86753d26de07ecba7868fa983104361948" + "reference": "c29c0525cf4572c11efe1db49a8b8aee9dfac58a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/eb343b86753d26de07ecba7868fa983104361948", - "reference": "eb343b86753d26de07ecba7868fa983104361948", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/c29c0525cf4572c11efe1db49a8b8aee9dfac58a", + "reference": "c29c0525cf4572c11efe1db49a8b8aee9dfac58a", "shasum": "" }, "require": { @@ -915,7 +915,7 @@ "testing", "xunit" ], - "time": "2019-03-16T07:31:17+00:00" + "time": "2019-03-26T13:23:54+00:00" }, { "name": "sebastian/code-unit-reverse-lookup", From 35e79d53a9f3a9bfa7a1df959d8aa13fd6051258 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Sat, 30 Mar 2019 10:01:12 -0400 Subject: [PATCH 074/142] OPML export fixes, with tests --- lib/ImportExport/OPML.php | 14 ++-- tests/cases/ImportExport/TestOPML.php | 98 +++++++++++++++++++++++++++ tests/phpunit.xml | 3 + 3 files changed, 108 insertions(+), 7 deletions(-) create mode 100644 tests/cases/ImportExport/TestOPML.php diff --git a/lib/ImportExport/OPML.php b/lib/ImportExport/OPML.php index 032d7532..6c650c8f 100644 --- a/lib/ImportExport/OPML.php +++ b/lib/ImportExport/OPML.php @@ -44,20 +44,20 @@ class OPML { $el->setAttribute("text", $r['name']); $folders[$r['id']] = $el; } - // insert each folder into its parent node; for the root folder the parent is the document root node - foreach ($folders as $id => $el) { - $parent = $parents[$id] ?? $document->documentElement; - $parent->appendChild($el); - } + } + // insert each folder into its parent node; for the root folder the parent is the document root node + foreach ($folders as $id => $el) { + $parent = $folders[$parents[$id]] ?? $document->documentElement; + $parent->appendChild($el); } // create a DOM node for each subscription and insert them directly into their folder DOM node foreach (Arsse::$db->subscriptionList($user) as $r) { $el = $document->createElement(("outline")); - $el->setAttribute("text", $r['title']); $el->setAttribute("type", "rss"); + $el->setAttribute("text", $r['title']); $el->setAttribute("xmlUrl", $r['url']); // include the category attribute only if there are tags - if (sizeof($tags[$r['id']])) { + if (isset($tags[$r['id']]) && sizeof($tags[$r['id']])) { $el->setAttribute("category", implode(",", $tags[$r['id']])); } // if flat output was requested subscriptions are inserted into the root folder diff --git a/tests/cases/ImportExport/TestOPML.php b/tests/cases/ImportExport/TestOPML.php new file mode 100644 index 00000000..387400c2 --- /dev/null +++ b/tests/cases/ImportExport/TestOPML.php @@ -0,0 +1,98 @@ + */ +class TestOPML extends \JKingWeb\Arsse\Test\AbstractTest { + protected $folders = [ + ['id' => 5, 'parent' => 3, 'children' => 0, 'feeds' => 1, 'name' => "Local"], + ['id' => 6, 'parent' => 3, 'children' => 0, 'feeds' => 2, 'name' => "National"], + ['id' => 4, 'parent' => null, 'children' => 0, 'feeds' => 0, 'name' => "Photography"], + ['id' => 3, 'parent' => null, 'children' => 2, 'feeds' => 0, 'name' => "Politics"], + ['id' => 2, 'parent' => 1, 'children' => 0, 'feeds' => 1, 'name' => "Rocketry"], + ['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'], + ]; + protected $tags = [ + ['id' => 1, 'name' => "Canada", 'subscription' => 2], + ['id' => 1, 'name' => "Canada", 'subscription' => 4], + ['id' => 1, 'name' => "Canada", 'subscription' => 5], + ['id' => 2, 'name' => "Politics", 'subscription' => 4], + ['id' => 2, 'name' => "Politics", 'subscription' => 5], + ['id' => 3, 'name' => "Science, etc", 'subscription' => 1], + ['id' => 3, 'name' => "Science, etc", 'subscription' => 3], + // Eurogamer is untagged + ]; + protected $serialization = << + + + + + + + + + + + + + + + + + + + + + + +OPML_EXPORT_SERIALIZATION; + protected $serializationFlat = << + + + + + + + + + + + +OPML_EXPORT_SERIALIZATION; + + public function setUp() { + Arsse::$db = \Phake::mock(\JKingWeb\Arsse\Database::class); + } + + public function testExportToOpml() { + \Phake::when(Arsse::$db)->folderList("john.doe@example.com")->thenReturn(new Result($this->folders)); + \Phake::when(Arsse::$db)->subscriptionList("john.doe@example.com")->thenReturn(new Result($this->subscriptions)); + \Phake::when(Arsse::$db)->tagSummarize("john.doe@example.com")->thenReturn(new Result($this->tags)); + $this->assertXmlStringEqualsXmlString($this->serialization, (new OPML)->export("john.doe@example.com")); + } + + public function testExportToFlatOpml() { + \Phake::when(Arsse::$db)->folderList("john.doe@example.com")->thenReturn(new Result($this->folders)); + \Phake::when(Arsse::$db)->subscriptionList("john.doe@example.com")->thenReturn(new Result($this->subscriptions)); + \Phake::when(Arsse::$db)->tagSummarize("john.doe@example.com")->thenReturn(new Result($this->tags)); + $this->assertXmlStringEqualsXmlString($this->serializationFlat, (new OPML)->export("john.doe@example.com", true)); + } +} diff --git a/tests/phpunit.xml b/tests/phpunit.xml index 7c698ab7..fd5429fa 100644 --- a/tests/phpunit.xml +++ b/tests/phpunit.xml @@ -113,5 +113,8 @@ cases/Service/TestService.php cases/CLI/TestCLI.php + + cases/ImportExport/TestOPML.php + From deea294f8a70329cc4ab5ade952920af12e3c3b2 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Mon, 1 Apr 2019 16:54:14 -0400 Subject: [PATCH 075/142] Add export-to-file wrapper for OPML --- lib/AbstractException.php | 2 + lib/ImportExport/Exception.php | 10 +++ lib/ImportExport/OPML.php | 14 ++++ locale/en.php | 10 +++ tests/cases/ImportExport/TestOPML.php | 9 +++ tests/cases/ImportExport/TestOPMLFile.php | 82 +++++++++++++++++++++++ tests/phpunit.xml | 1 + 7 files changed, 128 insertions(+) create mode 100644 lib/ImportExport/Exception.php create mode 100644 tests/cases/ImportExport/TestOPMLFile.php diff --git a/lib/AbstractException.php b/lib/AbstractException.php index a524da60..e4f22a9a 100644 --- a/lib/AbstractException.php +++ b/lib/AbstractException.php @@ -86,6 +86,8 @@ abstract class AbstractException extends \Exception { "Feed/Exception.xmlEntity" => 10512, "Feed/Exception.subscriptionNotFound" => 10521, "Feed/Exception.unsupportedFeedFormat" => 10522, + "ImportExport/Exception.fileUnwritable" => 10604, + "ImportExport/Exception.fileUncreatable" => 10605, ]; public function __construct(string $msgID = "", $vars = null, \Throwable $e = null) { diff --git a/lib/ImportExport/Exception.php b/lib/ImportExport/Exception.php new file mode 100644 index 00000000..888cfcac --- /dev/null +++ b/lib/ImportExport/Exception.php @@ -0,0 +1,10 @@ +exists($user)) { + throw new UserException("doesNotExist", ["action" => __FUNCTION__, "user" => $user]); + } $tags = []; $folders = []; $parents = [0 => null]; @@ -68,4 +72,14 @@ class OPML { // return the serialization return $document->saveXML(); } + + public function exportFile(string $file, string $user, bool $flat = false): bool { + $data = $this->export($user, $flat); + if (!@file_put_contents($file, $data)) { + // if it fails throw an exception + $err = file_exists($file) ? "fileUnwritable" : "fileUncreatable"; + throw new Exception($err, ['file' => $file, 'format' => str_replace(__NAMESPACE__."\\", "", __CLASS__)]); + } + return true; + } } diff --git a/locale/en.php b/locale/en.php index ddbf1182..a9fa045f 100644 --- a/locale/en.php +++ b/locale/en.php @@ -155,4 +155,14 @@ return [ 'Exception.JKingWeb/Arsse/Feed/Exception.xmlEntity' => 'Refused to parse feed "{url}" because it contains an XXE attack', 'Exception.JKingWeb/Arsse/Feed/Exception.subscriptionNotFound' => 'Unable to find a feed at location "{url}"', 'Exception.JKingWeb/Arsse/Feed/Exception.unsupportedFeedFormat' => 'Feed "{url}" is of an unsupported format', + 'Exception.JKingWeb/Arsse/ImportExport/Exception.fileUncreatable' => + 'Insufficient permissions to write {type, select, + OPML {OPML} + other {"{type}"} + } export to file "{file}"', + 'Exception.JKingWeb/Arsse/ImportExport/Exception.fileUnwritable' => + 'Insufficient permissions to write {type, select, + OPML {OPML} + other {"{type}"} + } export to existing file "{file}"', ]; diff --git a/tests/cases/ImportExport/TestOPML.php b/tests/cases/ImportExport/TestOPML.php index 387400c2..2c8d7d29 100644 --- a/tests/cases/ImportExport/TestOPML.php +++ b/tests/cases/ImportExport/TestOPML.php @@ -79,7 +79,10 @@ OPML_EXPORT_SERIALIZATION; OPML_EXPORT_SERIALIZATION; public function setUp() { + 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); } public function testExportToOpml() { @@ -95,4 +98,10 @@ OPML_EXPORT_SERIALIZATION; \Phake::when(Arsse::$db)->tagSummarize("john.doe@example.com")->thenReturn(new Result($this->tags)); $this->assertXmlStringEqualsXmlString($this->serializationFlat, (new OPML)->export("john.doe@example.com", true)); } + + public function testExportToOpmlAMissingUser() { + \Phake::when(Arsse::$user)->exists->thenReturn(false); + $this->assertException("doesNotExist", "User"); + (new OPML)->export("john.doe@example.com"); + } } diff --git a/tests/cases/ImportExport/TestOPMLFile.php b/tests/cases/ImportExport/TestOPMLFile.php new file mode 100644 index 00000000..ecb601d1 --- /dev/null +++ b/tests/cases/ImportExport/TestOPMLFile.php @@ -0,0 +1,82 @@ + */ +class TestOPMLFile extends \JKingWeb\Arsse\Test\AbstractTest { + protected $vfs; + protected $path; + protected $opml; + + public function setUp() { + self::clearData(); + // create a mock OPML processor with stubbed underlying import/export routines + $this->opml = \Phake::partialMock(OPML::class); + \Phake::when($this->opml)->export->thenReturn("OPML_FILE"); + $this->vfs = vfsStream::setup("root", null, [ + 'exportGoodFile' => "", + 'exportGoodDir' => [], + 'exportBadFile' => "", + 'exportBadDir' => [], + ]); + $this->path = $this->vfs->url()."/"; + // make the "bad" entries inaccessible + chmod($this->path."exportBadFile", 0000); + chmod($this->path."exportBadDir", 0000); + } + + public function tearDown() { + $this->path = null; + $this->vfs = null; + $this->opml = null; + self::clearData(); + } + + /** @dataProvider provideFileExports */ + public function testExportOpmlToAFile(string $file, string $user, bool $flat, $exp) { + $path = $this->path.$file; + try { + if ($exp instanceof \JKingWeb\Arsse\AbstractException) { + $this->assertException($exp); + $this->opml->exportFile($path, $user, $flat); + } else { + $this->assertSame($exp, $this->opml->exportFile($path, $user, $flat)); + $this->assertSame("OPML_FILE", $this->vfs->getChild($file)->getContent()); + } + } finally { + \Phake::verify($this->opml)->export($user, $flat); + } + } + + public function provideFileExports() { + $createException = new Exception("fileUncreatable"); + $writeException = new Exception("fileUnwritable"); + return [ + ["exportGoodFile", "john.doe@example.com", true, true], + ["exportGoodFile", "john.doe@example.com", false, true], + ["exportGoodFile", "jane.doe@example.com", true, true], + ["exportGoodFile", "jane.doe@example.com", false, true], + ["exportGoodDir/file", "john.doe@example.com", true, true], + ["exportGoodDir/file", "john.doe@example.com", false, true], + ["exportGoodDir/file", "jane.doe@example.com", true, true], + ["exportGoodDir/file", "jane.doe@example.com", false, true], + ["exportBadFile", "john.doe@example.com", true, $writeException], + ["exportBadFile", "john.doe@example.com", false, $writeException], + ["exportBadFile", "jane.doe@example.com", true, $writeException], + ["exportBadFile", "jane.doe@example.com", false, $writeException], + ["exportBadDir/file", "john.doe@example.com", true, $createException], + ["exportBadDir/file", "john.doe@example.com", false, $createException], + ["exportBadDir/file", "jane.doe@example.com", true, $createException], + ["exportBadDir/file", "jane.doe@example.com", false, $createException], + ]; + } +} diff --git a/tests/phpunit.xml b/tests/phpunit.xml index fd5429fa..6ad94f32 100644 --- a/tests/phpunit.xml +++ b/tests/phpunit.xml @@ -115,6 +115,7 @@ cases/ImportExport/TestOPML.php + cases/ImportExport/TestOPMLFile.php From 77efaa7b416fe539250bcafe47a67e9afc08a905 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Mon, 1 Apr 2019 17:24:19 -0400 Subject: [PATCH 076/142] CLI command for exporting OPML and sundry cleanup --- lib/CLI.php | 53 +++++++++++++++++++------------------ tests/cases/CLI/TestCLI.php | 50 ++++++++++++++++++++++++++++------ 2 files changed, 69 insertions(+), 34 deletions(-) diff --git a/lib/CLI.php b/lib/CLI.php index 96936980..218e3d30 100644 --- a/lib/CLI.php +++ b/lib/CLI.php @@ -7,6 +7,7 @@ declare(strict_types=1); namespace JKingWeb\Arsse; use JKingWeb\Arsse\REST\Fever\User as Fever; +use JKingWeb\Arsse\ImportExport\OPML; class CLI { const USAGE = << [--oldpass=] [--fever] arsse.php user auth [--fever] + arsse.php export [] [-f | --flat] arsse.php --version arsse.php --help | -h @@ -54,6 +56,12 @@ USAGE_TEXT; return true; } + protected function resolveFile($file, string $mode): string { + // TODO: checking read/write permissions on the provided path may be useful + $stdinOrStdout = in_array($mode, ["r", "r+"]) ? "php://input" : "php://output"; + return ($file === "-" ? null : $file) ?? $stdinOrStdout; + } + public function dispatch(array $argv = null) { $argv = $argv ?? $_SERVER['argv']; $argv0 = array_shift($argv); @@ -62,7 +70,12 @@ USAGE_TEXT; 'help' => false, ]); try { - switch ($this->command(["--help", "--version", "daemon", "feed refresh", "feed refresh-all", "conf save-defaults", "user"], $args)) { + $cmd = $this->command(["--help", "--version", "daemon", "feed refresh", "feed refresh-all", "conf save-defaults", "user", "export"], $args); + if ($cmd && !in_array($cmd, ["--help", "--version", "conf save-defaults"])) { + // only certain commands don't require configuration to be loaded + $this->loadConf(); + } + switch ($cmd) { case "--help": echo $this->usage($argv0).\PHP_EOL; return 0; @@ -70,23 +83,22 @@ USAGE_TEXT; echo Arsse::VERSION.\PHP_EOL; return 0; case "daemon": - $this->loadConf(); - $this->getService()->watch(true); + $this->getInstance(Service::class)->watch(true); return 0; case "feed refresh": - $this->loadConf(); return (int) !Arsse::$db->feedUpdate((int) $args[''], true); case "feed refresh-all": - $this->loadConf(); - $this->getService()->watch(false); + $this->getInstance(Service::class)->watch(false); return 0; case "conf save-defaults": - $file = $args['']; - $file = ($file === "-" ? null : $file) ?? "php://output"; - return (int) !($this->getConf())->exportFile($file, true); + $file = $this->resolveFile($args[''], "w"); + return (int) !$this->getInstance(Conf::class)->exportFile($file, true); case "user": - $this->loadConf(); return $this->userManage($args); + case "export": + $u = $args['']; + $file = $this->resolveFile($args[''], "w"); + return (int) !$this->getInstance(OPML::class)->exportFile($file, $u, $args['--flat']); } } catch (AbstractException $e) { $this->logError($e->getMessage()); @@ -99,19 +111,8 @@ USAGE_TEXT; fwrite(STDERR, $msg.\PHP_EOL); } - /** @codeCoverageIgnore */ - protected function getService(): Service { - return new Service; - } - - /** @codeCoverageIgnore */ - protected function getConf(): Conf { - return new Conf; - } - - /** @codeCoverageIgnore */ - protected function getFever(): Fever { - return new Fever; + protected function getInstance(string $class) { + return new $class; } protected function userManage($args): int { @@ -120,7 +121,7 @@ USAGE_TEXT; return $this->userAddOrSetPassword("add", $args[""], $args[""]); case "set-pass": if ($args['--fever']) { - $passwd = $this->getFever()->register($args[""], $args[""]); + $passwd = $this->getInstance(Fever::class)->register($args[""], $args[""]); if (is_null($args[""])) { echo $passwd.\PHP_EOL; } @@ -130,7 +131,7 @@ USAGE_TEXT; } case "unset-pass": if ($args['--fever']) { - $this->getFever()->unregister($args[""]); + $this->getInstance(Fever::class)->unregister($args[""]); } else { Arsse::$user->passwordUnset($args[""], $args["--oldpass"]); } @@ -162,7 +163,7 @@ USAGE_TEXT; } protected function userAuthenticate(string $user, string $password, bool $fever = false): int { - $result = $fever ? $this->getFever()->authenticate($user, $password) : Arsse::$user->auth($user, $password); + $result = $fever ? $this->getInstance(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/tests/cases/CLI/TestCLI.php b/tests/cases/CLI/TestCLI.php index 3f1c3d30..56202d94 100644 --- a/tests/cases/CLI/TestCLI.php +++ b/tests/cases/CLI/TestCLI.php @@ -13,6 +13,7 @@ use JKingWeb\Arsse\Database; use JKingWeb\Arsse\Service; use JKingWeb\Arsse\CLI; use JKingWeb\Arsse\REST\Fever\User as FeverUser; +use JKingWeb\Arsse\ImportExport\OPML; use Phake; /** @covers \JKingWeb\Arsse\CLI */ @@ -68,21 +69,21 @@ class TestCLI extends \JKingWeb\Arsse\Test\AbstractTest { public function testStartTheDaemon() { $srv = Phake::mock(Service::class); Phake::when($srv)->watch->thenReturn(new \DateTimeImmutable); - Phake::when($this->cli)->getService->thenReturn($srv); + Phake::when($this->cli)->getInstance(Service::class)->thenReturn($srv); $this->assertConsole($this->cli, "arsse.php daemon", 0); $this->assertLoaded(true); Phake::verify($srv)->watch(true); - Phake::verify($this->cli)->getService; + Phake::verify($this->cli)->getInstance(Service::class); } public function testRefreshAllFeeds() { $srv = Phake::mock(Service::class); Phake::when($srv)->watch->thenReturn(new \DateTimeImmutable); - Phake::when($this->cli)->getService->thenReturn($srv); + Phake::when($this->cli)->getInstance(Service::class)->thenReturn($srv); $this->assertConsole($this->cli, "arsse.php feed refresh-all", 0); $this->assertLoaded(true); Phake::verify($srv)->watch(false); - Phake::verify($this->cli)->getService; + Phake::verify($this->cli)->getInstance(Service::class); } /** @dataProvider provideFeedUpdates */ @@ -108,7 +109,7 @@ class TestCLI extends \JKingWeb\Arsse\Test\AbstractTest { 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)->getConf->thenReturn($conf); + Phake::when($this->cli)->getInstance(Conf::class)->thenReturn($conf); $this->assertConsole($this->cli, $cmd, $exitStatus); $this->assertLoaded(false); Phake::verify($conf)->exportFile($file, true); @@ -179,7 +180,7 @@ class TestCLI extends \JKingWeb\Arsse\Test\AbstractTest { \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)->getFever->thenReturn($fever); + \Phake::when($this->cli)->getInstance(FeverUser::class)->thenReturn($fever); $this->assertConsole($this->cli, $cmd, $exitStatus, $output); } @@ -234,7 +235,7 @@ class TestCLI extends \JKingWeb\Arsse\Test\AbstractTest { Arsse::$user->method("passwordSet")->will($this->returnCallback($passwordChange)); $fever = \Phake::mock(FeverUser::class); \Phake::when($fever)->register->thenReturnCallback($passwordChange); - \Phake::when($this->cli)->getFever->thenReturn($fever); + \Phake::when($this->cli)->getInstance(FeverUser::class)->thenReturn($fever); $this->assertConsole($this->cli, $cmd, $exitStatus, $output); } @@ -264,7 +265,7 @@ class TestCLI extends \JKingWeb\Arsse\Test\AbstractTest { Arsse::$user->method("passwordUnset")->will($this->returnCallback($passwordClear)); $fever = \Phake::mock(FeverUser::class); \Phake::when($fever)->unregister->thenReturnCallback($passwordClear); - \Phake::when($this->cli)->getFever->thenReturn($fever); + \Phake::when($this->cli)->getInstance(FeverUser::class)->thenReturn($fever); $this->assertConsole($this->cli, $cmd, $exitStatus, $output); } @@ -276,4 +277,37 @@ class TestCLI extends \JKingWeb\Arsse\Test\AbstractTest { ["arsse.php user unset-pass jane.doe@example.com --fever", 10402, ""], ]; } + + /** @dataProvider provideOpmlExports */ + public function testExportToOpml(string $cmd, int $exitStatus, string $file, string $user, bool $flat) { + $opml = Phake::mock(OPML::class); + 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); + $this->assertLoaded(true); + Phake::verify($opml)->exportFile($file, $user, $flat); + } + + public function provideOpmlExports() { + return [ + ["arsse.php export john.doe@example.com", 0, "php://output", "john.doe@example.com", false], + ["arsse.php export john.doe@example.com -", 0, "php://output", "john.doe@example.com", false], + ["arsse.php export john.doe@example.com good.opml", 0, "good.opml", "john.doe@example.com", false], + ["arsse.php export john.doe@example.com bad.opml", 10604, "bad.opml", "john.doe@example.com", false], + ["arsse.php export john.doe@example.com --flat", 0, "php://output", "john.doe@example.com", true], + ["arsse.php export john.doe@example.com - --flat", 0, "php://output", "john.doe@example.com", true], + ["arsse.php export --flat john.doe@example.com good.opml", 0, "good.opml", "john.doe@example.com", true], + ["arsse.php export john.doe@example.com bad.opml --flat", 10604, "bad.opml", "john.doe@example.com", true], + ["arsse.php export jane.doe@example.com", 0, "php://output", "jane.doe@example.com", false], + ["arsse.php export jane.doe@example.com -", 0, "php://output", "jane.doe@example.com", false], + ["arsse.php export jane.doe@example.com good.opml", 0, "good.opml", "jane.doe@example.com", false], + ["arsse.php export jane.doe@example.com bad.opml", 10604, "bad.opml", "jane.doe@example.com", false], + ["arsse.php export jane.doe@example.com --flat", 0, "php://output", "jane.doe@example.com", true], + ["arsse.php export jane.doe@example.com - --flat", 0, "php://output", "jane.doe@example.com", true], + ["arsse.php export --flat jane.doe@example.com good.opml", 0, "good.opml", "jane.doe@example.com", true], + ["arsse.php export jane.doe@example.com bad.opml --flat", 10604, "bad.opml", "jane.doe@example.com", true], + ]; + } } From ba32ad2f1724cfd766df9579ce668063e57fa67d Mon Sep 17 00:00:00 2001 From: "J. King" Date: Tue, 2 Apr 2019 09:32:31 -0400 Subject: [PATCH 077/142] Add context options for multiple tags, labels, etc --- lib/Context/ExclusionContext.php | 68 +++++++++++++++++++++++++++++--- tests/cases/Misc/TestContext.php | 25 ++++++++++-- 2 files changed, 84 insertions(+), 9 deletions(-) diff --git a/lib/Context/ExclusionContext.php b/lib/Context/ExclusionContext.php index 1f91994a..63cc97d3 100644 --- a/lib/Context/ExclusionContext.php +++ b/lib/Context/ExclusionContext.php @@ -11,16 +11,23 @@ use JKingWeb\Arsse\Misc\Date; class ExclusionContext { public $folder; + public $folders; public $folderShallow; + public $foldersShallow; public $tag; + public $tags; public $tagName; + public $tagNames; public $subscription; + public $subscriptions; public $edition; - public $article; public $editions; + public $article; public $articles; public $label; + public $labels; public $labelName; + public $labelNames; public $annotationTerms; public $searchTerms; public $titleTerms; @@ -70,16 +77,18 @@ class ExclusionContext { } } - protected function cleanIdArray(array $spec): array { + protected function cleanIdArray(array $spec, bool $allowZero = false): array { $spec = array_values($spec); for ($a = 0; $a < sizeof($spec); $a++) { - if (ValueInfo::id($spec[$a])) { + if (ValueInfo::id($spec[$a], $allowZero)) { $spec[$a] = (int) $spec[$a]; } else { - $spec[$a] = 0; + $spec[$a] = null; } } - return array_values(array_unique(array_filter($spec))); + return array_values(array_unique(array_filter($spec, function ($v) { + return !is_null($v); + }))); } protected function cleanStringArray(array $spec): array { @@ -99,22 +108,57 @@ class ExclusionContext { return $this->act(__FUNCTION__, func_num_args(), $spec); } + public function folders(array $spec = null) { + if (isset($spec)) { + $spec = $this->cleanIdArray($spec, true); + } + return $this->act(__FUNCTION__, func_num_args(), $spec); + } + public function folderShallow(int $spec = null) { return $this->act(__FUNCTION__, func_num_args(), $spec); } + public function foldersShallow(array $spec = null) { + if (isset($spec)) { + $spec = $this->cleanIdArray($spec, true); + } + return $this->act(__FUNCTION__, func_num_args(), $spec); + } + public function tag(int $spec = null) { return $this->act(__FUNCTION__, func_num_args(), $spec); } + public function tags(array $spec = null) { + if (isset($spec)) { + $spec = $this->cleanIdArray($spec); + } + return $this->act(__FUNCTION__, func_num_args(), $spec); + } + public function tagName(string $spec = null) { return $this->act(__FUNCTION__, func_num_args(), $spec); } + public function tagNames(array $spec = null) { + if (isset($spec)) { + $spec = $this->cleanStringArray($spec); + } + return $this->act(__FUNCTION__, func_num_args(), $spec); + } + public function subscription(int $spec = null) { return $this->act(__FUNCTION__, func_num_args(), $spec); } + public function subscriptions(array $spec = null) { + if (isset($spec)) { + $spec = $this->cleanIdArray($spec); + } + return $this->act(__FUNCTION__, func_num_args(), $spec); + } + public function edition(int $spec = null) { return $this->act(__FUNCTION__, func_num_args(), $spec); } @@ -141,10 +185,24 @@ class ExclusionContext { return $this->act(__FUNCTION__, func_num_args(), $spec); } + public function labels(array $spec = null) { + if (isset($spec)) { + $spec = $this->cleanIdArray($spec); + } + return $this->act(__FUNCTION__, func_num_args(), $spec); + } + public function labelName(string $spec = null) { return $this->act(__FUNCTION__, func_num_args(), $spec); } + public function labelNames(array $spec = null) { + if (isset($spec)) { + $spec = $this->cleanStringArray($spec); + } + return $this->act(__FUNCTION__, func_num_args(), $spec); + } + public function annotationTerms(array $spec = null) { if (isset($spec)) { $spec = $this->cleanStringArray($spec); diff --git a/tests/cases/Misc/TestContext.php b/tests/cases/Misc/TestContext.php index e85d58ec..f32f11e4 100644 --- a/tests/cases/Misc/TestContext.php +++ b/tests/cases/Misc/TestContext.php @@ -29,10 +29,15 @@ class TestContext extends \JKingWeb\Arsse\Test\AbstractTest { 'limit' => 10, 'offset' => 5, 'folder' => 42, + 'folders' => [12,22], 'folderShallow' => 42, + 'foldersShallow' => [0,1], 'tag' => 44, + 'tags' => [44, 2112], 'tagName' => "XLIV", + 'tagNames' => ["XLIV", "MMCXII"], 'subscription' => 2112, + 'subscriptions' => [44, 2112], 'article' => 255, 'edition' => 65535, 'latestArticle' => 47, @@ -48,7 +53,9 @@ class TestContext extends \JKingWeb\Arsse\Test\AbstractTest { 'editions' => [1,2], 'articles' => [1,2], 'label' => 2112, + 'labels' => [2112, 1984], 'labelName' => "Rush", + 'labelNames' => ["Rush", "Orwell"], 'labelled' => true, 'annotated' => true, 'searchTerms' => ["foo", "bar"], @@ -79,9 +86,19 @@ class TestContext extends \JKingWeb\Arsse\Test\AbstractTest { } public function testCleanIdArrayValues() { - $methods = ["articles", "editions"]; - $in = [1, "2", 3.5, 3.0, "ook", 0, -20, true, false, null, new \DateTime(), -1.0]; - $out = [1,2, 3]; + $methods = ["articles", "editions", "tags", "labels", "subscriptions"]; + $in = [1, "2", 3.5, 4.0, 4, "ook", 0, -20, true, false, null, new \DateTime(), -1.0]; + $out = [1, 2, 4]; + $c = new Context; + foreach ($methods as $method) { + $this->assertSame($out, $c->$method($in)->$method, "Context method $method did not return the expected results"); + } + } + + public function testCleanFolderIdArrayValues() { + $methods = ["folders", "foldersShallow"]; + $in = [1, "2", 3.5, 4.0, 4, "ook", 0, -20, true, false, null, new \DateTime(), -1.0]; + $out = [1, 2, 4, 0]; $c = new Context; foreach ($methods as $method) { $this->assertSame($out, $c->$method($in)->$method, "Context method $method did not return the expected results"); @@ -89,7 +106,7 @@ class TestContext extends \JKingWeb\Arsse\Test\AbstractTest { } public function testCleanStringArrayValues() { - $methods = ["searchTerms", "annotationTerms", "titleTerms", "authorTerms"]; + $methods = ["searchTerms", "annotationTerms", "titleTerms", "authorTerms", "tagNames", "labelNames"]; $now = new \DateTime; $in = [1, 3.0, "ook", 0, true, false, null, $now, ""]; $out = ["1", "3", "ook", "0", valueInfo::normalize($now, ValueInfo::T_STRING)]; From ef1b761f9583373a1e1797c66902300206fdee42 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Tue, 2 Apr 2019 18:24:20 -0400 Subject: [PATCH 078/142] Implement most multiple-item context options Selecting multiple folder trees will require further effort --- lib/Database.php | 102 +++++++++++++++++-------- tests/cases/Database/SeriesArticle.php | 6 ++ 2 files changed, 78 insertions(+), 30 deletions(-) diff --git a/lib/Database.php b/lib/Database.php index 24869078..49f32e8b 100644 --- a/lib/Database.php +++ b/lib/Database.php @@ -1323,7 +1323,9 @@ class Database { "markedSince" => ["marked_date", ">=", "datetime", "notMarkedSince"], "notMarkedSince" => ["marked_date", "<=", "datetime", "markedSince"], "folderShallow" => ["folder", "=", "int", ""], + "foldersShallow" => ["folder", "in", "int", ""], "subscription" => ["subscription", "=", "int", ""], + "subscriptions" => ["subscription", "in", "int", ""], "unread" => ["unread", "=", "bool", ""], "starred" => ["starred", "=", "bool", ""], ]; @@ -1374,6 +1376,76 @@ class Database { $q->setWhereNot("{$colDefs[$col]} $op ?", $type, $context->not->$m); } } + // handle labels and tags + $options = [ + 'label' => [ + 'match_col' => "arsse_articles.id", + 'cte_name' => "labelled", + 'cte_cols' => ["article", "label_id", "label_name"], + 'cte_body' => "SELECT m.article, l.id, l.name from arsse_label_members as m join arsse_labels as l on l.id = m.label where l.owner = ? and m.assigned = 1", + 'cte_types' => ["str"], + 'cte_values' => [$user], + 'options' => [ + 'label' => ['use_name' => false, 'multi' => false], + 'labels' => ['use_name' => false, 'multi' => true], + 'labelName' => ['use_name' => true, 'multi' => false], + 'labelNames' => ['use_name' => true, 'multi' => true], + ], + ], + 'tag' => [ + 'match_col' => "arsse_subscriptions.id", + 'cte_name' => "tagged", + 'cte_cols' => ["subscription", "tag_id", "tag_name"], + 'cte_body' => "SELECT m.subscription, t.id, t.name from arsse_tag_members as m join arsse_tags as t on t.id = m.tag where t.owner = ? and m.assigned = 1", + 'cte_types' => ["str"], + 'cte_values' => [$user], + 'options' => [ + 'tag' => ['use_name' => false, 'multi' => false], + 'tags' => ['use_name' => false, 'multi' => true], + 'tagName' => ['use_name' => true, 'multi' => false], + 'tagNames' => ['use_name' => true, 'multi' => true], + ], + ], + ]; + foreach ($options as $opt) { + $seen = false; + $match = $opt['match_col']; + $table = $opt['cte_name']; + foreach ($opt['options'] as $m => $props) { + $named = $props['use_name']; + $multi = $props['multi']; + $selection = $opt['cte_cols'][0]; + $col = $opt['cte_cols'][$named ? 2 : 1]; + if ($context->$m()) { + $seen = true; + if ($multi) { + list($test, $types, $values) = $this->generateIn($context->$m, $named ? "str" : "int"); + $test = "in ($test)"; + } else { + $test = "= ?"; + $types = $named ? "str" : "int"; + $values = $context->$m; + } + $q->setWhere("$match in (select $selection from $table where $col $test)", $types, $values); + } + if ($context->not->$m()) { + $seen = true; + if ($multi) { + list($test, $types, $values) = $this->generateIn($context->not->$m, $named ? "str" : "int"); + $test = "in ($test)"; + } else { + $test = "= ?"; + $types = $named ? "str" : "int"; + $values = $context->not->$m; + } + $q->setWhereNot("$match in (select $selection from $table where $col $test)", $types, $values); + } + } + if ($seen) { + $spec = $opt['cte_name']."(".implode(",",$opt['cte_cols']).")"; + $q->setCTE($spec, $opt['cte_body'], $opt['cte_types'], $opt['cte_values']); + } + } // handle complex context options if ($context->annotated()) { $comp = ($context->annotated) ? "<>" : "="; @@ -1384,36 +1456,6 @@ class Database { $op = $context->labelled ? ">" : "="; $q->setWhere("coalesce(label_stats.assigned,0) $op 0"); } - if ($context->label() || $context->not->label() || $context->labelName() || $context->not->labelName()) { - $q->setCTE("labelled(article,label_id,label_name)","SELECT m.article, l.id, l.name from arsse_label_members as m join arsse_labels as l on l.id = m.label where l.owner = ? and m.assigned = 1", "str", $user); - if ($context->label()) { - $q->setWhere("arsse_articles.id in (select article from labelled where label_id = ?)", "int", $context->label); - } - if ($context->not->label()) { - $q->setWhereNot("arsse_articles.id in (select article from labelled where label_id = ?)", "int", $context->not->label); - } - if ($context->labelName()) { - $q->setWhere("arsse_articles.id in (select article from labelled where label_name = ?)", "str", $context->labelName); - } - if ($context->not->labelName()) { - $q->setWhereNot("arsse_articles.id in (select article from labelled where label_name = ?)", "str", $context->not->labelName); - } - } - if ($context->tag() || $context->not->tag() || $context->tagName() || $context->not->tagName()) { - $q->setCTE("tagged(id,name,subscription)","SELECT arsse_tags.id, arsse_tags.name, arsse_tag_members.subscription FROM arsse_tag_members join arsse_tags on arsse_tags.id = arsse_tag_members.tag WHERE arsse_tags.owner = ? and assigned = 1", "str", $user); - if ($context->tag()) { - $q->setWhere("arsse_subscriptions.id in (select subscription from tagged where id = ?)", "int", $context->tag); - } - if ($context->not->tag()) { - $q->setWhereNot("arsse_subscriptions.id in (select subscription from tagged where id = ?)", "int", $context->not->tag); - } - if ($context->tagName()) { - $q->setWhere("arsse_subscriptions.id in (select subscription from tagged where name = ?)", "str", $context->tagName); - } - if ($context->not->tagName()) { - $q->setWhereNot("arsse_subscriptions.id in (select subscription from tagged where name = ?)", "str", $context->not->tagName); - } - } if ($context->folder()) { // add a common table expression to list the folder and its children so that we select from the entire subtree $q->setCTE("folders(folder)", "SELECT ? union select id from arsse_folders join folders on parent = folder", "int", $context->folder); diff --git a/tests/cases/Database/SeriesArticle.php b/tests/cases/Database/SeriesArticle.php index 5340fcc7..51d9da53 100644 --- a/tests/cases/Database/SeriesArticle.php +++ b/tests/cases/Database/SeriesArticle.php @@ -427,7 +427,9 @@ trait SeriesArticle { 'Leaf folder' => [(new Context)->folder(6), [7,8]], 'Root folder only' => [(new Context)->folderShallow(0), [1,2,3,4]], 'Shallow folder' => [(new Context)->folderShallow(1), [5,6]], + 'Multiple shallow folders' => [(new Context)->foldersShallow([1,6]), [5,6,7,8]], 'Subscription' => [(new Context)->subscription(5), [19,20]], + 'Multiple subscriptions' => [(new Context)->subscriptions([4,5]), [7,8,19,20]], 'Unread' => [(new Context)->subscription(5)->unread(true), [20]], 'Read' => [(new Context)->subscription(5)->unread(false), [19]], 'Starred' => [(new Context)->starred(true), [1,20]], @@ -458,8 +460,10 @@ trait SeriesArticle { 'Reversed paged results' => [(new Context)->limit(2)->latestEdition(7)->reverse(true), [7,6]], 'With label ID 1' => [(new Context)->label(1), [1,19]], 'With label ID 2' => [(new Context)->label(2), [1,5,20]], + 'With label ID 1 or 2' => [(new Context)->labels([1,2]), [1,5,19,20]], 'With label "Interesting"' => [(new Context)->labelName("Interesting"), [1,19]], 'With label "Fascinating"' => [(new Context)->labelName("Fascinating"), [1,5,20]], + 'With label "Interesting" or "Fascinating"' => [(new Context)->labelNames(["Interesting","Fascinating"]), [1,5,19,20]], 'Article ID 20' => [(new Context)->article(20), [20]], 'Edition ID 1001' => [(new Context)->edition(1001), [20]], 'Multiple articles' => [(new Context)->articles([1,20,50]), [1,20]], @@ -494,8 +498,10 @@ trait SeriesArticle { 'Search 501 terms' => [(new Context)->searchTerms(array_merge(range(1,500),[str_repeat("a", 1000)])), []], 'With tag ID 1' => [(new Context)->tag(1), [5,6,7,8]], 'With tag ID 5' => [(new Context)->tag(5), [7,8,19,20]], + 'With tag ID 1 or 5' => [(new Context)->tags([1,5]), [5,6,7,8,19,20]], 'With tag "Technology"' => [(new Context)->tagName("Technology"), [5,6,7,8]], 'With tag "Politics"' => [(new Context)->tagName("Politics"), [7,8,19,20]], + 'With tag "Technology" or "Politics"' => [(new Context)->tagNames(["Technology","Politics"]), [5,6,7,8,19,20]], 'Excluding tag ID 1' => [(new Context)->not->tag(1), [1,2,3,4,19,20]], 'Excluding tag ID 5' => [(new Context)->not->tag(5), [1,2,3,4,5,6]], 'Excluding tag "Technology"' => [(new Context)->not->tagName("Technology"), [1,2,3,4,19,20]], From 98f6fca7e3d2ef88b8246e93514297b2d7c41a60 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Tue, 2 Apr 2019 18:37:46 -0400 Subject: [PATCH 079/142] Enforce minimum array size (for now) --- lib/Database.php | 3 +++ tests/cases/Database/SeriesArticle.php | 29 ++++++++++++-------------- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/lib/Database.php b/lib/Database.php index 49f32e8b..404f451f 100644 --- a/lib/Database.php +++ b/lib/Database.php @@ -1418,6 +1418,9 @@ class Database { $col = $opt['cte_cols'][$named ? 2 : 1]; if ($context->$m()) { $seen = true; + if (!$context->$m) { + throw new Db\ExceptionInput("tooShort", ['field' => $m, 'action' => $this->caller(), 'min' => 1]); // must have at least one array element + } if ($multi) { list($test, $types, $values) = $this->generateIn($context->$m, $named ? "str" : "int"); $test = "in ($test)"; diff --git a/tests/cases/Database/SeriesArticle.php b/tests/cases/Database/SeriesArticle.php index 51d9da53..694aec56 100644 --- a/tests/cases/Database/SeriesArticle.php +++ b/tests/cases/Database/SeriesArticle.php @@ -789,11 +789,6 @@ trait SeriesArticle { $this->compareExpectations($state); } - public function testMarkTooFewMultipleArticles() { - $this->assertException("tooShort", "Db", "ExceptionInput"); - Arsse::$db->articleMark($this->user, ['read'=>false,'starred'=>true], (new Context)->articles([])); - } - public function testMarkTooManyMultipleArticles() { $this->assertSame(7, Arsse::$db->articleMark($this->user, ['read'=>false,'starred'=>true], (new Context)->articles(range(1, Database::LIMIT_SET_SIZE * 3)))); } @@ -860,11 +855,6 @@ trait SeriesArticle { $this->compareExpectations($state); } - public function testMarkTooFewMultipleEditions() { - $this->assertException("tooShort", "Db", "ExceptionInput"); - Arsse::$db->articleMark($this->user, ['read'=>false,'starred'=>true], (new Context)->editions([])); - } - public function testMarkTooManyMultipleEditions() { $this->assertSame(7, Arsse::$db->articleMark($this->user, ['read'=>false,'starred'=>true], (new Context)->editions(range(1, 51)))); } @@ -1036,13 +1026,20 @@ trait SeriesArticle { Arsse::$db->articleCategoriesGet($this->user, 19); } - public function testSearchTooFewTerms() { - $this->assertException("tooShort", "Db", "ExceptionInput"); - Arsse::$db->articleList($this->user, (new Context)->searchTerms([])); - } - - public function testSearchTooFewTermsInNote() { + /** @dataProvider provideArrayContextOptions */ + public function testUseTooFewValuesInArrayContext(string $option) { $this->assertException("tooShort", "Db", "ExceptionInput"); Arsse::$db->articleList($this->user, (new Context)->annotationTerms([])); } + + public function provideArrayContextOptions() { + foreach([ + "articles", "editions", + "subscriptions", "foldersShallow", //"folders", + "tags", "tagNames", "labels", "labelNames", + "searchTerms", "authorTerms", "annotationTerms", + ] as $method) { + yield [$method]; + } + } } From cce1089e10740bbf6a7ee38fc9227cfcd09f2aaf Mon Sep 17 00:00:00 2001 From: "J. King" Date: Tue, 2 Apr 2019 19:58:35 -0400 Subject: [PATCH 080/142] Handle edge case with folder 0 Folder 0 (the root folder) is a valid, though nonsensical selection: using it as a positive option is the same as not using the option at all, and using it as a negative option necessarily yields an empty set. However, it can in some contexts be validly specified, and so it should be handled consistently. It had not been previously, but is now. --- lib/Context/ExclusionContext.php | 1 + lib/Database.php | 4 ++-- tests/cases/Database/SeriesArticle.php | 2 ++ 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/lib/Context/ExclusionContext.php b/lib/Context/ExclusionContext.php index 63cc97d3..7cf45cb3 100644 --- a/lib/Context/ExclusionContext.php +++ b/lib/Context/ExclusionContext.php @@ -49,6 +49,7 @@ class ExclusionContext { } public function __clone() { + // if the context was cloned because its parent was cloned, change the parent to the clone if ($this->parent) { $t = debug_backtrace(\DEBUG_BACKTRACE_IGNORE_ARGS | \DEBUG_BACKTRACE_PROVIDE_OBJECT, 2)[1]; if (($t['object'] ?? null) instanceof self && $t['function'] === "__clone") { diff --git a/lib/Database.php b/lib/Database.php index 404f451f..1173ae67 100644 --- a/lib/Database.php +++ b/lib/Database.php @@ -1461,13 +1461,13 @@ class Database { } if ($context->folder()) { // add a common table expression to list the folder and its children so that we select from the entire subtree - $q->setCTE("folders(folder)", "SELECT ? union select id from arsse_folders join folders on parent = folder", "int", $context->folder); + $q->setCTE("folders(folder)", "SELECT ? union select id from arsse_folders join folders on coalesce(parent,0) = folder", "int", $context->folder); // limit subscriptions to the listed folders $q->setWhere("coalesce(arsse_subscriptions.folder,0) in (select folder from folders)"); } if ($context->not->folder()) { // add a common table expression to list the folder and its children so that we exclude from the entire subtree - $q->setCTE("folders_excluded(folder)", "SELECT ? union select id from arsse_folders join folders_excluded on parent = folder", "int", $context->not->folder); + $q->setCTE("folders_excluded(folder)", "SELECT ? union select id from arsse_folders join folders_excluded on coalesce(parent,0) = folder", "int", $context->not->folder); // excluded any subscriptions in the listed folders $q->setWhereNot("coalesce(arsse_subscriptions.folder,0) in (select folder from folders_excluded)"); } diff --git a/tests/cases/Database/SeriesArticle.php b/tests/cases/Database/SeriesArticle.php index 694aec56..31dcf961 100644 --- a/tests/cases/Database/SeriesArticle.php +++ b/tests/cases/Database/SeriesArticle.php @@ -424,6 +424,7 @@ trait SeriesArticle { return [ 'Blank context' => [new Context, [1,2,3,4,5,6,7,8,19,20]], 'Folder tree' => [(new Context)->folder(1), [5,6,7,8]], + 'Entire folder tree' => [(new Context)->folder(0), [1,2,3,4,5,6,7,8,19,20]], 'Leaf folder' => [(new Context)->folder(6), [7,8]], 'Root folder only' => [(new Context)->folderShallow(0), [1,2,3,4]], 'Shallow folder' => [(new Context)->folderShallow(1), [5,6]], @@ -506,6 +507,7 @@ trait SeriesArticle { 'Excluding tag ID 5' => [(new Context)->not->tag(5), [1,2,3,4,5,6]], 'Excluding tag "Technology"' => [(new Context)->not->tagName("Technology"), [1,2,3,4,19,20]], 'Excluding tag "Politics"' => [(new Context)->not->tagName("Politics"), [1,2,3,4,5,6]], + 'Excluding entire folder tree' => [(new Context)->not->folder(0), []], ]; } From 74fc39fca034a152d301baea9ea9f0b64a53d8ec Mon Sep 17 00:00:00 2001 From: "J. King" Date: Tue, 2 Apr 2019 22:44:09 -0400 Subject: [PATCH 081/142] Implement multi-folder context option --- lib/Database.php | 14 ++++++++++++++ tests/cases/Database/SeriesArticle.php | 6 +++++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/lib/Database.php b/lib/Database.php index 1173ae67..459f4676 100644 --- a/lib/Database.php +++ b/lib/Database.php @@ -1465,12 +1465,26 @@ class Database { // limit subscriptions to the listed folders $q->setWhere("coalesce(arsse_subscriptions.folder,0) in (select folder from folders)"); } + if ($context->folders()) { + list($inClause, $inTypes, $inValues) = $this->generateIn($context->folders, "int"); + // add a common table expression to list the folders and their children so that we select from the entire subtree + $q->setCTE("folders_multi(folder)", "SELECT id as folder from (select id from (select 0 as id union select id from arsse_folders where owner = ?) as f where id in ($inClause)) as folders_multi union select id from arsse_folders join folders_multi on coalesce(parent,0) = folder", ["str", $inTypes], [$user, $inValues]); + // limit subscriptions to the listed folders + $q->setWhere("coalesce(arsse_subscriptions.folder,0) in (select folder from folders_multi)"); + } if ($context->not->folder()) { // add a common table expression to list the folder and its children so that we exclude from the entire subtree $q->setCTE("folders_excluded(folder)", "SELECT ? union select id from arsse_folders join folders_excluded on coalesce(parent,0) = folder", "int", $context->not->folder); // excluded any subscriptions in the listed folders $q->setWhereNot("coalesce(arsse_subscriptions.folder,0) in (select folder from folders_excluded)"); } + if ($context->not->folders()) { + list($inClause, $inTypes, $inValues) = $this->generateIn($context->not->folders, "int"); + // add a common table expression to list the folders and their children so that we select from the entire subtree + $q->setCTE("folders_multi_excluded(folder)", "SELECT id as folder from (select id from (select 0 as id union select id from arsse_folders where owner = ?) as f where id in ($inClause)) as folders_multi_excluded union select id from arsse_folders join folders_multi_excluded on coalesce(parent,0) = folder", ["str", $inTypes], [$user, $inValues]); + // limit subscriptions to the listed folders + $q->setWhereNot("coalesce(arsse_subscriptions.folder,0) in (select folder from folders_multi_excluded)"); + } // handle text-matching context options $options = [ "titleTerms" => ["arsse_articles.title"], diff --git a/tests/cases/Database/SeriesArticle.php b/tests/cases/Database/SeriesArticle.php index 31dcf961..17b0ece3 100644 --- a/tests/cases/Database/SeriesArticle.php +++ b/tests/cases/Database/SeriesArticle.php @@ -426,8 +426,10 @@ trait SeriesArticle { 'Folder tree' => [(new Context)->folder(1), [5,6,7,8]], 'Entire folder tree' => [(new Context)->folder(0), [1,2,3,4,5,6,7,8,19,20]], 'Leaf folder' => [(new Context)->folder(6), [7,8]], - 'Root folder only' => [(new Context)->folderShallow(0), [1,2,3,4]], + 'Multiple folder trees' => [(new Context)->folders([1,5]), [5,6,7,8,19,20]], + 'Multiple folder trees including root' => [(new Context)->folders([0,1,5]), [1,2,3,4,5,6,7,8,19,20]], 'Shallow folder' => [(new Context)->folderShallow(1), [5,6]], + 'Root folder only' => [(new Context)->folderShallow(0), [1,2,3,4]], 'Multiple shallow folders' => [(new Context)->foldersShallow([1,6]), [5,6,7,8]], 'Subscription' => [(new Context)->subscription(5), [19,20]], 'Multiple subscriptions' => [(new Context)->subscriptions([4,5]), [7,8,19,20]], @@ -508,6 +510,8 @@ trait SeriesArticle { 'Excluding tag "Technology"' => [(new Context)->not->tagName("Technology"), [1,2,3,4,19,20]], 'Excluding tag "Politics"' => [(new Context)->not->tagName("Politics"), [1,2,3,4,5,6]], 'Excluding entire folder tree' => [(new Context)->not->folder(0), []], + 'Excluding multiple folder trees' => [(new Context)->not->folders([1,5]), [1,2,3,4]], + 'Excluding multiple folder trees including root' => [(new Context)->not->folders([0,1,5]), []], ]; } From 4b133bddd640dcafd80c2fff5c1243119ab38842 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Wed, 3 Apr 2019 15:02:59 -0400 Subject: [PATCH 082/142] Prototype arbitrary result ordering --- lib/Database.php | 60 ++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 48 insertions(+), 12 deletions(-) diff --git a/lib/Database.php b/lib/Database.php index 459f4676..341b2671 100644 --- a/lib/Database.php +++ b/lib/Database.php @@ -1226,7 +1226,7 @@ class Database { * @param Context $context The search context * @param array $cols The columns to request in the result set */ - protected function articleQuery(string $user, Context $context, array $cols = ["id"]): Query { + protected function articleQuery(string $user, Context $context, array $cols = ["id"], array $sort = []): Query { // validate input if ($context->subscription()) { $this->subscriptionValidateId($user, $context->subscription); @@ -1275,23 +1275,55 @@ class Database { 'media_type' => "arsse_enclosures.type", ]; if (!$cols) { - // if no columns are specified return a count - $columns = "count(distinct arsse_articles.id) as count"; + // if no columns are specified return a count; don't borther with sorting + $outColumns = "count(distinct arsse_articles.id) as count"; + $sortColumns = []; } else { - $columns = []; + // normalize requested output and sorting columns + $norm = function($v) { + return trim(strtolower(ValueInfo::normalize($v, ValueInfo::T_STRING))); + }; + $cols = array_map($norm, $cols); + $sort = array_map($norm, $sort); + // make an output column list + $outColumns = []; foreach ($cols as $col) { - $col = trim(strtolower($col)); if (!isset($colDefs[$col])) { continue; } - $columns[] = $colDefs[$col]." as ".$col; + $outColumns[] = $colDefs[$col]." as ".$col; + } + $outColumns = implode(",", $outColumns); + // make an ORDER BY column list + $sortColumns = []; + foreach ($sort as $spec) { + $col = explode(" ", $spec, 1); + $order = $col[1] ?? ""; + $col = $col[0]; + if ($order === "desc") { + $order = " desc"; + } elseif ($order === "asc" || $order === "") { + $order = ""; + } else { + // column direction spec is bogus + continue; + } + if (!isset($colDefs[$col])) { + // column name spec is bogus + continue; + } elseif (in_array($col, $cols)) { + // if the sort column is also an output column, use it as-is + $sortColumns[] = $col.$order; + } else { + // otherwise if the column name is valid, use its expression + $sortColumns[] = $colDefs[$col].$order; + } } - $columns = implode(",", $columns); } // define the basic query, to which we add lots of stuff where necessary $q = new Query( "SELECT - $columns + $outColumns 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 @@ -1307,6 +1339,10 @@ class Database { [$user, $user] ); $q->setLimit($context->limit, $context->offset); + // apply the ORDER BY definition computed above + array_walk($sortColumns, function($v, $k, Query $q) { + $q->setOrder($v); + }, $q); // handle the simple context options $options = [ // each context array consists of a column identifier (see $colDefs above), a comparison operator, a data type, and an option to pair with for BETWEEN evaluation @@ -1492,20 +1528,20 @@ class Database { "authorTerms" => ["arsse_articles.author"], "annotationTerms" => ["arsse_marks.note"], ]; - foreach ($options as $m => $cols) { + foreach ($options as $m => $columns) { if (!$context->$m()) { continue; } elseif (!$context->$m) { throw new Db\ExceptionInput("tooShort", ['field' => $m, 'action' => $this->caller(), 'min' => 1]); // must have at least one array element } - $q->setWhere(...$this->generateSearch($context->$m, $cols)); + $q->setWhere(...$this->generateSearch($context->$m, $columns)); } // further handle exclusionary text-matching context options - foreach ($options as $m => $cols) { + foreach ($options as $m => $columns) { if (!$context->not->$m() || !$context->not->$m) { continue; } - $q->setWhereNot(...$this->generateSearch($context->not->$m, $cols, true)); + $q->setWhereNot(...$this->generateSearch($context->not->$m, $columns, true)); } // return the query return $q; From 156ce2d09970860dde25d200ce577d3d4e06576e Mon Sep 17 00:00:00 2001 From: "J. King" Date: Thu, 4 Apr 2019 11:20:40 -0400 Subject: [PATCH 083/142] Fix Unix Robo script --- robo | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/robo b/robo index 0b3be08f..f5259416 100755 --- a/robo +++ b/robo @@ -5,7 +5,7 @@ shift ulimit -n 2048 if [ "$1" = "clean" ]; then - "$base/vendor/bin/robo" "$roboCommand" $* + "$base/vendor/bin/robo" "$roboCommand" "$@" else - "$base/vendor/bin/robo" "$roboCommand" -- $* + "$base/vendor/bin/robo" "$roboCommand" -- "$@" fi From f72c85c9f64f0015e3b5e37e4c2303ac050b4c58 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Thu, 4 Apr 2019 11:22:50 -0400 Subject: [PATCH 084/142] Hopefully working but maybe broken custom sorting --- lib/Context/Context.php | 5 - lib/Database.php | 140 +++++++++++++------------ lib/REST/NextCloudNews/V1_2.php | 10 +- lib/REST/TinyTinyRSS/API.php | 13 +-- tests/cases/Database/SeriesArticle.php | 1 - 5 files changed, 84 insertions(+), 85 deletions(-) diff --git a/lib/Context/Context.php b/lib/Context/Context.php index 858409f6..fb1236a3 100644 --- a/lib/Context/Context.php +++ b/lib/Context/Context.php @@ -9,7 +9,6 @@ namespace JKingWeb\Arsse\Context; class Context extends ExclusionContext { /** @var ExclusionContext */ public $not; - public $reverse = false; public $limit = 0; public $offset = 0; public $unread; @@ -31,10 +30,6 @@ class Context extends ExclusionContext { unset($this->not); } - public function reverse(bool $spec = null) { - return $this->act(__FUNCTION__, func_num_args(), $spec); - } - public function limit(int $spec = null) { return $this->act(__FUNCTION__, func_num_args(), $spec); } diff --git a/lib/Database.php b/lib/Database.php index 341b2671..ff74f657 100644 --- a/lib/Database.php +++ b/lib/Database.php @@ -1218,40 +1218,13 @@ class Database { )->run($feedID, $vId, $vHashUT, $vHashUC, $vHashTC); } - /** Computes an SQL query to find and retrieve data about articles in the database + /** Returns an associative array of result column names and their SQL computations for article queries * - * If an empty column list is supplied, a count of articles matching the context is queried instead - * - * @param string $user The user whose articles are to be queried - * @param Context $context The search context - * @param array $cols The columns to request in the result set + * This is used for whitelisting and defining both output column and order-by columns, as well as for resolution of some context options */ - protected function articleQuery(string $user, Context $context, array $cols = ["id"], array $sort = []): Query { - // validate input - if ($context->subscription()) { - $this->subscriptionValidateId($user, $context->subscription); - } - if ($context->folder()) { - $this->folderValidateId($user, $context->folder); - } - if ($context->folderShallow()) { - $this->folderValidateId($user, $context->folderShallow); - } - if ($context->edition()) { - $this->articleValidateEdition($user, $context->edition); - } - if ($context->article()) { - $this->articleValidateId($user, $context->article); - } - if ($context->label()) { - $this->labelValidateId($user, $context->label, false); - } - if ($context->labelName()) { - $this->labelValidateId($user, $context->labelName, true); - } - // prepare the output column list; the column definitions are also used later + protected function articleColumns(): array { $greatest = $this->db->sqlToken("greatest"); - $colDefs = [ + return [ 'id' => "arsse_articles.id", 'edition' => "latest_editions.edition", 'url' => "arsse_articles.url", @@ -1274,17 +1247,50 @@ class Database { 'media_url' => "arsse_enclosures.url", 'media_type' => "arsse_enclosures.type", ]; + } + + /** Computes an SQL query to find and retrieve data about articles in the database + * + * If an empty column list is supplied, a count of articles matching the context is queried instead + * + * @param string $user The user whose articles are to be queried + * @param Context $context The search context + * @param array $cols The columns to request in the result set + */ + protected function articleQuery(string $user, Context $context, array $cols = ["id"]): Query { + // validate input + if ($context->subscription()) { + $this->subscriptionValidateId($user, $context->subscription); + } + if ($context->folder()) { + $this->folderValidateId($user, $context->folder); + } + if ($context->folderShallow()) { + $this->folderValidateId($user, $context->folderShallow); + } + if ($context->edition()) { + $this->articleValidateEdition($user, $context->edition); + } + if ($context->article()) { + $this->articleValidateId($user, $context->article); + } + if ($context->label()) { + $this->labelValidateId($user, $context->label, false); + } + if ($context->labelName()) { + $this->labelValidateId($user, $context->labelName, true); + } + // prepare the output column list; the column definitions are also used later + $colDefs = $this->articleColumns(); if (!$cols) { // if no columns are specified return a count; don't borther with sorting $outColumns = "count(distinct arsse_articles.id) as count"; - $sortColumns = []; } else { // normalize requested output and sorting columns $norm = function($v) { return trim(strtolower(ValueInfo::normalize($v, ValueInfo::T_STRING))); }; $cols = array_map($norm, $cols); - $sort = array_map($norm, $sort); // make an output column list $outColumns = []; foreach ($cols as $col) { @@ -1294,31 +1300,6 @@ class Database { $outColumns[] = $colDefs[$col]." as ".$col; } $outColumns = implode(",", $outColumns); - // make an ORDER BY column list - $sortColumns = []; - foreach ($sort as $spec) { - $col = explode(" ", $spec, 1); - $order = $col[1] ?? ""; - $col = $col[0]; - if ($order === "desc") { - $order = " desc"; - } elseif ($order === "asc" || $order === "") { - $order = ""; - } else { - // column direction spec is bogus - continue; - } - if (!isset($colDefs[$col])) { - // column name spec is bogus - continue; - } elseif (in_array($col, $cols)) { - // if the sort column is also an output column, use it as-is - $sortColumns[] = $col.$order; - } else { - // otherwise if the column name is valid, use its expression - $sortColumns[] = $colDefs[$col].$order; - } - } } // define the basic query, to which we add lots of stuff where necessary $q = new Query( @@ -1339,10 +1320,6 @@ class Database { [$user, $user] ); $q->setLimit($context->limit, $context->offset); - // apply the ORDER BY definition computed above - array_walk($sortColumns, function($v, $k, Query $q) { - $q->setOrder($v); - }, $q); // handle the simple context options $options = [ // each context array consists of a column identifier (see $colDefs above), a comparison operator, a data type, and an option to pair with for BETWEEN evaluation @@ -1553,16 +1530,47 @@ class Database { * * @param string $user The user whose articles are to be listed * @param Context $context The search context - * @param array $cols The columns to return in the result set, any of: id, edition, url, title, author, content, guid, fingerprint, folder, subscription, feed, starred, unread, note, published_date, edited_date, modified_date, marked_date, subscription_title, media_url, media_type + * @param array $fieldss The columns to return in the result set, any of: id, edition, url, title, author, content, guid, fingerprint, folder, subscription, feed, starred, unread, note, published_date, edited_date, modified_date, marked_date, subscription_title, media_url, media_type + * @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"]): Db\Result { + 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); - $q->setOrder("arsse_articles.edited".($context->reverse ? " desc" : "")); - $q->setOrder("latest_editions.edition".($context->reverse ? " desc" : "")); + // make an ORDER BY column list + $colDefs = $this->articleColumns(); + // normalize requested output and sorting columns + $norm = function($v) { + return trim(strtolower((string) $v)); + }; + $fields = array_map($norm, $fields); + $sort = array_map($norm, $sort); + foreach ($sort as $spec) { + $col = explode(" ", $spec, 1); + $order = $col[1] ?? ""; + $col = $col[0]; + if ($order === "desc") { + $order = " desc"; + } elseif ($order === "asc" || $order === "") { + $order = ""; + } else { + // column direction spec is bogus + continue; + } + if (!isset($colDefs[$col])) { + // column name spec is bogus + continue; + } elseif (in_array($col, $fields)) { + // if the sort column is also an output column, use it as-is + $q->setOrder($col.$order); + } else { + // otherwise if the column name is valid, use its expression + $q->setOrder($colDefs[$col].$order); + } + } // perform the query and return results return $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues()); } diff --git a/lib/REST/NextCloudNews/V1_2.php b/lib/REST/NextCloudNews/V1_2.php index 7f4301c8..0df5032d 100644 --- a/lib/REST/NextCloudNews/V1_2.php +++ b/lib/REST/NextCloudNews/V1_2.php @@ -521,14 +521,10 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler { $c->limit($data['batchSize']); } // set the order of returned items - if ($data['oldestFirst']) { - $c->reverse(false); - } else { - $c->reverse(true); - } + $reverse = !$data['oldestFirst']; // set the edition mark-off; the database uses an or-equal comparison for internal consistency, but the protocol does not, so we must adjust by one if ($data['offset'] > 0) { - if ($c->reverse) { + if ($reverse) { $c->latestEdition($data['offset'] - 1); } else { $c->oldestEdition($data['offset'] + 1); @@ -579,7 +575,7 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler { "starred", "modified_date", "fingerprint", - ]); + ], [$reverse ? "edition desc" : "edition"]); } catch (ExceptionInput $e) { // ID of subscription or folder is not valid return new EmptyResponse(422); diff --git a/lib/REST/TinyTinyRSS/API.php b/lib/REST/TinyTinyRSS/API.php index 8bf85bcc..b274f20f 100644 --- a/lib/REST/TinyTinyRSS/API.php +++ b/lib/REST/TinyTinyRSS/API.php @@ -23,6 +23,7 @@ use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\ResponseInterface; use Zend\Diactoros\Response\JsonResponse as Response; use Zend\Diactoros\Response\EmptyResponse; +use Robo\Task\Archive\Pack; class API extends \JKingWeb\Arsse\REST\AbstractHandler { const LEVEL = 14; // emulated API level @@ -1438,7 +1439,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { // no context needed here break; case self::FEED_READ: - $c->markedSince(Date::sub("PT24H"))->unread(false); // FIXME: this selects any recently touched article which is read, not necessarily a recently read one + $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 break; default: // any actual feed @@ -1491,15 +1492,15 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { switch ($data['order_by']) { case "date_reverse": // sort oldest first - $c->reverse(false); + $order = ["edited_date"]; break; case "feed_dates": // sort newest first - $c->reverse(true); + $order = ["edited_date desc"]; break; default: - // in TT-RSS the default sort order is unusual for some of the special feeds; we do not implement this - $c->reverse(true); + // sort most recently marked for special feeds, newest first otherwise + $order = (!$cat && ($id == self::FEED_READ || $id == self::FEED_STARRED)) ? ["marked_date desc"] : ["edited_date desc"]; break; } // set the limit and offset @@ -1514,6 +1515,6 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { $c->oldestArticle($data['since_id'] + 1); } // return results - return Arsse::$db->articleList(Arsse::$user->id, $c, $fields); + return Arsse::$db->articleList(Arsse::$user->id, $c, $fields, $order); } } diff --git a/tests/cases/Database/SeriesArticle.php b/tests/cases/Database/SeriesArticle.php index 17b0ece3..fb547c19 100644 --- a/tests/cases/Database/SeriesArticle.php +++ b/tests/cases/Database/SeriesArticle.php @@ -460,7 +460,6 @@ trait SeriesArticle { 'Marked or labelled between 2000 and 2015' => [(new Context)->markedSince("2000-01-01T00:00:00Z")->notMarkedSince("2015-12-31T23:59:59Z"), [1,2,3,4,5,6,7,8,20]], 'Marked or labelled in 2010' => [(new Context)->markedSince("2010-01-01T00:00:00Z")->notMarkedSince("2010-12-31T23:59:59Z"), [2,4,6,20]], 'Paged results' => [(new Context)->limit(2)->oldestEdition(4), [4,5]], - 'Reversed paged results' => [(new Context)->limit(2)->latestEdition(7)->reverse(true), [7,6]], 'With label ID 1' => [(new Context)->label(1), [1,19]], 'With label ID 2' => [(new Context)->label(2), [1,5,20]], 'With label ID 1 or 2' => [(new Context)->labels([1,2]), [1,5,19,20]], From 12f23ddc164b332c3eb491c5b1c4bc80831ba11d Mon Sep 17 00:00:00 2001 From: "J. King" Date: Thu, 4 Apr 2019 17:21:23 -0400 Subject: [PATCH 085/142] Updated tests for arbitrary sorting --- lib/Database.php | 2 +- tests/cases/Database/SeriesArticle.php | 24 ++++++- tests/cases/REST/NextCloudNews/TestV1_2.php | 34 ++++----- tests/cases/REST/TinyTinyRSS/TestAPI.php | 80 ++++++++++----------- 4 files changed, 81 insertions(+), 59 deletions(-) diff --git a/lib/Database.php b/lib/Database.php index ff74f657..fa754fd7 100644 --- a/lib/Database.php +++ b/lib/Database.php @@ -1549,7 +1549,7 @@ class Database { $fields = array_map($norm, $fields); $sort = array_map($norm, $sort); foreach ($sort as $spec) { - $col = explode(" ", $spec, 1); + $col = explode(" ", $spec, 2); $order = $col[1] ?? ""; $col = $col[0]; if ($order === "desc") { diff --git a/tests/cases/Database/SeriesArticle.php b/tests/cases/Database/SeriesArticle.php index fb547c19..d47f918d 100644 --- a/tests/cases/Database/SeriesArticle.php +++ b/tests/cases/Database/SeriesArticle.php @@ -10,6 +10,7 @@ use JKingWeb\Arsse\Database; use JKingWeb\Arsse\Arsse; use JKingWeb\Arsse\Context\Context; use JKingWeb\Arsse\Misc\Date; +use JKingWeb\Arsse\Misc\ValueInfo; use Phake; trait SeriesArticle { @@ -508,6 +509,8 @@ trait SeriesArticle { 'Excluding tag ID 5' => [(new Context)->not->tag(5), [1,2,3,4,5,6]], 'Excluding tag "Technology"' => [(new Context)->not->tagName("Technology"), [1,2,3,4,19,20]], 'Excluding tag "Politics"' => [(new Context)->not->tagName("Politics"), [1,2,3,4,5,6]], + 'Excluding tags ID 1 and 5' => [(new Context)->not->tags([1,5]), [1,2,3,4]], + 'Excluding tags "Technology" and "Politics"' => [(new Context)->not->tagNames(["Technology","Politics"]), [1,2,3,4]], 'Excluding entire folder tree' => [(new Context)->not->folder(0), []], 'Excluding multiple folder trees' => [(new Context)->not->folders([1,5]), [1,2,3,4]], 'Excluding multiple folder trees including root' => [(new Context)->not->folders([0,1,5]), []], @@ -574,6 +577,25 @@ trait SeriesArticle { $this->assertEquals($this->fields, $test); } + /** @dataProvider provideOrderedLists */ + public function testListArticlesCheckingOrder(array $sortCols, array $exp) { + $act = ValueInfo::normalize(array_column(iterator_to_array(Arsse::$db->articleList("john.doe@example.com", null, ["id"], $sortCols)), "id"), ValueInfo::T_INT | ValueInfo::M_ARRAY); + $this->assertSame($exp, $act); + } + + public function provideOrderedLists() { + return [ + [["id"], [1,2,3,4,5,6,7,8,19,20]], + [["id asc"], [1,2,3,4,5,6,7,8,19,20]], + [["id desc"], [20,19,8,7,6,5,4,3,2,1]], + [["edition"], [1,2,3,4,5,6,7,8,19,20]], + [["edition asc"], [1,2,3,4,5,6,7,8,19,20]], + [["edition desc"], [20,19,8,7,6,5,4,3,2,1]], + [["id", "edition desk"], [1,2,3,4,5,6,7,8,19,20]], + [["id", "editio"], [1,2,3,4,5,6,7,8,19,20]], + ]; + } + public function testListArticlesWithoutAuthority() { Phake::when(Arsse::$user)->authorize->thenReturn(false); $this->assertException("notAuthorized", "User", "ExceptionAuthz"); @@ -1034,7 +1056,7 @@ trait SeriesArticle { /** @dataProvider provideArrayContextOptions */ public function testUseTooFewValuesInArrayContext(string $option) { $this->assertException("tooShort", "Db", "ExceptionInput"); - Arsse::$db->articleList($this->user, (new Context)->annotationTerms([])); + Arsse::$db->articleList($this->user, (new Context)->$option([])); } public function provideArrayContextOptions() { diff --git a/tests/cases/REST/NextCloudNews/TestV1_2.php b/tests/cases/REST/NextCloudNews/TestV1_2.php index 664db4e1..52291cb9 100644 --- a/tests/cases/REST/NextCloudNews/TestV1_2.php +++ b/tests/cases/REST/NextCloudNews/TestV1_2.php @@ -734,11 +734,11 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest { ['lastModified' => $t->getTimestamp()], ['oldestFirst' => false, 'batchSize' => 5, 'offset' => 0], // offset=0 should not set the latestEdition context ]; - Phake::when(Arsse::$db)->articleList(Arsse::$user->id, $this->anything(), $this->anything())->thenReturn(new Result($this->v($this->articles['db']))); - Phake::when(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->subscription(42), $this->anything())->thenThrow(new ExceptionInput("idMissing")); - Phake::when(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->folder(2112), $this->anything())->thenThrow(new ExceptionInput("idMissing")); - Phake::when(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->subscription(-1), $this->anything())->thenThrow(new ExceptionInput("typeViolation")); - Phake::when(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->folder(-1), $this->anything())->thenThrow(new ExceptionInput("typeViolation")); + 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 @@ -759,17 +759,17 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest { $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)->reverse(true), $this->anything()); - Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->subscription(42), $this->anything()); - Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->folder(2112), $this->anything()); - Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->subscription(-1), $this->anything()); - Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->folder(-1), $this->anything()); - Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->starred(true), $this->anything()); - Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(false)->limit(10)->oldestEdition(6), $this->anything()); // offset is one more than specified - Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->limit(5)->latestEdition(4), $this->anything()); // offset is one less than specified - Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->unread(true), $this->anything()); - Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, $this->equalTo((new Context)->reverse(true)->markedSince($t), 2), $this->anything()); - Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->reverse(true)->limit(5), $this->anything()); + 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() { @@ -958,6 +958,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)->reverse(false)->starred(true), $this->anything()); + Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->starred(true), $this->anything(), ["edition"]); } } diff --git a/tests/cases/REST/TinyTinyRSS/TestAPI.php b/tests/cases/REST/TinyTinyRSS/TestAPI.php index 91b370cf..dfe4077c 100644 --- a/tests/cases/REST/TinyTinyRSS/TestAPI.php +++ b/tests/cases/REST/TinyTinyRSS/TestAPI.php @@ -1749,19 +1749,19 @@ LONG_STRING; 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)->reverse(true); - Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->subscription(2112), ["id"])->thenThrow(new ExceptionInput("subjectMissing")); - Phake::when(Arsse::$db)->articleList($this->anything(), $c, ["id"])->thenReturn(new Result($this->v($this->articles))); - Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->starred(true), ["id"])->thenReturn(new Result($this->v([['id' => 1]]))); - Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->label(1088), ["id"])->thenReturn(new Result($this->v([['id' => 2]]))); - Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->unread(true), ["id"])->thenReturn(new Result($this->v([['id' => 3]]))); - Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->label(1088)->unread(true), ["id"])->thenReturn(new Result($this->v([['id' => 4]]))); - Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->subscription(42)->starred(true), ["id"])->thenReturn(new Result($this->v([['id' => 5]]))); - Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->subscription(42)->annotated(true), ["id"])->thenReturn(new Result($this->v([['id' => 6]]))); - Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->limit(5), ["id"])->thenReturn(new Result($this->v([['id' => 7]]))); - Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->offset(2), ["id"])->thenReturn(new Result($this->v([['id' => 8]]))); - Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->limit(5)->offset(2), ["id"])->thenReturn(new Result($this->v([['id' => 9]]))); - Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->oldestArticle(48), ["id"])->thenReturn(new Result($this->v([['id' => 10]]))); + $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([]), @@ -1793,9 +1793,9 @@ LONG_STRING; $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"])->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"])->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"])->thenReturn(new Result($this->v([['id' => 1003]]))); + 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"); } } @@ -1853,25 +1853,25 @@ LONG_STRING; 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)->reverse(true); - Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->subscription(2112), $this->anything())->thenThrow(new ExceptionInput("subjectMissing")); - Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->starred(true), $this->anything())->thenReturn($this->generateHeadlines(1)); - Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->label(1088), $this->anything())->thenReturn($this->generateHeadlines(2)); - Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->unread(true), $this->anything())->thenReturn($this->generateHeadlines(3)); - Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->label(1088)->unread(true), $this->anything())->thenReturn($this->generateHeadlines(4)); - Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->subscription(42)->starred(true), $this->anything())->thenReturn($this->generateHeadlines(5)); - Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->subscription(42)->annotated(true), $this->anything())->thenReturn($this->generateHeadlines(6)); - Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->limit(5), $this->anything())->thenReturn($this->generateHeadlines(7)); - Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->offset(2), $this->anything())->thenReturn($this->generateHeadlines(8)); - Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->limit(5)->offset(2), $this->anything())->thenReturn($this->generateHeadlines(9)); - Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->oldestArticle(48), $this->anything())->thenReturn($this->generateHeadlines(10)); - Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c), $this->anything())->thenReturn($this->generateHeadlines(11)); - Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->labelled(true), $this->anything())->thenReturn($this->generateHeadlines(12)); - Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->folderShallow(0), $this->anything())->thenReturn($this->generateHeadlines(13)); - Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->folderShallow(42), $this->anything())->thenReturn($this->generateHeadlines(14)); - Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->folder(42), $this->anything())->thenReturn($this->generateHeadlines(15)); - Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->reverse(false), $this->anything())->thenReturn($this->generateHeadlines(16)); - Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->subscription(42)->searchTerms(["interesting"]), $this->anything())->thenReturn($this->generateHeadlines(17)); + $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), @@ -1909,9 +1909,9 @@ LONG_STRING; $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())->thenReturn($this->generateHeadlines(1001)); - Phake::when(Arsse::$db)->articleList($this->anything(), $this->equalTo((clone $c)->unread(true)->modifiedSince(Date::sub("PT24H")), 2), $this->anything())->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())->thenReturn($this->generateHeadlines(1003)); + 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"); } } @@ -1990,7 +1990,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)->reverse(true)->subscription(2112), $this->anything())->thenThrow(new ExceptionInput("subjectMissing")); + Phake::when(Arsse::$db)->articleList($this->anything(), (new Context)->limit(200)->subscription(2112), $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], @@ -2005,7 +2005,7 @@ LONG_STRING; ]); $this->assertMessage($exp, $test); // test 'include_header' with skip - Phake::when(Arsse::$db)->articleList($this->anything(), (new Context)->reverse(true)->limit(1)->subscription(42), $this->anything())->thenReturn($this->generateHeadlines(1867)); + Phake::when(Arsse::$db)->articleList($this->anything(), (new Context)->limit(1)->subscription(42), $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 c6d241e653345bd160ceac0f6be98982d4789e1b Mon Sep 17 00:00:00 2001 From: "J. King" Date: Thu, 4 Apr 2019 17:57:12 -0400 Subject: [PATCH 086/142] Implement Fever item list --- lib/REST/Fever/API.php | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/lib/REST/Fever/API.php b/lib/REST/Fever/API.php index 5dcb9b08..0b79c482 100644 --- a/lib/REST/Fever/API.php +++ b/lib/REST/Fever/API.php @@ -225,4 +225,41 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { } return $out; } + + protected function getItems(array $G): array { + $c = (new Context)->limit(50); + $reverse = false; + // handle the standard options + if ($G['with_ids']) { + $c->articles(explode(",", $G['with_ids'])); + } elseif ($G['since_id']) { + $c->oldestArticle($G['since_id'] + 1); + } elseif ($G['max_id']) { + $c->newestArticle($G['max_id'] - 1); + $reverse = true; + } + // handle the undocumented options + if ($G['group_ids']) { + $c->tags(explode(",", $G['group_ids'])); + } + if ($G['feed_ids']) { + $c->subscriptions(explode(",", $G['feed_ids'])); + } + // get results + $out = []; + $order = $reverse ? "id desc" : "id"; + foreach (Arsse::$db->articleList(Arsse::$user->id, $c, ["id", "subscription", "title", "author", "content", "url", "starred", "unread", "published_date"], [$order]) as $r) { + $out[] = [ + 'id' => (int) $r['id'], + 'feed_id' => (int) $r['subscription'], + 'title' => (string) $r['title'], + 'author' => (string) $r['author'], + 'html' => (string) $r['content'], + 'is_saved' => (int) $r['starred'], + 'is_read' => (int) !$r['unread'], + 'created_on_time' => Date::transform($r['published_date'], "unix", "sql"), + ]; + } + return $out; + } } From 7c85e837df2b6f92c64387bca82b39e965824219 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Thu, 4 Apr 2019 18:01:57 -0400 Subject: [PATCH 087/142] Documentation update --- CHANGELOG | 4 ++++ README.md | 1 - 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG b/CHANGELOG index edc4b0ab..79a1be5d 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -5,6 +5,10 @@ New features: - Support for the Fever protocol (see README.md for details) - Command line functionality for clearing a password, disabling the account - Command line options for dealing with Fever passwords +- Command line functionality for exporting subscriptions to OPML + +Bug fixes: +- Sort Tiny Tiny RSS special feeds according to special ordering Version 0.7.1 (2019-03-25) ========================== diff --git a/README.md b/README.md index ab7dc2ed..67600720 100644 --- a/README.md +++ b/README.md @@ -148,7 +148,6 @@ We are not aware of any other extensions to the TTRSS protocol. If you know of a - Full-text search is not yet employed with any database, including PostgreSQL - Article hashes are normally SHA1; The Arsse uses SHA256 hashes - Article attachments normally have unique IDs; The Arsse always gives attachments an ID of `"0"` -- The default sort order of the `getHeadlines` operation normally uses custom sorting for "special" feeds; The Arsse's default sort order is equivalent to `feed_dates` for all feeds - The `getCounters` operation normally omits members with zero unread; The Arsse includes everything to appease some clients #### Other notes From 982f09c9aa78674e13bfc467fab1756c57baba3e Mon Sep 17 00:00:00 2001 From: "J. King" Date: Thu, 4 Apr 2019 18:05:26 -0400 Subject: [PATCH 088/142] Upgrade notes --- UPGRADING | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/UPGRADING b/UPGRADING index a837396d..ea3c84ad 100644 --- a/UPGRADING +++ b/UPGRADING @@ -10,6 +10,12 @@ usually prudent: - If installing from source, update dependencies with: `composer install -o --no-dev` +Upgrading from 0.7.1 to 0.8.0 +============================= + +- The database schema has changed from rev4 to rev5; if upgrading the database + manually, apply the 4.sql file + Upgrading from 0.5.1 to 0.6.0 ============================= From 0752e9cf3dc0352e9a7af8de3ddcdba68d622b30 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Thu, 4 Apr 2019 19:37:48 -0400 Subject: [PATCH 089/142] Implement Fever sync --- lib/REST/Fever/API.php | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/lib/REST/Fever/API.php b/lib/REST/Fever/API.php index 0b79c482..62212f86 100644 --- a/lib/REST/Fever/API.php +++ b/lib/REST/Fever/API.php @@ -138,6 +138,12 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { // TODO: implement hot links $out['inks'] = []; } + if ($G['unread_item_ids']) { + $out['unread_item_ids'] = $this->getItemIds((new Context)->unread(true)); + } + if ($G['saved_item_ids']) { + $out['saved_item_ids'] = $this->getItemIds((new Context)->starred(true)); + } return $out; } @@ -262,4 +268,12 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { } return $out; } + + protected function getItemIds(Context $c = null): array { + $out = []; + foreach (Arsse::$db->articleList(Arsse::$user->id, $c) as $r) { + $out[] = (int) $r['id']; + } + return $out; + } } From 0ef606aa03caed72da3e1abe0b78e932f324956b Mon Sep 17 00:00:00 2001 From: "J. King" Date: Fri, 5 Apr 2019 08:20:05 -0400 Subject: [PATCH 090/142] Return string list of item IDs --- lib/REST/Fever/API.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/REST/Fever/API.php b/lib/REST/Fever/API.php index 62212f86..2504463e 100644 --- a/lib/REST/Fever/API.php +++ b/lib/REST/Fever/API.php @@ -269,11 +269,11 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { return $out; } - protected function getItemIds(Context $c = null): array { + protected function getItemIds(Context $c = null): string { $out = []; foreach (Arsse::$db->articleList(Arsse::$user->id, $c) as $r) { $out[] = (int) $r['id']; } - return $out; + return implode(",", $out); } } From e3d2215920538045001eacfcd770dcc1ef09b41b Mon Sep 17 00:00:00 2001 From: "J. King" Date: Fri, 5 Apr 2019 11:03:15 -0400 Subject: [PATCH 091/142] Style fixes --- lib/CLI.php | 1 + lib/Context/ExclusionContext.php | 2 +- lib/Database.php | 242 ++++++++++++------------- lib/Db/Driver.php | 14 +- lib/Db/SQLite3/PDODriver.php | 4 +- lib/Db/SQLite3/PDOStatement.php | 2 +- lib/Db/Statement.php | 2 +- lib/REST/TinyTinyRSS/API.php | 2 +- lib/REST/TinyTinyRSS/Search.php | 8 +- tests/cases/Database/SeriesArticle.php | 6 +- tests/cases/REST/Fever/TestAPI.php | 1 - 11 files changed, 145 insertions(+), 139 deletions(-) diff --git a/lib/CLI.php b/lib/CLI.php index 96936980..617a68b7 100644 --- a/lib/CLI.php +++ b/lib/CLI.php @@ -128,6 +128,7 @@ USAGE_TEXT; } else { return $this->userAddOrSetPassword("passwordSet", $args[""], $args[""], $args["--oldpass"]); } + // no break case "unset-pass": if ($args['--fever']) { $this->getFever()->unregister($args[""]); diff --git a/lib/Context/ExclusionContext.php b/lib/Context/ExclusionContext.php index 7cf45cb3..e7323ea7 100644 --- a/lib/Context/ExclusionContext.php +++ b/lib/Context/ExclusionContext.php @@ -87,7 +87,7 @@ class ExclusionContext { $spec[$a] = null; } } - return array_values(array_unique(array_filter($spec, function ($v) { + return array_values(array_unique(array_filter($spec, function($v) { return !is_null($v); }))); } diff --git a/lib/Database.php b/lib/Database.php index fa754fd7..69f1ae1b 100644 --- a/lib/Database.php +++ b/lib/Database.php @@ -14,9 +14,9 @@ use JKingWeb\Arsse\Misc\Date; use JKingWeb\Arsse\Misc\ValueInfo; /** The high-level interface with the database - * + * * The database stores information on the following things: - * + * * - Users * - Subscriptions to feeds, which belong to users * - Folders, which belong to users and contain subscriptions @@ -28,9 +28,9 @@ use JKingWeb\Arsse\Misc\ValueInfo; * - Sessions, used by some protocols to identify users across periods of time * - Tokens, similar to sessions, but with more control over their properties * - Metadata, used internally by the server - * + * * The various methods of this class perform operations on these things, with - * each public method prefixed with the thing it concerns e.g. userRemove() + * each public method prefixed with the thing it concerns e.g. userRemove() * 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 @@ -54,7 +54,7 @@ class Database { public $db; /** Constructs the database interface - * + * * @param boolean $initialize Whether to attempt to upgrade the databse schema when constructing */ public function __construct($initialize = true) { @@ -71,7 +71,7 @@ class Database { return debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 3)[2]['function']; } - /** Lists the available database drivers, as an associative array with + /** Lists the available database drivers, as an associative array with * fully-qualified class names as keys, and human-readable descriptions as values */ public static function driverList(): array { @@ -105,9 +105,9 @@ class Database { } /** Computes the column and value text of an SQL "SET" clause, validating arbitrary input against a whitelist - * + * * Returns an indexed array containing the clause text, an array of types, and another array of values - * + * * @param array $props An associative array containing untrusted data; keys are column names * @param array $valid An associative array containing a whitelist: keys are column names, and values are strings representing data types */ @@ -130,9 +130,9 @@ class Database { } /** Computes the contents of an SQL "IN()" clause, for each input value either embedding the value or producing a parameter placeholder - * + * * Returns an indexed array containing the clause text, an array of types, and an array of values. Note that the array of output values may not match the array of input values - * + * * @param array $values Arbitrary values * @param string $type A single data type applied to each value */ @@ -147,7 +147,7 @@ class Database { $params = []; $count = 0; $convType = Db\AbstractStatement::TYPE_NORM_MAP[Statement::TYPES[$type]]; - foreach($values as $v) { + foreach ($values as $v) { $v = ValueInfo::normalize($v, $convType, null, "sql"); if (is_null($v)) { // nulls are pointless to have @@ -176,11 +176,11 @@ class Database { } /** Computes basic LIKE-based text search constraints for use in a WHERE clause - * + * * Returns an indexed array containing the clause text, an array of types, and another array of values - * + * * The clause is structured such that all terms must be present across any of the columns - * + * * @param string[] $terms The terms to search for * @param string[] $cols The columns to match against; these are -not- sanitized, so much -not- come directly from user input * @param boolean $matchAny Whether the search is successful when it matches any (true) or all (false) terms @@ -194,7 +194,7 @@ class Database { $values = []; $like = $this->db->sqlToken("like"); $embedSet = sizeof($terms) > ((int) (self::LIMIT_SET_SIZE / sizeof($cols))); - foreach($terms as $term) { + foreach ($terms as $term) { $embedTerm = ($embedSet && strlen($term) <= self::LIMIT_SET_STRING_LENGTH); $term = str_replace(["%", "_", "^"], ["^%", "^_", "^^"], $term); $term = "%$term%"; @@ -249,7 +249,7 @@ 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 */ @@ -298,7 +298,7 @@ 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 */ @@ -329,10 +329,10 @@ class Database { } /** Explicitly removes a session from the database - * - * Sessions may also be invalidated as they expire, and then be automatically pruned. + * + * Sessions may also be invalidated as they expire, and then be automatically pruned. * This function can be used to explicitly invalidate a session after a user logs out - * + * * @param string $user The user who owns the session to be destroyed * @param string $id The identifier of the session to destroy */ @@ -346,7 +346,7 @@ class Database { } /** Resumes a session, returning available session data - * + * * This also has the side effect of refreshing the session if it is near its timeout */ public function sessionResume(string $id): array { @@ -380,8 +380,8 @@ class Database { return (($now + $diff) >= $expiry->getTimestamp()); } - /** Creates a new token for the given user in the given class - * + /** Creates a new token for the given user in the given class + * * @param string $user The user for whom to create the token * @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 @@ -403,7 +403,7 @@ class Database { } /** Revokes one or all tokens for a user in a class - * + * * @param string $user The user who owns the token to be revoked * @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 @@ -436,14 +436,14 @@ class Database { } /** Adds a folder for containing newsfeed subscriptions, returning an integer identifying the created folder - * + * * The $data array may contain the following keys: - * + * * - "name": A folder name, which must be a non-empty string not composed solely of whitespace; this key is required * - "parent": An integer (or null) identifying a parent folder; this key is optional - * + * * If a folder with the same name and parent already exists, this is an error - * + * * @param string $user The user who will own the folder * @param array $data An associative array defining the folder */ @@ -462,15 +462,15 @@ class Database { } /** Returns a result set listing a user's folders - * + * * Each record in the result set contains: - * + * * - "id": The folder identifier, an integer * - "name": The folder's name, a string * - "parent": The integer identifier of the folder's parent, or null * - "children": The number of child folders contained in the given folder - * - "feeds": The number of newsfeed subscriptions contained in the given folder, not including subscriptions in descendent folders - * + * - "feeds": The number of newsfeed subscriptions contained in the given folder, not including subscriptions in descendent folders + * * @param string $uer The user whose folders are to be listed * @param integer|null $parent Restricts the list to the descendents of the specified folder identifier * @param boolean $recursive Whether to list all descendents (true) or only direct children (false) @@ -505,9 +505,9 @@ class Database { } /** Deletes a folder from the database - * + * * Any descendent folders are also deleted, as are all newsfeed subscriptions contained in the deleted folder tree - * + * * @param string $user The user to whom the folder to be deleted belongs * @param integer $id The identifier of the folder to delete */ @@ -541,14 +541,14 @@ class Database { } /** Modifies the properties of a folder - * + * * The $data array must contain one or more of the following keys: - * + * * - "name": A new folder name, which must be a non-empty string not composed solely of whitespace * - "parent": An integer (or null) identifying a parent folder - * + * * If a folder with the new name and parent combination already exists, this is an error; it is also an error to move a folder to itself or one of its descendents - * + * * @param string $user The user who owns the folder to be modified * @param integer $id The identifier of the folder to be modified * @param array $data An associative array of properties to modify. Anything not specified will remain unchanged @@ -590,9 +590,9 @@ class Database { } /** Ensures the specified folder exists and raises an exception otherwise - * - * Returns an associative array containing the id, name, and parent of the folder if it exists - * + * + * Returns an associative array containing the id, name, and parent of the folder if it exists + * * @param string $user The user who owns the folder to be validated * @param integer|null $id The identifier of the folder to validate; null or zero represent the implied root folder * @param boolean $subject Whether the folder 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 @@ -668,7 +668,7 @@ class Database { } /** Ensures a prospective folder name is valid, and optionally ensure it is not a duplicate if renamed - * + * * @param string $name The name to check * @param boolean $checkDuplicates Whether to also check if the new name would cause a collision * @param integer|null $parent The parent folder context in which to check for duplication @@ -695,7 +695,7 @@ class Database { } /** Adds a subscription to a newsfeed, and returns the numeric identifier of the added subscription - * + * * @param string $user The user which will own the subscription * @param string $url The URL of the newsfeed or discovery source * @param string $fetchUser The user name required to access the newsfeed, if applicable @@ -731,7 +731,7 @@ class Database { } /** Lists a user's subscriptions, returning various data - * + * * @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 * @param boolean $recursive Whether to list subscriptions of descendent folders as well as the selected folder @@ -802,8 +802,8 @@ class Database { } /** Deletes a subscription from the database - * - * This has the side effect of deleting all marks the user has set on articles + * + * This has the side effect of deleting all marks the user has set on articles * belonging to the newsfeed, but may not delete the articles themselves, as * other users may also be subscribed to the same newsfeed. There is also a * configurable retention period for newsfeeds @@ -823,7 +823,7 @@ class Database { } /** 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 @@ -855,14 +855,14 @@ class Database { } /** Modifies the properties of a subscription - * + * * The $data array must contain one or more of the following keys: - * + * * - "title": The title of the newsfeed * - "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) - * + * * @param string $user The user whose subscription is to be modified * @param integer $id the numeric identifier of the subscription to modfify * @param array $data An associative array of properties to modify; any keys not specified will be left unchanged @@ -908,7 +908,7 @@ class Database { } /** Returns an indexed array listing the tags assigned to a subscription - * + * * @param string $user The user whose tags are to be listed * @param integer $id The numeric identifier of the subscription whose tags are to be listed * @param boolean $byName Whether to return the tag names (true) instead of the numeric tag identifiers (false) @@ -924,14 +924,14 @@ class Database { } /** 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 + * 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 */ @@ -965,9 +965,9 @@ 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 - * + * * @param string $user The user who owns the subscription to be validated * @param integer $id The identifier of the subscription to validate * @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 @@ -990,7 +990,7 @@ class Database { } /** Attempts to refresh a newsfeed, returning an indication of success - * + * * @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 */ @@ -1146,7 +1146,7 @@ class Database { } /** Deletes orphaned newsfeeds from the database - * + * * Newsfeeds are orphaned if no users are subscribed to them. Deleting a newsfeed also deletes its articles */ public function feedCleanup(): bool { @@ -1167,14 +1167,14 @@ class Database { } /** Retrieves various identifiers for the latest $count articles in the given newsfeed. The identifiers are: - * + * * - "id": The database record key for the article * - "guid": The (theoretically) unique identifier for the article * - "edited": The time at which the article was last edited, per the newsfeed * - "url_title_hash": A cryptographic hash of the article URL and its title * - "url_content_hash": A cryptographic hash of the article URL and its content * - "title_content_hash": A cryptographic hash of the article title and its content - * + * * @param integer $feedID The numeric identifier of the feed * @param integer $count The number of records to return */ @@ -1187,14 +1187,14 @@ class Database { } /** Retrieves various identifiers for articles in the given newsfeed which match the input identifiers. The output identifiers are: - * + * * - "id": The database record key for the article * - "guid": The (theoretically) unique identifier for the article * - "edited": The time at which the article was last edited, per the newsfeed * - "url_title_hash": A cryptographic hash of the article URL and its title * - "url_content_hash": A cryptographic hash of the article URL and its content * - "title_content_hash": A cryptographic hash of the article title and its content - * + * * @param integer $feedID The numeric identifier of the feed * @param array $ids An array of GUIDs of articles * @param array $hashesUT An array of hashes of articles' URL and title @@ -1219,7 +1219,7 @@ class Database { } /** 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 */ protected function articleColumns(): array { @@ -1250,9 +1250,9 @@ class Database { } /** Computes an SQL query to find and retrieve data about articles in the database - * + * * If an empty column list is supplied, a count of articles matching the context is queried instead - * + * * @param string $user The user whose articles are to be queried * @param Context $context The search context * @param array $cols The columns to request in the result set @@ -1270,7 +1270,7 @@ class Database { } if ($context->edition()) { $this->articleValidateEdition($user, $context->edition); - } + } if ($context->article()) { $this->articleValidateId($user, $context->article); } @@ -1356,7 +1356,7 @@ class Database { } elseif ($pair && $context->$pair()) { // option is paired with another which is also being used if ($op === ">=") { - $q->setWhere("{$colDefs[$col]} BETWEEN ? AND ?", [$type, $type], [$context->$m, $context->$pair]); + $q->setWhere("{$colDefs[$col]} BETWEEN ? AND ?", [$type, $type], [$context->$m, $context->$pair]); } else { // option has already been paired continue; @@ -1380,7 +1380,7 @@ class Database { } elseif ($pair && $context->not->$pair()) { // option is paired with another which is also being used if ($op === ">=") { - $q->setWhereNot("{$colDefs[$col]} BETWEEN ? AND ?", [$type, $type], [$context->not->$m, $context->not->$pair]); + $q->setWhereNot("{$colDefs[$col]} BETWEEN ? AND ?", [$type, $type], [$context->not->$m, $context->not->$pair]); } else { // option has already been paired continue; @@ -1458,7 +1458,7 @@ class Database { } } if ($seen) { - $spec = $opt['cte_name']."(".implode(",",$opt['cte_cols']).")"; + $spec = $opt['cte_name']."(".implode(",", $opt['cte_cols']).")"; $q->setCTE($spec, $opt['cte_body'], $opt['cte_types'], $opt['cte_values']); } } @@ -1525,9 +1525,9 @@ class Database { } /** Lists articles in the database which match a given query context - * + * * If an empty column list is supplied, a count of articles is returned instead - * + * * @param string $user The user whose articles are to be listed * @param Context $context The search context * @param array $fieldss The columns to return in the result set, any of: id, edition, url, title, author, content, guid, fingerprint, folder, subscription, feed, starred, unread, note, published_date, edited_date, modified_date, marked_date, subscription_title, media_url, media_type @@ -1576,7 +1576,7 @@ class Database { } /** Returns a count of articles which match the given query context - * + * * @param string $user The user whose articles are to be counted * @param Context $context The search context */ @@ -1590,13 +1590,13 @@ class Database { } /** Applies one or multiple modifications to all articles matching the given query context - * + * * The $data array enumerates the modifications to perform and must contain one or more of the following keys: - * + * * - "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 * - "note": A string containing a freeform plain-text note for the article - * + * * @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 @@ -1680,9 +1680,9 @@ class Database { } /** Returns statistics about the articles starred by the given user - * + * * The associative array returned has the following keys: - * + * * - "total": The count of all starred articles * - "unread": The count of starred articles which are unread * - "read": The count of starred articles which are read @@ -1704,7 +1704,7 @@ class Database { } /** Returns an indexed array listing the labels assigned to an article - * + * * @param string $user The user whose labels are to be listed * @param integer $id The numeric identifier of the article whose labels are to be listed * @param boolean $byName Whether to return the label names (true) instead of the numeric label identifiers (false) @@ -1768,9 +1768,9 @@ class Database { } /** Ensures the specified article exists and raises an exception otherwise - * - * Returns an associative array containing the id and latest edition of the article if it exists - * + * + * Returns an associative array containing the id and latest edition of the article if it exists + * * @param string $user The user who owns the article to be validated * @param integer $id The identifier of the article to validate */ @@ -1795,9 +1795,9 @@ class Database { } /** Ensures the specified article edition exists and raises an exception otherwise - * - * Returns an associative array containing the edition id, article id, and latest edition of the edition if it exists - * + * + * Returns an associative array containing the edition id, article id, and latest edition of the edition if it exists + * * @param string $user The user who owns the edition to be validated * @param integer $id The identifier of the edition to validate */ @@ -1848,9 +1848,9 @@ class Database { } /** Creates a label, and returns its numeric identifier - * + * * Labels are discrete objects in the database and can be associated with multiple articles; an article may in turn be associated with multiple labels - * + * * @param string $user The user who will own the created label * @param array $data An associative array defining the label's properties; currently only "name" is understood */ @@ -1867,14 +1867,14 @@ class Database { } /** Lists a user's article labels - * + * * The following keys are included in each record: - * + * * - "id": The label's numeric identifier * - "name" The label's textual name * - "articles": The count of articles which have the label assigned to them * - "read": How many of the total articles assigned to the label are read - * + * * @param string $user The user whose labels are to be listed * @param boolean $includeEmpty Whether to include (true) or supress (false) labels which have no articles assigned to them */ @@ -1911,9 +1911,9 @@ class Database { } /** Deletes a label from the database - * + * * Any articles associated with the label remains untouched - * + * * @param string $user The owner of the label to remove * @param integer|string $id The numeric identifier or name of the label * @param boolean $byName Whether to interpret the $id parameter as the label's name (true) or identifier (false) @@ -1933,14 +1933,14 @@ class Database { } /** Retrieves the properties of a label - * + * * The following keys are included in the output array: - * + * * - "id": The label's numeric identifier * - "name" The label's textual name * - "articles": The count of articles which have the label assigned to them * - "read": How many of the total articles assigned to the label are read - * + * * @param string $user The owner of the label to remove * @param integer|string $id The numeric identifier or name of the label * @param boolean $byName Whether to interpret the $id parameter as the label's name (true) or identifier (false) @@ -1981,7 +1981,7 @@ class Database { } /** Sets the properties of a label - * + * * @param string $user The owner of the label to query * @param integer|string $id The numeric identifier or name of the label * @param array $data An associative array defining the label's properties; currently only "name" is understood @@ -2013,7 +2013,7 @@ class Database { } /** Returns an indexed array of article identifiers assigned to a label - * + * * @param string $user The owner of the label to query * @param integer|string $id The numeric identifier or name of the label * @param boolean $byName Whether to interpret the $id parameter as the label's name (true) or identifier (false) @@ -2039,7 +2039,7 @@ class Database { } /** Makes or breaks associations between a given label and articles matching the given query context - * + * * @param string $user The owner of the label * @param integer|string $id The numeric identifier or name of the label * @param Context $context The query context matching the desired articles @@ -2080,9 +2080,9 @@ class Database { } /** Ensures the specified label identifier or name is valid (and optionally whether it exists) and raises an exception otherwise - * - * Returns an associative array containing the id, name of the label if it exists - * + * + * Returns an associative array containing the id, name of the label if it exists + * * @param string $user The user who owns the label to be validated * @param integer|string $id The numeric identifier or name of the label to validate * @param boolean $byName Whether to interpret the $id parameter as the label's name (true) or identifier (false) @@ -2127,9 +2127,9 @@ class Database { } /** Creates a tag, and returns its numeric identifier - * + * * Tags are discrete objects in the database and can be associated with multiple subscriptions; a subscription may in turn be associated with multiple tags - * + * * @param string $user The user who will own the created tag * @param array $data An associative array defining the tag's properties; currently only "name" is understood */ @@ -2146,13 +2146,13 @@ class Database { } /** Lists a user's subscription tags - * + * * The following keys are included in each record: - * + * * - "id": The tag's numeric identifier * - "name" The tag's textual name * - "subscriptions": The count of subscriptions which have the tag assigned to them - * + * * @param string $user The user whose tags are to be listed * @param boolean $includeEmpty Whether to include (true) or supress (false) tags which have no subscriptions assigned to them */ @@ -2177,14 +2177,14 @@ class Database { } /** Lists the associations between all tags and subscription - * + * * The following keys are included in each record: - * + * * - "tag_id": The tag's numeric identifier * - "tag_name" The tag's textual name * - "subscription_id": The numeric identifier of the associated subscription * - "subscription_name" The subscription's textual name - * + * * @param string $user The user whose tags are to be listed */ public function tagSummarize(string $user): Db\Result { @@ -2205,9 +2205,9 @@ class Database { } /** Deletes a tag from the database - * + * * Any subscriptions associated with the tag remains untouched - * + * * @param string $user The owner of the tag to remove * @param integer|string $id The numeric identifier or name of the tag * @param boolean $byName Whether to interpret the $id parameter as the tag's name (true) or identifier (false) @@ -2227,13 +2227,13 @@ class Database { } /** Retrieves the properties of a tag - * + * * The following keys are included in the output array: - * + * * - "id": The tag's numeric identifier * - "name" The tag's textual name * - "subscriptions": The count of subscriptions which have the tag assigned to them - * + * * @param string $user The owner of the tag to remove * @param integer|string $id The numeric identifier or name of the tag * @param boolean $byName Whether to interpret the $id parameter as the tag's name (true) or identifier (false) @@ -2262,7 +2262,7 @@ class Database { } /** Sets the properties of a tag - * + * * @param string $user The owner of the tag to query * @param integer|string $id The numeric identifier or name of the tag * @param array $data An associative array defining the tag's properties; currently only "name" is understood @@ -2294,7 +2294,7 @@ class Database { } /** Returns an indexed array of subscription identifiers assigned to a tag - * + * * @param string $user The owner of the tag to query * @param integer|string $id The numeric identifier or name of the tag * @param boolean $byName Whether to interpret the $id parameter as the tag's name (true) or identifier (false) @@ -2320,7 +2320,7 @@ class Database { } /** Makes or breaks associations between a given tag and specified subscriptions - * + * * @param string $user The owner of the tag * @param integer|string $id The numeric identifier or name of the tag * @param integer[] $context The query context matching the desired subscriptions @@ -2339,7 +2339,7 @@ class Database { $q1 = $this->db->prepare( "UPDATE arsse_tag_members set assigned = ?, modified = CURRENT_TIMESTAMP - where tag = ? and assigned <> ? and subscription in (select id from arsse_subscriptions where owner = ? and id in ($inClause))", + where tag = ? and assigned <> ? and subscription in (select id from arsse_subscriptions where owner = ? and id in ($inClause))", "bool", "int", "bool", @@ -2370,9 +2370,9 @@ class Database { } /** Ensures the specified tag identifier or name is valid (and optionally whether it exists) and raises an exception otherwise - * - * Returns an associative array containing the id, name of the tag if it exists - * + * + * Returns an associative array containing the id, name of the tag if it exists + * * @param string $user The user who owns the tag to be validated * @param integer|string $id The numeric identifier or name of the tag to validate * @param boolean $byName Whether to interpret the $id parameter as the tag's name (true) or identifier (false) diff --git a/lib/Db/Driver.php b/lib/Db/Driver.php index b0f572c8..7f04dc6c 100644 --- a/lib/Db/Driver.php +++ b/lib/Db/Driver.php @@ -20,7 +20,7 @@ interface Driver { public static function driverName(): string; /** Returns the version of the schema of the opened database; if uninitialized should return 0 - * + * * Normally the version is stored under the 'schema_version' key in the arsse_meta table, but another method may be used if appropriate */ public function schemaVersion(): int; @@ -32,7 +32,7 @@ interface Driver { public function begin(bool $lock = false): Transaction; /** Manually begins a real or synthetic transactions, with real or synthetic nesting, and returns its numeric ID - * + * * If the database backend does not implement savepoints, IDs must still be tracked as if it does */ public function savepointCreate(): int; @@ -44,7 +44,7 @@ interface Driver { public function savepointUndo(int $index = null): bool; /** Performs an in-place upgrade of the database schema - * + * * The driver may choose not to implement in-place upgrading, in which case an exception should be thrown */ public function schemaUpdate(int $to): bool; @@ -62,15 +62,15 @@ interface Driver { public function prepareArray(string $query, array $paramTypes): Statement; /** Reports whether the database character set is correct/acceptable - * + * * The backend must be able to accept and provide UTF-8 text; information may be stored in any encoding capable of representing the entire range of Unicode */ public function charsetAcceptable(): bool; /** Returns an implementation-dependent form of a reference SQL function or operator - * + * * The tokens the implementation must understand are: - * + * * - "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 @@ -78,7 +78,7 @@ interface Driver { public function sqlToken(string $token): string; /** Returns a string literal which is properly escaped to guard against SQL injections. Delimiters are included in the output string - * + * * This functionality should be avoided in favour of using statement parameters whenever possible */ public function literalString(string $str): string; diff --git a/lib/Db/SQLite3/PDODriver.php b/lib/Db/SQLite3/PDODriver.php index b1cff198..c6d7ad40 100644 --- a/lib/Db/SQLite3/PDODriver.php +++ b/lib/Db/SQLite3/PDODriver.php @@ -50,7 +50,7 @@ class PDODriver extends AbstractPDODriver { /** @codeCoverageIgnore */ public function exec(string $query): bool { - // because PDO uses sqlite3_prepare() internally instead of sqlite3_prepare_v2(), + // because PDO uses sqlite3_prepare() internally instead of sqlite3_prepare_v2(), // we have to retry ourselves in cases of schema changes // the SQLite3 class is not similarly affected $attempts = 0; @@ -68,7 +68,7 @@ class PDODriver extends AbstractPDODriver { /** @codeCoverageIgnore */ public function query(string $query): \JKingWeb\Arsse\Db\Result { - // because PDO uses sqlite3_prepare() internally instead of sqlite3_prepare_v2(), + // because PDO uses sqlite3_prepare() internally instead of sqlite3_prepare_v2(), // we have to retry ourselves in cases of schema changes // the SQLite3 class is not similarly affected $attempts = 0; diff --git a/lib/Db/SQLite3/PDOStatement.php b/lib/Db/SQLite3/PDOStatement.php index 166fe313..eb4fdfe4 100644 --- a/lib/Db/SQLite3/PDOStatement.php +++ b/lib/Db/SQLite3/PDOStatement.php @@ -12,7 +12,7 @@ class PDOStatement extends \JKingWeb\Arsse\Db\PDOStatement { /** @codeCoverageIgnore */ public function runArray(array $values = []): \JKingWeb\Arsse\Db\Result { - // because PDO uses sqlite3_prepare() internally instead of sqlite3_prepare_v2(), + // because PDO uses sqlite3_prepare() internally instead of sqlite3_prepare_v2(), // we have to retry ourselves in cases of schema changes // the SQLite3 class is not similarly affected $attempts = 0; diff --git a/lib/Db/Statement.php b/lib/Db/Statement.php index b85ceca4..0ed86856 100644 --- a/lib/Db/Statement.php +++ b/lib/Db/Statement.php @@ -24,7 +24,7 @@ interface Statement { 'str' => self::T_STRING, 'bool' => self::T_BOOLEAN, 'boolean' => self::T_BOOLEAN, - 'bit' => self::T_BOOLEAN, + 'bit' => self::T_BOOLEAN, 'strict int' => self::T_NOT_NULL + self::T_INTEGER, 'strict integer' => self::T_NOT_NULL + self::T_INTEGER, 'strict float' => self::T_NOT_NULL + self::T_FLOAT, diff --git a/lib/REST/TinyTinyRSS/API.php b/lib/REST/TinyTinyRSS/API.php index b274f20f..26cf441b 100644 --- a/lib/REST/TinyTinyRSS/API.php +++ b/lib/REST/TinyTinyRSS/API.php @@ -1499,7 +1499,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { $order = ["edited_date desc"]; break; default: - // sort most recently marked for special feeds, newest first otherwise + // sort most recently marked for special feeds, newest first otherwise $order = (!$cat && ($id == self::FEED_READ || $id == self::FEED_STARRED)) ? ["marked_date desc"] : ["edited_date desc"]; break; } diff --git a/lib/REST/TinyTinyRSS/Search.php b/lib/REST/TinyTinyRSS/Search.php index 4ff634b6..f7913616 100644 --- a/lib/REST/TinyTinyRSS/Search.php +++ b/lib/REST/TinyTinyRSS/Search.php @@ -82,6 +82,7 @@ class Search { $state = self::STATE_IN_TOKEN_OR_TAG; continue 3; } + // no break case self::STATE_BEFORE_TOKEN_QUOTED: switch ($char) { case "": @@ -130,6 +131,7 @@ class Search { $state = self::STATE_IN_TOKEN_OR_TAG_QUOTED; continue 3; } + // no break case self::STATE_IN_DATE: while ($pos < $stop && $search[$pos] !== " ") { $buffer .= $search[$pos++]; @@ -169,6 +171,7 @@ class Search { $buffer .= $char; continue 3; } + // no break case self::STATE_IN_TOKEN: while ($pos < $stop && $search[$pos] !== " ") { $buffer .= $search[$pos++]; @@ -214,6 +217,7 @@ class Search { $buffer .= $char; continue 3; } + // no break case self::STATE_IN_TOKEN_OR_TAG: switch ($char) { case "": @@ -223,7 +227,7 @@ class Search { $flag_negative = false; $buffer = $tag = ""; continue 3; - case ":"; + case ":": $tag = $buffer; $buffer = ""; $state = self::STATE_IN_TOKEN; @@ -232,6 +236,7 @@ class Search { $buffer .= $char; continue 3; } + // no break case self::STATE_IN_TOKEN_OR_TAG_QUOTED: switch ($char) { case "": @@ -267,6 +272,7 @@ class Search { $buffer .= $char; continue 3; } + // no break default: throw new \Exception; // @codeCoverageIgnore } diff --git a/tests/cases/Database/SeriesArticle.php b/tests/cases/Database/SeriesArticle.php index d47f918d..38714749 100644 --- a/tests/cases/Database/SeriesArticle.php +++ b/tests/cases/Database/SeriesArticle.php @@ -498,7 +498,7 @@ trait SeriesArticle { 'Excluded folder tree' => [(new Context)->not->folder(1), [1,2,3,4,19,20]], 'Excluding label ID 2' => [(new Context)->not->label(2), [2,3,4,6,7,8,19]], 'Excluding label "Fascinating"' => [(new Context)->not->labelName("Fascinating"), [2,3,4,6,7,8,19]], - 'Search 501 terms' => [(new Context)->searchTerms(array_merge(range(1,500),[str_repeat("a", 1000)])), []], + 'Search 501 terms' => [(new Context)->searchTerms(array_merge(range(1, 500), [str_repeat("a", 1000)])), []], 'With tag ID 1' => [(new Context)->tag(1), [5,6,7,8]], 'With tag ID 5' => [(new Context)->tag(5), [7,8,19,20]], 'With tag ID 1 or 5' => [(new Context)->tags([1,5]), [5,6,7,8,19,20]], @@ -1060,8 +1060,8 @@ trait SeriesArticle { } public function provideArrayContextOptions() { - foreach([ - "articles", "editions", + foreach ([ + "articles", "editions", "subscriptions", "foldersShallow", //"folders", "tags", "tagNames", "labels", "labelNames", "searchTerms", "authorTerms", "annotationTerms", diff --git a/tests/cases/REST/Fever/TestAPI.php b/tests/cases/REST/Fever/TestAPI.php index 272a25fc..1986db07 100644 --- a/tests/cases/REST/Fever/TestAPI.php +++ b/tests/cases/REST/Fever/TestAPI.php @@ -26,7 +26,6 @@ use Zend\Diactoros\Response\EmptyResponse; /** @covers \JKingWeb\Arsse\REST\Fever\API */ class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest { - protected function v($value) { return $value; } From 4ce371ece69e0e0763870297fc25af263f1cce64 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Mon, 8 Apr 2019 18:41:56 -0400 Subject: [PATCH 092/142] Tests and fixes for Fever item listing --- lib/REST/Fever/API.php | 7 +- tests/cases/REST/Fever/TestAPI.php | 147 +++++++++++++++++++++++++++++ 2 files changed, 151 insertions(+), 3 deletions(-) diff --git a/lib/REST/Fever/API.php b/lib/REST/Fever/API.php index 2504463e..0849f612 100644 --- a/lib/REST/Fever/API.php +++ b/lib/REST/Fever/API.php @@ -238,11 +238,11 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { // handle the standard options if ($G['with_ids']) { $c->articles(explode(",", $G['with_ids'])); + } elseif ($G['max_id']) { + $c->latestArticle($G['max_id'] - 1); + $reverse = true; } elseif ($G['since_id']) { $c->oldestArticle($G['since_id'] + 1); - } elseif ($G['max_id']) { - $c->newestArticle($G['max_id'] - 1); - $reverse = true; } // handle the undocumented options if ($G['group_ids']) { @@ -261,6 +261,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { 'title' => (string) $r['title'], 'author' => (string) $r['author'], 'html' => (string) $r['content'], + 'url' => (string) $r['url'], 'is_saved' => (int) $r['starred'], 'is_read' => (int) !$r['unread'], 'created_on_time' => Date::transform($r['published_date'], "unix", "sql"), diff --git a/tests/cases/REST/Fever/TestAPI.php b/tests/cases/REST/Fever/TestAPI.php index 1986db07..490149f6 100644 --- a/tests/cases/REST/Fever/TestAPI.php +++ b/tests/cases/REST/Fever/TestAPI.php @@ -26,6 +26,122 @@ use Zend\Diactoros\Response\EmptyResponse; /** @covers \JKingWeb\Arsse\REST\Fever\API */ class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest { + protected $articles = [ + 'db' => [ + [ + 'id' => 101, + 'url' => 'http://example.com/1', + 'title' => 'Article title 1', + 'author' => '', + 'content' => '

Article content 1

', + 'published_date' => '2000-01-01 00:00:00', + 'unread' => 1, + 'starred' => 0, + 'subscription' => 8, + ], + [ + 'id' => 102, + 'url' => 'http://example.com/2', + 'title' => 'Article title 2', + 'author' => '', + 'content' => '

Article content 2

', + 'published_date' => '2000-01-02 00:00:00', + 'unread' => 0, + 'starred' => 0, + 'subscription' => 8, + ], + [ + 'id' => 103, + 'url' => 'http://example.com/3', + 'title' => 'Article title 3', + 'author' => '', + 'content' => '

Article content 3

', + 'published_date' => '2000-01-03 00:00:00', + 'unread' => 1, + 'starred' => 1, + 'subscription' => 9, + ], + [ + 'id' => 104, + 'url' => 'http://example.com/4', + 'title' => 'Article title 4', + 'author' => '', + 'content' => '

Article content 4

', + 'published_date' => '2000-01-04 00:00:00', + 'unread' => 0, + 'starred' => 1, + 'subscription' => 9, + ], + [ + 'id' => 105, + 'url' => 'http://example.com/5', + 'title' => 'Article title 5', + 'author' => '', + 'content' => '

Article content 5

', + 'published_date' => '2000-01-05 00:00:00', + 'unread' => 1, + 'starred' => 0, + 'subscription' => 10, + ], + ], + 'rest' => [ + [ + 'id' => 101, + 'feed_id' => 8, + 'title' => 'Article title 1', + 'author' => '', + 'html' => '

Article content 1

', + 'url' => 'http://example.com/1', + 'is_saved' => 0, + 'is_read' => 0, + 'created_on_time' => 946684800, + ], + [ + 'id' => 102, + 'feed_id' => 8, + 'title' => 'Article title 2', + 'author' => '', + 'html' => '

Article content 2

', + 'url' => 'http://example.com/2', + 'is_saved' => 0, + 'is_read' => 1, + 'created_on_time' => 946771200, + ], + [ + 'id' => 103, + 'feed_id' => 9, + 'title' => 'Article title 3', + 'author' => '', + 'html' => '

Article content 3

', + 'url' => 'http://example.com/3', + 'is_saved' => 1, + 'is_read' => 0, + 'created_on_time' => 946857600, + ], + [ + 'id' => 104, + 'feed_id' => 9, + 'title' => 'Article title 4', + 'author' => '', + 'html' => '

Article content 4

', + 'url' => 'http://example.com/4', + 'is_saved' => 1, + 'is_read' => 1, + 'created_on_time' => 946944000, + ], + [ + 'id' => 105, + 'feed_id' => 10, + 'title' => 'Article title 5', + 'author' => '', + 'html' => '

Article content 5

', + 'url' => 'http://example.com/5', + 'is_saved' => 0, + 'is_read' => 0, + 'created_on_time' => 947030400, + ], + ], + ]; protected function v($value) { return $value; } @@ -204,4 +320,35 @@ class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest { $act = $this->req("api&feeds"); $this->assertMessage($exp, $act); } + + /** @dataProvider provideItemListContexts */ + public function testListItems(string $url, Context $c, bool $desc) { + $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($this->anything())->thenReturn(1024); + $exp = new JsonResponse([ + 'items' => $this->articles['rest'], + 'total_items' => 1024, + ]); + $act = $this->req("api&$url"); + $this->assertMessage($exp, $act); + \Phake::verify(Arsse::$db)->articleList($this->anything(), $c, $fields, $order); + } + + public function provideItemListContexts() { + $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&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&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], + ]; + } } From e8f4732b1f32c49c68ded4313c68346749e98265 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Mon, 8 Apr 2019 19:15:12 -0400 Subject: [PATCH 093/142] Tests for saved and unread item ID lists --- tests/cases/REST/Fever/TestAPI.php | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/cases/REST/Fever/TestAPI.php b/tests/cases/REST/Fever/TestAPI.php index 490149f6..ffd7a631 100644 --- a/tests/cases/REST/Fever/TestAPI.php +++ b/tests/cases/REST/Fever/TestAPI.php @@ -351,4 +351,19 @@ class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest { ["items&feed_ids=1,2,3,4&since_id=6", (clone $c)->subscriptions([1,2,3,4])->oldestArticle(7), false], ]; } + + public function testListItemIds() { + $saved = [['id' => 1],['id' => 2],['id' => 3]]; + $unread = [['id' => 4],['id' => 5],['id' => 6]]; + \Phake::when(Arsse::$db)->articleList($this->anything(), (new Context)->starred(true))->thenReturn(new Result($saved)); + \Phake::when(Arsse::$db)->articleList($this->anything(), (new Context)->unread(true))->thenReturn(new Result($unread)); + $exp = new JsonResponse([ + 'saved_item_ids' => "1,2,3" + ]); + $this->assertMessage($exp, $this->req("api&saved_item_ids")); + $exp = new JsonResponse([ + 'unread_item_ids' => "4,5,6" + ]); + $this->assertMessage($exp, $this->req("api&unread_item_ids")); + } } From 98fc3f4940867593fe533fcd7446d5faea825742 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Mon, 8 Apr 2019 19:21:21 -0400 Subject: [PATCH 094/142] Test for hot links --- lib/REST/Fever/API.php | 2 +- tests/cases/REST/Fever/TestAPI.php | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/lib/REST/Fever/API.php b/lib/REST/Fever/API.php index 0849f612..8c14d699 100644 --- a/lib/REST/Fever/API.php +++ b/lib/REST/Fever/API.php @@ -136,7 +136,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { } if ($G['links']) { // TODO: implement hot links - $out['inks'] = []; + $out['links'] = []; } if ($G['unread_item_ids']) { $out['unread_item_ids'] = $this->getItemIds((new Context)->unread(true)); diff --git a/tests/cases/REST/Fever/TestAPI.php b/tests/cases/REST/Fever/TestAPI.php index ffd7a631..686ef22c 100644 --- a/tests/cases/REST/Fever/TestAPI.php +++ b/tests/cases/REST/Fever/TestAPI.php @@ -366,4 +366,12 @@ class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest { ]); $this->assertMessage($exp, $this->req("api&unread_item_ids")); } + + public function testListHotLinks() { + // hot links are not actually implemented, so an empty array should be all we get + $exp = new JsonResponse([ + 'links' => [] + ]); + $this->assertMessage($exp, $this->req("api&links")); + } } From c783ec4357fd5864a9f2932a4f1bd427b4b93d2b Mon Sep 17 00:00:00 2001 From: "J. King" Date: Mon, 8 Apr 2019 20:58:45 -0400 Subject: [PATCH 095/142] Prototype XML output for Fever --- lib/REST/Fever/API.php | 38 +++++++++++++++++++++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/lib/REST/Fever/API.php b/lib/REST/Fever/API.php index 8c14d699..d1ad2b31 100644 --- a/lib/REST/Fever/API.php +++ b/lib/REST/Fever/API.php @@ -21,6 +21,7 @@ use JKingWeb\Arsse\REST\Exception405; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\ResponseInterface; use Zend\Diactoros\Response\JsonResponse; +use Zend\Diactoros\Response\XmlResponse; use Zend\Diactoros\Response\EmptyResponse; class API extends \JKingWeb\Arsse\REST\AbstractHandler { @@ -161,12 +162,47 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { protected function formatResponse(array $data, bool $xml): ResponseInterface { if ($xml) { - throw \Exception("Not implemented yet"); + $d = new \DOMDocument("1.0", "utf-8"); + $d->appendChild($this->makeXMLAssoc($data, $d->createElement("response"))); + return new XmlResponse($d->saveXML()); } else { return new JsonResponse($data, 200, [], \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE); } } + protected function makeXMLAssoc(array $data, \DOMElement $p): \DOMElement { + $d = $p->ownerDocument; + foreach ($data as $k => $v) { + if (!is_array($v)) { + $p->appendChild($d->createElement($k, $v)); + } elseif (isset($v[0])) { + // this is a very simplistic check for an indexed array + // it would not pass muster in the face of generic data, + // but we'll assume our code produces only well-ordered + // indexed arrays + $p->appendChild($this->makeXMLIndexed($v, $d->createElement($k), substr($k, 0, strlen($k) - 1))); + } else { + $p->appendChild($this->makeXMLAssoc($v, $d->createElement($k))); + } + } + return $p; + } + + protected function makeXMLIndexed(array $data, \DOMElement $p, string $k): \DOMElement { + $d = $p->ownerDocument; + foreach ($data as $v) { + if (!is_array($v)) { + $p->appendChild($d->createElement($k, $v)); + } elseif (isset($v[0])) { + $p->appendChild($this->makeXMLIndexed($v, $d->createElement($k), substr($k, 0, strlen($k) - 1))); + } else { + $p->appendChild($this->makeXMLAssoc($v, $d->createElement($k))); + } + } + return $p; + + } + protected function logIn(string $hash): bool { // if HTTP authentication was successful and sessions are not enforced, proceed unconditionally if (isset(Arsse::$user->id) && !Arsse::$conf->userSessionEnforced) { From 15915a4393b6029f757e13c7928c2d6baad98f93 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Mon, 8 Apr 2019 23:31:22 -0400 Subject: [PATCH 096/142] Initial implementation of simple marks --- lib/REST/Fever/API.php | 75 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 73 insertions(+), 2 deletions(-) diff --git a/lib/REST/Fever/API.php b/lib/REST/Fever/API.php index d1ad2b31..29ca3c2b 100644 --- a/lib/REST/Fever/API.php +++ b/lib/REST/Fever/API.php @@ -119,6 +119,18 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { } protected function processRequest(array $out, array $G, array $P): array { + $listUnread = false; + $listSaved = false; + if ($P['unread_recently_read']) { + $this->setUnread(); + $listUnread = true; + } + if ($P['mark']) { + // depending on which mark are being made, + // either an 'unread_item_ids' or a + // 'saved_item_ids' entry will be added later + $listSaved = $this->setMarks($P, $listUnread); + } if ($G['feeds'] || $G['groups']) { if ($G['groups']) { $out['groups'] = $this->getGroups(); @@ -139,10 +151,10 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { // TODO: implement hot links $out['links'] = []; } - if ($G['unread_item_ids']) { + if ($G['unread_item_ids'] || $listUnread) { $out['unread_item_ids'] = $this->getItemIds((new Context)->unread(true)); } - if ($G['saved_item_ids']) { + if ($G['saved_item_ids'] || $listSaved) { $out['saved_item_ids'] = $this->getItemIds((new Context)->starred(true)); } return $out; @@ -219,6 +231,65 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { return true; } + protected function setMarks(array $P, &$listUnread): bool { + $listSaved = false; + $c = new Context; + $id = $P['id']; + if ($P['before']) { + $c->notMarkedSince($P['before']); + } + switch ($P['mark']) { + case "item": + $c->article($id); + break; + case "group": + if ($id > 0) { + // group zero is the "Kindling" supergroup i.e. all feeds + $c->tag($id); + } elseif ($id < 0) { + // group negative-one is the "Sparks" supergroup i.e. no feeds + $c->not->folder(0); + } + break; + case "feed": + $c->subscription($id); + break; + default: + return $listSaved; + } + switch ($P['as']) { + case "read": + $data = ['read' => true]; + $listUnread = true; + break; + case "unread": + // this option is undocumented, but valid + $data = ['read' => false]; + $listUnread = true; + break; + case "saved": + $data = ['starred' => true]; + $listSaved = true; + break; + case "unsaved": + $data = ['starred' => false]; + $listSaved = true; + break; + default: + return $listSaved; + } + try { + Arsse::$db->articleMark(Arsse::$user->id, $data, $c); + } catch (ExceptionInput $e) { + // ignore any errors + } + return $listSaved; + } + + protected function setUnread() { + // stub + } + protected function getRefreshTime() { return Date::transform(Arsse::$db->subscriptionRefreshed(Arsse::$user->id), "unix"); } From 61abf7ee7c876ae8b67a0f343a5ab036f1feffdd Mon Sep 17 00:00:00 2001 From: "J. King" Date: Tue, 9 Apr 2019 16:15:36 -0400 Subject: [PATCH 097/142] Upgrade to Diactoros 2.x --- arsse.php | 2 +- composer.json | 11 +-- composer.lock | 208 ++++++++++++++++++++++++++++++++++++++++++++------ 3 files changed, 192 insertions(+), 29 deletions(-) diff --git a/arsse.php b/arsse.php index 407be037..0cfa0ae4 100644 --- a/arsse.php +++ b/arsse.php @@ -25,7 +25,7 @@ if (\PHP_SAPI === "cli") { $conf = file_exists(BASE."config.php") ? new Conf(BASE."config.php") : new Conf; Arsse::load($conf); // handle Web requests - $emitter = new \Zend\Diactoros\Response\SapiEmitter(); + $emitter = new \Zend\HttpHandlerRunner\Emitter\SapiEmitter; $response = (new REST)->dispatch(); $emitter->emit($response); } diff --git a/composer.json b/composer.json index 0f5570c3..1a607c3c 100644 --- a/composer.json +++ b/composer.json @@ -18,15 +18,16 @@ ], "require": { - "php": "^7.0", + "php": "7.*", "ext-intl": "*", "ext-json": "*", "ext-hash": "*", "p3k/picofeed": "0.1.*", - "hosteurope/password-generator": "^1.0", - "docopt/docopt": "^1.0", - "jkingweb/druuid": "^3.0", - "zendframework/zend-diactoros": "^1.6" + "hosteurope/password-generator": "1.*", + "docopt/docopt": "1.*", + "jkingweb/druuid": "3.*", + "zendframework/zend-diactoros": "2.*", + "zendframework/zend-httphandlerrunner": "1.*" }, "require-dev": { "bamarni/composer-bin-plugin": "*" diff --git a/composer.lock b/composer.lock index b5e5d385..0a7f0730 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "d7a6a00be3d97c11d09ec4d4e56d36e0", + "content-hash": "bd427d25f07432e40d396060907cf1e3", "packages": [ { "name": "docopt/docopt", @@ -190,6 +190,58 @@ "homepage": "https://github.com/miniflux/picoFeed", "time": "2017-11-30T00:16:58+00:00" }, + { + "name": "psr/http-factory", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-factory.git", + "reference": "378bfe27931ecc54ff824a20d6f6bfc303bbd04c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-factory/zipball/378bfe27931ecc54ff824a20d6f6bfc303bbd04c", + "reference": "378bfe27931ecc54ff824a20d6f6bfc303bbd04c", + "shasum": "" + }, + "require": { + "php": ">=7.0.0", + "psr/http-message": "^1.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Common interfaces for PSR-7 HTTP message factories", + "keywords": [ + "factory", + "http", + "message", + "psr", + "psr-17", + "psr-7", + "request", + "response" + ], + "time": "2018-07-30T21:54:04+00:00" + }, { "name": "psr/http-message", "version": "1.0.1", @@ -241,39 +293,95 @@ "time": "2016-08-06T14:39:51+00:00" }, { - "name": "zendframework/zend-diactoros", - "version": "1.8.6", + "name": "psr/http-server-handler", + "version": "1.0.1", "source": { "type": "git", - "url": "https://github.com/zendframework/zend-diactoros.git", - "reference": "20da13beba0dde8fb648be3cc19765732790f46e" + "url": "https://github.com/php-fig/http-server-handler.git", + "reference": "aff2f80e33b7f026ec96bb42f63242dc50ffcae7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/zendframework/zend-diactoros/zipball/20da13beba0dde8fb648be3cc19765732790f46e", - "reference": "20da13beba0dde8fb648be3cc19765732790f46e", + "url": "https://api.github.com/repos/php-fig/http-server-handler/zipball/aff2f80e33b7f026ec96bb42f63242dc50ffcae7", + "reference": "aff2f80e33b7f026ec96bb42f63242dc50ffcae7", "shasum": "" }, "require": { - "php": "^5.6 || ^7.0", + "php": ">=7.0", + "psr/http-message": "^1.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Server\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP server-side request handler", + "keywords": [ + "handler", + "http", + "http-interop", + "psr", + "psr-15", + "psr-7", + "request", + "response", + "server" + ], + "time": "2018-10-30T16:46:14+00:00" + }, + { + "name": "zendframework/zend-diactoros", + "version": "2.1.1", + "source": { + "type": "git", + "url": "https://github.com/zendframework/zend-diactoros.git", + "reference": "c3c330192bc9cc51b7e9ce968ff721dc32ffa986" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/zendframework/zend-diactoros/zipball/c3c330192bc9cc51b7e9ce968ff721dc32ffa986", + "reference": "c3c330192bc9cc51b7e9ce968ff721dc32ffa986", + "shasum": "" + }, + "require": { + "php": "^7.1", + "psr/http-factory": "^1.0", "psr/http-message": "^1.0" }, "provide": { + "psr/http-factory-implementation": "1.0", "psr/http-message-implementation": "1.0" }, "require-dev": { "ext-dom": "*", "ext-libxml": "*", + "http-interop/http-factory-tests": "^0.5.0", "php-http/psr7-integration-tests": "dev-master", - "phpunit/phpunit": "^5.7.16 || ^6.0.8 || ^7.2.7", - "zendframework/zend-coding-standard": "~1.0" + "phpunit/phpunit": "^7.0.2", + "zendframework/zend-coding-standard": "~1.0.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.8.x-dev", - "dev-develop": "1.9.x-dev", - "dev-release-2.0": "2.0.x-dev" + "dev-master": "2.1.x-dev", + "dev-develop": "2.2.x-dev", + "dev-release-1.8": "1.8.x-dev" } }, "autoload": { @@ -293,16 +401,70 @@ }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-2-Clause" + "BSD-3-Clause" ], "description": "PSR HTTP Message implementations", - "homepage": "https://github.com/zendframework/zend-diactoros", "keywords": [ "http", "psr", "psr-7" ], - "time": "2018-09-05T19:29:37+00:00" + "time": "2019-01-05T20:13:32+00:00" + }, + { + "name": "zendframework/zend-httphandlerrunner", + "version": "1.1.0", + "source": { + "type": "git", + "url": "https://github.com/zendframework/zend-httphandlerrunner.git", + "reference": "75fb12751fe9d6e392cce1ee0d687dacae2db787" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/zendframework/zend-httphandlerrunner/zipball/75fb12751fe9d6e392cce1ee0d687dacae2db787", + "reference": "75fb12751fe9d6e392cce1ee0d687dacae2db787", + "shasum": "" + }, + "require": { + "php": "^7.1", + "psr/http-message": "^1.0", + "psr/http-message-implementation": "^1.0", + "psr/http-server-handler": "^1.0" + }, + "require-dev": { + "phpunit/phpunit": "^7.0.2", + "zendframework/zend-coding-standard": "~1.0.0", + "zendframework/zend-diactoros": "^1.7 || ^2.1.1" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.1.x-dev", + "dev-develop": "1.2.x-dev" + }, + "zf": { + "config-provider": "Zend\\HttpHandlerRunner\\ConfigProvider" + } + }, + "autoload": { + "psr-4": { + "Zend\\HttpHandlerRunner\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "description": "Execute PSR-15 RequestHandlerInterface instances and emit responses they generate.", + "keywords": [ + "ZendFramework", + "components", + "expressive", + "psr-15", + "psr-7", + "zf" + ], + "time": "2019-02-19T18:20:34+00:00" }, { "name": "zendframework/zendxml", @@ -354,16 +516,16 @@ "packages-dev": [ { "name": "bamarni/composer-bin-plugin", - "version": "v1.2.0", + "version": "v1.3.0", "source": { "type": "git", "url": "https://github.com/bamarni/composer-bin-plugin.git", - "reference": "62fef740245a85f00665e81ea8f0aa0b72afe6e7" + "reference": "67f9d314dc7ecf7245b8637906e151ccc62b8d24" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/bamarni/composer-bin-plugin/zipball/62fef740245a85f00665e81ea8f0aa0b72afe6e7", - "reference": "62fef740245a85f00665e81ea8f0aa0b72afe6e7", + "url": "https://api.github.com/repos/bamarni/composer-bin-plugin/zipball/67f9d314dc7ecf7245b8637906e151ccc62b8d24", + "reference": "67f9d314dc7ecf7245b8637906e151ccc62b8d24", "shasum": "" }, "require": { @@ -371,7 +533,7 @@ }, "require-dev": { "composer/composer": "dev-master", - "symfony/console": "^2.5 || ^3.0" + "symfony/console": "^2.5 || ^3.0 || ^4.0" }, "type": "composer-plugin", "extra": { @@ -389,7 +551,7 @@ "license": [ "MIT" ], - "time": "2017-09-11T13:13:58+00:00" + "time": "2019-03-17T12:38:04+00:00" } ], "aliases": [], @@ -398,7 +560,7 @@ "prefer-stable": false, "prefer-lowest": false, "platform": { - "php": "^7.0", + "php": "7.*", "ext-intl": "*", "ext-json": "*", "ext-hash": "*" From 52bc5fbda6124aaed1cf19fc9e7f4e1f04fcb6e9 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Wed, 10 Apr 2019 09:48:28 -0400 Subject: [PATCH 098/142] Tests for simple marking --- lib/REST/Fever/API.php | 7 ++- tests/cases/REST/Fever/TestAPI.php | 68 ++++++++++++++++++++++++++---- 2 files changed, 64 insertions(+), 11 deletions(-) diff --git a/lib/REST/Fever/API.php b/lib/REST/Fever/API.php index 29ca3c2b..d472b11a 100644 --- a/lib/REST/Fever/API.php +++ b/lib/REST/Fever/API.php @@ -125,7 +125,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { $this->setUnread(); $listUnread = true; } - if ($P['mark']) { + if ($P['mark'] && $P['as'] && is_int($P['id'])) { // depending on which mark are being made, // either an 'unread_item_ids' or a // 'saved_item_ids' entry will be added later @@ -244,11 +244,14 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { break; case "group": if ($id > 0) { - // group zero is the "Kindling" supergroup i.e. all feeds + // concrete groups $c->tag($id); } 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 } break; case "feed": diff --git a/tests/cases/REST/Fever/TestAPI.php b/tests/cases/REST/Fever/TestAPI.php index 686ef22c..9a899c01 100644 --- a/tests/cases/REST/Fever/TestAPI.php +++ b/tests/cases/REST/Fever/TestAPI.php @@ -7,16 +7,11 @@ declare(strict_types=1); namespace JKingWeb\Arsse\TestCase\REST\Fever; use JKingWeb\Arsse\Arsse; -use JKingWeb\Arsse\Conf; use JKingWeb\Arsse\User; use JKingWeb\Arsse\Database; -use JKingWeb\Arsse\Service; -use JKingWeb\Arsse\REST\Request; use JKingWeb\Arsse\Test\Result; -use JKingWeb\Arsse\Misc\Date; use JKingWeb\Arsse\Context\Context; use JKingWeb\Arsse\Db\ExceptionInput; -use JKingWeb\Arsse\User\Exception as UserException; use JKingWeb\Arsse\Db\Transaction; use JKingWeb\Arsse\REST\Fever\API; use Psr\Http\Message\ResponseInterface; @@ -161,9 +156,8 @@ class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest { if (is_array($dataPost)) { $req = $req->withParsedBody($dataPost); } else { - $body = $req->getBody(); - $body->write($dataPost); - $req = $req->withBody($body); + parse_str($dataPost, $arr); + $req = $req->withParsedBody($arr); } if (isset($user)) { if (strlen($user)) { @@ -319,7 +313,7 @@ class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest { ]); $act = $this->req("api&feeds"); $this->assertMessage($exp, $act); - } + } /** @dataProvider provideItemListContexts */ public function testListItems(string $url, Context $c, bool $desc) { @@ -374,4 +368,60 @@ class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest { ]); $this->assertMessage($exp, $this->req("api&links")); } + + /** @dataProvider provideMarkingContexts */ + public function testSetMarks(string $post, Context $c, array $data, array $out) { + $saved = [['id' => 1],['id' => 2],['id' => 3]]; + $unread = [['id' => 4],['id' => 5],['id' => 6]]; + \Phake::when(Arsse::$db)->articleList($this->anything(), (new Context)->starred(true))->thenReturn(new Result($saved)); + \Phake::when(Arsse::$db)->articleList($this->anything(), (new Context)->unread(true))->thenReturn(new Result($unread)); + \Phake::when(Arsse::$db)->articleMark->thenReturn(0); + \Phake::when(Arsse::$db)->articleMark($this->anything(), $this->anything(), (new Context)->article(2112))->thenThrow(new \JKingWeb\Arsse\Db\ExceptionInput("subjectMissing")); + $exp = new JsonResponse($out); + $act = $this->req("api", $post); + $this->assertMessage($exp, $act); + if ($c && $data) { + \Phake::verify(Arsse::$db)->articleMark($this->anything(), $data, $c); + } else { + \Phake::verify(Arsse::$db, \Phake::times(0))->articleMark; + } + } + + public function provideMarkingContexts() { + $markRead = ['read' => true]; + $markUnread = ['read' => false]; + $markSaved = ['starred' => true]; + $markUnsaved = ['starred' => false]; + $listSaved = ['saved_item_ids' => "1,2,3"]; + $listUnread = ['unread_item_ids' => "4,5,6"]; + return [ + ["mark=item&as=read&id=5", (new Context)->article(5), $markRead, $listUnread], + ["mark=item&as=unread&id=42", (new Context)->article(42), $markUnread, $listUnread], + ["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=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=-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], + ["mark=group&as=unsaved&id=-1", (new Context)->not->folder(0), $markUnsaved, $listSaved], + ["mark=group&as=read&id=-1&before=946684800", (new Context)->not->folder(0)->notMarkedSince("2000-01-01T00:00:00"), $markRead, $listUnread], + ["mark=item&as=unread", new Context, [], []], + ["mark=item&id=6", new Context, [], []], + ["as=unread&id=6", new Context, [], []], + ]; + } } From afb95e53b025554aade539e842dc6c0c8b164011 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Wed, 10 Apr 2019 10:21:14 -0400 Subject: [PATCH 099/142] Initial implementation of read-undo --- lib/REST/Fever/API.php | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/lib/REST/Fever/API.php b/lib/REST/Fever/API.php index d472b11a..2d0f9848 100644 --- a/lib/REST/Fever/API.php +++ b/lib/REST/Fever/API.php @@ -290,7 +290,19 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { } protected function setUnread() { - // stub + $lastUnread = Arsse::$db->articleList(Arsse::$user->id, (new Context)->limit(1), ["marked_date"], ["marked_date desc"])->getValue(); + if (!$lastUnread) { + // there are no articles + return; + } + // 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 + // not actually signify a mark, but we'll otherwise also count back fifteen seconds + $c = new Context; + $lastUnread = Date::normalize($lastUnread, "sql"); + $since = Date::sub("DT15S", $lastUnread); + $c->unread(false)->markedSince($since); + Arsse::$db->articleMark(Arsse::$user->id, ['read' => false], $c); } protected function getRefreshTime() { From 8532c581a8c638a747e01e0655cb12bda52687ed Mon Sep 17 00:00:00 2001 From: "J. King" Date: Wed, 10 Apr 2019 10:51:02 -0400 Subject: [PATCH 100/142] Handle OPTIONS requests in Fever --- lib/REST/Fever/API.php | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/lib/REST/Fever/API.php b/lib/REST/Fever/API.php index 2d0f9848..baa501ff 100644 --- a/lib/REST/Fever/API.php +++ b/lib/REST/Fever/API.php @@ -63,8 +63,10 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { } switch ($req->getMethod()) { case "OPTIONS": - // do stuff - break; + return new EmptyResponse(204, [ + 'Allow' => "POST", + 'Accept' => "application/x-www-form-urlencoded", + ]); case "POST": if (strlen($req->getHeaderLine("Content-Type")) && $req->getHeaderLine("Content-Type") !== "application/x-www-form-urlencoded") { return new EmptyResponse(415, ['Accept' => "application/x-www-form-urlencoded"]); @@ -297,7 +299,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 - // not actually signify a mark, but we'll otherwise also count back fifteen seconds + // may not actually signify a mark, but we'll otherwise also count back fifteen seconds $c = new Context; $lastUnread = Date::normalize($lastUnread, "sql"); $since = Date::sub("DT15S", $lastUnread); From efd8492573f84b89a991cb4ca4d2750872f3ca2f Mon Sep 17 00:00:00 2001 From: "J. King" Date: Wed, 10 Apr 2019 15:07:34 -0400 Subject: [PATCH 101/142] Tests for various invalid requests --- tests/cases/REST/Fever/TestAPI.php | 38 ++++++++++++++++++++---------- 1 file changed, 26 insertions(+), 12 deletions(-) diff --git a/tests/cases/REST/Fever/TestAPI.php b/tests/cases/REST/Fever/TestAPI.php index 9a899c01..e0771971 100644 --- a/tests/cases/REST/Fever/TestAPI.php +++ b/tests/cases/REST/Fever/TestAPI.php @@ -141,14 +141,15 @@ class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest { return $value; } - protected function req($dataGet, $dataPost = "", string $method = "POST", string $type = null, string $url = "", string $user = null): ResponseInterface { + protected function req($dataGet, $dataPost = "", string $method = "POST", string $type = null, string $url = "", string $user = null): ServerRequest { $url = "/fever/".$url; + $type = $type ?? "application/x-www-form-urlencoded"; $server = [ 'REQUEST_METHOD' => $method, 'REQUEST_URI' => $url, - 'HTTP_CONTENT_TYPE' => $type ?? "application/x-www-form-urlencoded", + 'HTTP_CONTENT_TYPE' => $type, ]; - $req = new ServerRequest($server, [], $url, $method, "php://memory"); + $req = new ServerRequest($server, [], $url, $method, "php://memory", ['Content-Type' => $type]); if (!is_array($dataGet)) { parse_str($dataGet, $dataGet); } @@ -166,7 +167,7 @@ class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest { $req = $req->withAttribute("authenticationFailed", true); } } - return $this->h->dispatch($req); + return $req; } public function setUp() { @@ -205,7 +206,7 @@ class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest { \Phake::when($this->h)->processRequest->thenReturnCallback(function($out, $G, $P) { return $out; }); - $act = $this->req($dataGet, $dataPost, "POST", null, "", $httpUser); + $act = $this->h->dispatch($this->req($dataGet, $dataPost, "POST", null, "", $httpUser)); $this->assertMessage($exp, $act); } @@ -284,7 +285,7 @@ class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest { ['group_id' => 2, 'feed_ids' => "1,3"], ], ]); - $act = $this->req("api&groups"); + $act = $this->h->dispatch($this->req("api&groups")); $this->assertMessage($exp, $act); } @@ -311,7 +312,7 @@ class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest { ['group_id' => 2, 'feed_ids' => "1,3"], ], ]); - $act = $this->req("api&feeds"); + $act = $this->h->dispatch($this->req("api&feeds")); $this->assertMessage($exp, $act); } @@ -325,7 +326,7 @@ class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest { 'items' => $this->articles['rest'], 'total_items' => 1024, ]); - $act = $this->req("api&$url"); + $act = $this->h->dispatch($this->req("api&$url")); $this->assertMessage($exp, $act); \Phake::verify(Arsse::$db)->articleList($this->anything(), $c, $fields, $order); } @@ -354,11 +355,11 @@ class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest { $exp = new JsonResponse([ 'saved_item_ids' => "1,2,3" ]); - $this->assertMessage($exp, $this->req("api&saved_item_ids")); + $this->assertMessage($exp, $this->h->dispatch($this->req("api&saved_item_ids"))); $exp = new JsonResponse([ 'unread_item_ids' => "4,5,6" ]); - $this->assertMessage($exp, $this->req("api&unread_item_ids")); + $this->assertMessage($exp, $this->h->dispatch($this->req("api&unread_item_ids"))); } public function testListHotLinks() { @@ -366,7 +367,7 @@ class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest { $exp = new JsonResponse([ 'links' => [] ]); - $this->assertMessage($exp, $this->req("api&links")); + $this->assertMessage($exp, $this->h->dispatch($this->req("api&links"))); } /** @dataProvider provideMarkingContexts */ @@ -378,7 +379,7 @@ class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest { \Phake::when(Arsse::$db)->articleMark->thenReturn(0); \Phake::when(Arsse::$db)->articleMark($this->anything(), $this->anything(), (new Context)->article(2112))->thenThrow(new \JKingWeb\Arsse\Db\ExceptionInput("subjectMissing")); $exp = new JsonResponse($out); - $act = $this->req("api", $post); + $act = $this->h->dispatch($this->req("api", $post)); $this->assertMessage($exp, $act); if ($c && $data) { \Phake::verify(Arsse::$db)->articleMark($this->anything(), $data, $c); @@ -424,4 +425,17 @@ class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest { ["as=unread&id=6", new Context, [], []], ]; } + + /** @dataProvider provideInvalidRequests */ + public function testSendInvalidRequests(ServerRequest $req, ResponseInterface $exp) { + $this->assertMessage($exp, $this->h->dispatch($req)); + } + + public function provideInvalidRequests() { + return [ + 'Not an API request' => [$this->req(""), new EmptyResponse(404)], + 'Wrong method' => [$this->req("api", "", "GET"), new EmptyResponse(405, ['Allow' => "OPTIONS,POST"])], + 'Wrong content type' => [$this->req("api", "", "POST", "application/json"), new EmptyResponse(415, ['Accept' => "application/x-www-form-urlencoded"])], + ]; + } } From c55a960b856f548c55b6cd3854dd502b9559e947 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Wed, 10 Apr 2019 15:14:45 -0400 Subject: [PATCH 102/142] Slight cleanup --- lib/REST/TinyTinyRSS/API.php | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/lib/REST/TinyTinyRSS/API.php b/lib/REST/TinyTinyRSS/API.php index 26cf441b..b5b610f5 100644 --- a/lib/REST/TinyTinyRSS/API.php +++ b/lib/REST/TinyTinyRSS/API.php @@ -8,11 +8,9 @@ namespace JKingWeb\Arsse\REST\TinyTinyRSS; use JKingWeb\Arsse\Feed; use JKingWeb\Arsse\Arsse; -use JKingWeb\Arsse\Database; -use JKingWeb\Arsse\User; -use JKingWeb\Arsse\Service; -use JKingWeb\Arsse\Misc\Date; +use JKingWeb\Arsse\Service;; use JKingWeb\Arsse\Context\Context; +use JKingWeb\Arsse\Misc\Date; use JKingWeb\Arsse\Misc\ValueInfo; use JKingWeb\Arsse\AbstractException; use JKingWeb\Arsse\ExceptionType; @@ -23,7 +21,6 @@ use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\ResponseInterface; use Zend\Diactoros\Response\JsonResponse as Response; use Zend\Diactoros\Response\EmptyResponse; -use Robo\Task\Archive\Pack; class API extends \JKingWeb\Arsse\REST\AbstractHandler { const LEVEL = 14; // emulated API level From daeff63239d2235cd29fa8df9b77ab4127bc0688 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Wed, 10 Apr 2019 16:01:58 -0400 Subject: [PATCH 103/142] Test basic Fever responses --- tests/cases/REST/Fever/TestAPI.php | 47 ++++++++++++++++++++++++------ 1 file changed, 38 insertions(+), 9 deletions(-) diff --git a/tests/cases/REST/Fever/TestAPI.php b/tests/cases/REST/Fever/TestAPI.php index e0771971..aa7bc75d 100644 --- a/tests/cases/REST/Fever/TestAPI.php +++ b/tests/cases/REST/Fever/TestAPI.php @@ -18,6 +18,7 @@ use Psr\Http\Message\ResponseInterface; use Zend\Diactoros\ServerRequest; use Zend\Diactoros\Response\JsonResponse; use Zend\Diactoros\Response\EmptyResponse; +use PHPUnit\Util\Json; /** @covers \JKingWeb\Arsse\REST\Fever\API */ class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest { @@ -321,14 +322,14 @@ 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($this->anything())->thenReturn(1024); + \Phake::when(Arsse::$db)->articleCount(Arsse::$user->id)->thenReturn(1024); $exp = new JsonResponse([ 'items' => $this->articles['rest'], 'total_items' => 1024, ]); $act = $this->h->dispatch($this->req("api&$url")); $this->assertMessage($exp, $act); - \Phake::verify(Arsse::$db)->articleList($this->anything(), $c, $fields, $order); + \Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, $c, $fields, $order); } public function provideItemListContexts() { @@ -350,8 +351,8 @@ class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest { public function testListItemIds() { $saved = [['id' => 1],['id' => 2],['id' => 3]]; $unread = [['id' => 4],['id' => 5],['id' => 6]]; - \Phake::when(Arsse::$db)->articleList($this->anything(), (new Context)->starred(true))->thenReturn(new Result($saved)); - \Phake::when(Arsse::$db)->articleList($this->anything(), (new Context)->unread(true))->thenReturn(new Result($unread)); + \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)); $exp = new JsonResponse([ 'saved_item_ids' => "1,2,3" ]); @@ -374,15 +375,15 @@ class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest { public function testSetMarks(string $post, Context $c, array $data, array $out) { $saved = [['id' => 1],['id' => 2],['id' => 3]]; $unread = [['id' => 4],['id' => 5],['id' => 6]]; - \Phake::when(Arsse::$db)->articleList($this->anything(), (new Context)->starred(true))->thenReturn(new Result($saved)); - \Phake::when(Arsse::$db)->articleList($this->anything(), (new Context)->unread(true))->thenReturn(new Result($unread)); + \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)->articleMark->thenReturn(0); - \Phake::when(Arsse::$db)->articleMark($this->anything(), $this->anything(), (new Context)->article(2112))->thenThrow(new \JKingWeb\Arsse\Db\ExceptionInput("subjectMissing")); + \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); $act = $this->h->dispatch($this->req("api", $post)); $this->assertMessage($exp, $act); if ($c && $data) { - \Phake::verify(Arsse::$db)->articleMark($this->anything(), $data, $c); + \Phake::verify(Arsse::$db)->articleMark(Arsse::$user->id, $data, $c); } else { \Phake::verify(Arsse::$db, \Phake::times(0))->articleMark; } @@ -419,7 +420,7 @@ class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest { ["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], ["mark=group&as=unsaved&id=-1", (new Context)->not->folder(0), $markUnsaved, $listSaved], - ["mark=group&as=read&id=-1&before=946684800", (new Context)->not->folder(0)->notMarkedSince("2000-01-01T00:00:00"), $markRead, $listUnread], + ["mark=group&as=read&id=-1&before=946684800", (new Context)->not->folder(0)->notMarkedSince("2000-01-01T00:00:00Z"), $markRead, $listUnread], ["mark=item&as=unread", new Context, [], []], ["mark=item&id=6", new Context, [], []], ["as=unread&id=6", new Context, [], []], @@ -438,4 +439,32 @@ class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest { 'Wrong content type' => [$this->req("api", "", "POST", "application/json"), new EmptyResponse(415, ['Accept' => "application/x-www-form-urlencoded"])], ]; } + + public function testMakeABaseQuery() { + $this->h = \Phake::partialMock(API::class); + \Phake::when($this->h)->logIn->thenReturn(true); + \Phake::when(Arsse::$db)->subscriptionRefreshed(Arsse::$user->id)->thenReturn(new \DateTimeImmutable("2000-01-01T00:00:00Z")); + $exp = new JsonResponse([ + 'api_version' => API::LEVEL, + 'auth' => 1, + 'last_refreshed_on_time' => 946684800, + ]); + $act = $this->h->dispatch($this->req("api")); + $this->assertMessage($exp, $act); + \Phake::when(Arsse::$db)->subscriptionRefreshed(Arsse::$user->id)->thenReturn(null); // no subscriptions + $exp = new JsonResponse([ + 'api_version' => API::LEVEL, + 'auth' => 1, + 'last_refreshed_on_time' => null, + ]); + $act = $this->h->dispatch($this->req("api")); + $this->assertMessage($exp, $act); + \Phake::when($this->h)->logIn->thenReturn(false); + $exp = new JsonResponse([ + 'api_version' => API::LEVEL, + 'auth' => 0, + ]); + $act = $this->h->dispatch($this->req("api")); + $this->assertMessage($exp, $act); + } } From 2d18be959cc8b55f3dedd76202fdbdb029f1c67e Mon Sep 17 00:00:00 2001 From: "J. King" Date: Wed, 10 Apr 2019 18:27:57 -0400 Subject: [PATCH 104/142] Tests for undoing read marks --- lib/REST/Fever/API.php | 2 +- tests/cases/REST/Fever/TestAPI.php | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/lib/REST/Fever/API.php b/lib/REST/Fever/API.php index baa501ff..643d1e87 100644 --- a/lib/REST/Fever/API.php +++ b/lib/REST/Fever/API.php @@ -302,7 +302,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { // may not actually signify a mark, but we'll otherwise also count back fifteen seconds $c = new Context; $lastUnread = Date::normalize($lastUnread, "sql"); - $since = Date::sub("DT15S", $lastUnread); + $since = Date::sub("PT15S", $lastUnread); $c->unread(false)->markedSince($since); Arsse::$db->articleMark(Arsse::$user->id, ['read' => false], $c); } diff --git a/tests/cases/REST/Fever/TestAPI.php b/tests/cases/REST/Fever/TestAPI.php index aa7bc75d..40231222 100644 --- a/tests/cases/REST/Fever/TestAPI.php +++ b/tests/cases/REST/Fever/TestAPI.php @@ -467,4 +467,20 @@ class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest { $act = $this->h->dispatch($this->req("api")); $this->assertMessage($exp, $act); } + + public function testUndoReadMarks() { + $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)->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([])); + $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 825c286e5b5551ab8a2a2276955b95a7e8a27958 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Fri, 19 Apr 2019 18:01:31 -0400 Subject: [PATCH 105/142] Prototype OPML import parser --- lib/ImportExport/OPML.php | 53 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/lib/ImportExport/OPML.php b/lib/ImportExport/OPML.php index 9b70a4a8..06ebfb38 100644 --- a/lib/ImportExport/OPML.php +++ b/lib/ImportExport/OPML.php @@ -10,6 +10,59 @@ use JKingWeb\Arsse\Arsse; use JKingWeb\Arsse\User\Exception as UserException; class OPML { + public function import(string $user, string $opml, bool $flat = false, bool $replace = false): bool { + list($folders, $feeds) = $this->parse($opml, $flat); + return true; + } + + protected function parse(string $opml, bool $flat): array { + $d = new \DOMDocument; + if (!@$d->loadXML($opml)) { + // not a valid XML document + throw new \Exception; + } + $body = $d->getElementsByTagName("body"); + if ($d->documentElement->nodeName !== "opml" || !$body->length || $body->item(0)->parentNode != $d->documentElement) { + // not a valid OPML document + throw new \Exception; + } + $body = $body->item(0); + $folders = []; + $feeds = []; + $folderMap = new \SplObjectStorage; + $folderMap[$body] = sizeof($folderMap); + $node = $body->firstChild; + while ($node && $node != $body) { + if ($node->nodeType == \XML_ELEMENT_NODE && $node->nodeName === "outline") { + if ($node->getAttribute("type") === "rss") { + $url = $node->getAttribute("xmlUrl"); + if (strlen($url)) { + $title = $node->getAttribute("text"); + $folder = $folderMap[$node->parentNode] ?? 0; + $categories = $node->getAttribute("category"); + if (strlen($categories)) { + $categories = array_map(function($v) { + return trim(preg_replace("/\s+/g", " ", $v)); + }, explode(",", $categories)); + } + $feeds[] = ['url' => $url, 'title' => $title, 'folder' => $folder, 'categories' => $categories]; + } + $node = $node->nextSibling ?: $node->parentNode; + } else { + if (!$flat) { + $id = sizeof($folderMap); + $folderMap[$node] = $id; + $folders[$id] = ['id' => $id, 'name' => $node->getAttribute("text"), 'parent' => $folderMap[$node->parentNode]]; + } + $node = $node->hasChildNodes() ? $node->firstChild : ($node->nextSibling ?: $node->parentNode); + } + } else { + $node = $node->nextSibling ?: $node->parentNode; + } + } + return [$feeds, $folders]; + } + public function export(string $user, bool $flat = false): string { if (!Arsse::$user->exists($user)) { throw new UserException("doesNotExist", ["action" => __FUNCTION__, "user" => $user]); From ceecd583937dd7c7b4016ab7b77bbc7b09db8a41 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Sun, 21 Apr 2019 13:10:47 -0400 Subject: [PATCH 106/142] OPML parsing comments and minr fixes --- lib/Database.php | 2 +- lib/ImportExport/OPML.php | 16 +++++++++++++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/lib/Database.php b/lib/Database.php index 24869078..df29fb46 100644 --- a/lib/Database.php +++ b/lib/Database.php @@ -700,7 +700,7 @@ class Database { * @param string $url The URL of the newsfeed or discovery source * @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 $discovery Whether to perform newsfeed discovery if $url points to an HTML document + * @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__)) { diff --git a/lib/ImportExport/OPML.php b/lib/ImportExport/OPML.php index 06ebfb38..5a1da742 100644 --- a/lib/ImportExport/OPML.php +++ b/lib/ImportExport/OPML.php @@ -11,7 +11,8 @@ use JKingWeb\Arsse\User\Exception as UserException; class OPML { public function import(string $user, string $opml, bool $flat = false, bool $replace = false): bool { - list($folders, $feeds) = $this->parse($opml, $flat); + list($feeds, $folders) = $this->parse($opml, $flat); + return true; } @@ -29,34 +30,47 @@ class OPML { $body = $body->item(0); $folders = []; $feeds = []; + // add the root folder to a map from folder DOM nodes to folder ID numbers $folderMap = new \SplObjectStorage; $folderMap[$body] = sizeof($folderMap); + // iterate through each node in the body $node = $body->firstChild; while ($node && $node != $body) { if ($node->nodeType == \XML_ELEMENT_NODE && $node->nodeName === "outline") { + // process any nodes which are outlines if ($node->getAttribute("type") === "rss") { + // feed nodes $url = $node->getAttribute("xmlUrl"); if (strlen($url)) { + // only process the node if it has a URL $title = $node->getAttribute("text"); $folder = $folderMap[$node->parentNode] ?? 0; $categories = $node->getAttribute("category"); if (strlen($categories)) { + // collapse and trim whitespace from category names, if any, splitting along commas $categories = array_map(function($v) { return trim(preg_replace("/\s+/g", " ", $v)); }, explode(",", $categories)); + } else { + $categories = []; } $feeds[] = ['url' => $url, 'title' => $title, 'folder' => $folder, 'categories' => $categories]; } + // skip any child nodes of a feed outline-entry $node = $node->nextSibling ?: $node->parentNode; } else { + // any outline entries which are not feeds are treated as folders if (!$flat) { + // only process folders if we're not treating he file as flat $id = sizeof($folderMap); $folderMap[$node] = $id; $folders[$id] = ['id' => $id, 'name' => $node->getAttribute("text"), 'parent' => $folderMap[$node->parentNode]]; } + // proceed to child nodes, if any $node = $node->hasChildNodes() ? $node->firstChild : ($node->nextSibling ?: $node->parentNode); } } else { + // skip any node which is not an outline element; if the node has descendents they are skipped as well $node = $node->nextSibling ?: $node->parentNode; } } From 2af223753d0c0493883f31b491b83532928fd0d8 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Sun, 21 Apr 2019 14:07:36 -0400 Subject: [PATCH 107/142] Function to add a feed without a subscription --- lib/Database.php | 55 ++++++++++++++++++++++++++++++------------------ 1 file changed, 35 insertions(+), 20 deletions(-) diff --git a/lib/Database.php b/lib/Database.php index df29fb46..43e6a2e5 100644 --- a/lib/Database.php +++ b/lib/Database.php @@ -706,26 +706,8 @@ class Database { if (!Arsse::$user->authorize($user, __FUNCTION__)) { throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); } - // check to see if the feed exists - $check = $this->db->prepare("SELECT id from arsse_feeds where url = ? and username = ? and password = ?", "str", "str", "str"); - $feedID = $check->run($url, $fetchUser, $fetchPassword)->getValue(); - if ($discover && is_null($feedID)) { - // if the feed doesn't exist, first perform discovery if requested and check for the existence of that URL - $url = Feed::discover($url, $fetchUser, $fetchPassword); - $feedID = $check->run($url, $fetchUser, $fetchPassword)->getValue(); - } - if (is_null($feedID)) { - // if the feed still doesn't exist in the database, add it to the database; we do this unconditionally so as to lock SQLite databases for as little time as possible - $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); - } 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); - throw $e; - } - } + // 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. return $this->db->prepare('INSERT INTO arsse_subscriptions(owner,feed) values(?,?)', 'str', 'int')->run($user, $feedID)->lastId(); } @@ -983,6 +965,39 @@ class Database { return $out; } + /** Adds a newsfeed to the database without adding any subscriptions, and returns the numeric identifier of the added feed + * + * If the feed already exists in the database, the existing ID is returned + * + * @param string $url The URL of the newsfeed or discovery source + * @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 + */ + public function feedAdd(string $url, string $fetchUser = "", string $fetchPassword = "", bool $discover = true): int { + // check to see if the feed already exists + $check = $this->db->prepare("SELECT id from arsse_feeds where url = ? and username = ? and password = ?", "str", "str", "str"); + $feedID = $check->run($url, $fetchUser, $fetchPassword)->getValue(); + if ($discover && is_null($feedID)) { + // if the feed doesn't exist, first perform discovery if requested and check for the existence of that URL + $url = Feed::discover($url, $fetchUser, $fetchPassword); + $feedID = $check->run($url, $fetchUser, $fetchPassword)->getValue(); + } + if (is_null($feedID)) { + // if the feed still doesn't exist in the database, add it to the database; we do this unconditionally so as to lock SQLite databases for as little time as possible + $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); + } 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); + throw $e; + } + } + return (int) $feedID; + } + /** Returns an indexed array of numeric identifiers for newsfeeds which should be refreshed */ public function feedListStale(): array { $feeds = $this->db->query("SELECT id from arsse_feeds where next_fetch <= CURRENT_TIMESTAMP")->getAll(); From 3899ee6b4ef2d0a0c3d5c83723a330a50964b5e6 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Sat, 27 Apr 2019 18:32:15 -0400 Subject: [PATCH 108/142] Allow for replacing label and tag associations This supplements adding and removing --- lib/Database.php | 141 +++++++++++++++-------- lib/REST/TinyTinyRSS/API.php | 3 +- tests/cases/Database/SeriesLabel.php | 37 +++++- tests/cases/Database/SeriesTag.php | 45 ++++++-- tests/cases/REST/TinyTinyRSS/TestAPI.php | 16 +-- 5 files changed, 173 insertions(+), 69 deletions(-) diff --git a/lib/Database.php b/lib/Database.php index 43e6a2e5..72a06e45 100644 --- a/lib/Database.php +++ b/lib/Database.php @@ -43,6 +43,12 @@ class Database { const LIMIT_SET_SIZE = 25; /** The length of a string in an embedded set beyond which a parameter placeholder will be used for the string */ const LIMIT_SET_STRING_LENGTH = 200; + /** Makes tag/label association change operations remove members */ + const ASSOC_REMOVE = 0; + /** Makes tag/label association change operations add members */ + const ASSOC_ADD = 1; + /** Makes tag/label association change operations replace members */ + const ASSOC_REPLACE = 2; /** A map database driver short-names and their associated class names */ const DRIVER_NAMES = [ 'sqlite3' => \JKingWeb\Arsse\Db\SQLite3\Driver::class, @@ -1955,37 +1961,61 @@ class Database { * @param string $user The owner of the label * @param integer|string $id The numeric identifier or name of the label * @param Context $context The query context matching the desired articles - * @param boolean $remove Whether to remove (true) rather than add (true) an association with the articles matching the context + * @param int $mode Whether to add (ASSOC_ADD), remove (ASSOC_REMOVE), or replace with (ASSOC_REPLACE) the matching associations * @param boolean $byName Whether to interpret the $id parameter as the label's name (true) or identifier (false) */ - public function labelArticlesSet(string $user, $id, Context $context = null, bool $remove = false, bool $byName = false): int { + public function labelArticlesSet(string $user, $id, Context $context, int $mode = self::ASSOC_ADD, bool $byName = false): int { + if (!in_array($mode, [self::ASSOC_ADD, self::ASSOC_REMOVE, self::ASSOC_REPLACE])) { + throw new Exception("constantUnknown", $mode); // @codeCoverageIgnore + } if (!Arsse::$user->authorize($user, __FUNCTION__)) { throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); } - // validate the label ID, and get the numeric ID if matching by name + // validate the tag ID, and get the numeric ID if matching by name $id = $this->labelValidateId($user, $id, $byName, true)['id']; - $context = $context ?? new Context; - // prepare either one or two queries - // first update any existing entries with the removal or re-addition of their association - $q1 = $this->articleQuery($user, $context); - $q1->pushCTE("target_articles"); - $q1->setBody("UPDATE arsse_label_members set assigned = ?, modified = CURRENT_TIMESTAMP where label = ? and assigned <> ? and article in (select id from target_articles)", ["bool","int","bool"], [!$remove, $id, !$remove]); - $v1 = $q1->getValues(); - $q1 = $this->db->prepare($q1->getQuery(), $q1->getTypes()); - // next, if we're not removing, add any new entries that need to be added - if (!$remove) { - $q2 = $this->articleQuery($user, $context, ["id", "subscription"]); - $q2->pushCTE("target_articles"); - $q2->setBody("SELECT ?,id,subscription from target_articles where id not in (select article from arsse_label_members where label = ?)", ["int", "int"], [$id, $id]); - $v2 = $q2->getValues(); - $q2 = $this->db->prepare("INSERT INTO arsse_label_members(label,article,subscription) ".$q2->getQuery(), $q2->getTypes()); + // get the list of articles matching the context + $articles = iterator_to_array($this->articleList($user, $context ?? new Context)); + // an empty article list is a special case + if (!sizeof($articles)) { + if ($mode == self::ASSOC_REPLACE) { + // replacing with an empty set means setting everything to zero + return $this->db->prepare("UPDATE arsse_label_members set assigned = 0, modified = CURRENT_TIMESTAMP where label = ? and assigned = 1", "int")->run($id)->changes(); + } else { + // adding or removing is a no-op + return 0; + } + } else { + $articles = array_column($articles, "id"); + } + // prepare up to three queries: removing requires one, adding two, and replacing three + list($inClause, $inTypes, $inValues) = $this->generateIn($articles, "int"); + $updateQ = "UPDATE arsse_label_members set assigned = ?, modified = CURRENT_TIMESTAMP where label = ? and assigned <> ? and article %in% ($inClause)"; + $updateT = ["bool", "int", "bool", $inTypes]; + $insertQ = "INSERT INTO arsse_label_members(label,article,subscription) SELECT ?,a.id,s.id from arsse_articles as a join arsse_subscriptions as s on a.feed = s.feed where s.owner = ? and a.id not in (select article from arsse_label_members where label = ?) and a.id in ($inClause)"; + $insertT = ["int", "str", "int", $inTypes]; + $clearQ = str_replace("%in%", "not in", $updateQ); + $clearT = $updateT; + $updateQ = str_replace("%in%", "in", $updateQ); + $qList = []; + switch ($mode) { + case self::ASSOC_REMOVE: + $qList[] = [$updateQ, $updateT, [false, $id, false, $inValues]]; // soft-delete any existing associations + break; + case self::ASSOC_ADD: + $qList[] = [$updateQ, $updateT, [true, $id, true, $inValues]]; // re-enable any previously soft-deleted association + $qList[] = [$insertQ, $insertT, [$id, $user, $id, $inValues]]; // insert any newly-required associations + break; + case self::ASSOC_REPLACE: + $qList[] = [$clearQ, $clearT, [false, $id, false, $inValues]]; // soft-delete any existing associations for articles not in the list + $qList[] = [$updateQ, $updateT, [true, $id, true, $inValues]]; // re-enable any previously soft-deleted association + $qList[] = [$insertQ, $insertT, [$id, $user, $id, $inValues]]; // insert any newly-required associations + break; } // execute them in a transaction $out = 0; $tr = $this->begin(); - $out += $q1->run($v1)->changes(); - if (!$remove) { - $out += $q2->run($v2)->changes(); + foreach ($qList as list($q, $t, $v)) { + $out += $this->db->prepare($q, ...$t)->run(...$v)->changes(); } $tr->commit(); return $out; @@ -2235,47 +2265,58 @@ class Database { * * @param string $user The owner of the tag * @param integer|string $id The numeric identifier or name of the tag - * @param integer[] $context The query context matching the desired subscriptions - * @param boolean $remove Whether to remove (true) rather than add (true) an association with the subscriptions matching the context + * @param integer[] $subscriptions An array listing the desired subscriptions + * @param int $mode Whether to add (ASSOC_ADD), remove (ASSOC_REMOVE), or replace with (ASSOC_REPLACE) the listed associations * @param boolean $byName Whether to interpret the $id parameter as the tag's name (true) or identifier (false) */ - public function tagSubscriptionsSet(string $user, $id, array $subscriptions, bool $remove = false, bool $byName = false): int { + public function tagSubscriptionsSet(string $user, $id, array $subscriptions, int $mode = self::ASSOC_ADD, bool $byName = false): int { + if (!in_array($mode, [self::ASSOC_ADD, self::ASSOC_REMOVE, self::ASSOC_REPLACE])) { + throw new Exception("constantUnknown", $mode); // @codeCoverageIgnore + } 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']; - // prepare either one or two queries + // an empty subscription list is a special case + if (!sizeof($subscriptions)) { + if ($mode == self::ASSOC_REPLACE) { + // replacing with an empty set means setting everything to zero + return $this->db->prepare("UPDATE arsse_tag_members set assigned = 0, modified = CURRENT_TIMESTAMP where tag = ? and assigned = 1", "int")->run($id)->changes(); + } else { + // adding or removing is a no-op + return 0; + } + } + // prepare up to three queries: removing requires one, adding two, and replacing three list($inClause, $inTypes, $inValues) = $this->generateIn($subscriptions, "int"); - // first update any existing entries with the removal or re-addition of their association - $q1 = $this->db->prepare( - "UPDATE arsse_tag_members - set assigned = ?, modified = CURRENT_TIMESTAMP - where tag = ? and assigned <> ? and subscription in (select id from arsse_subscriptions where owner = ? and id in ($inClause))", - "bool", - "int", - "bool", - "str", - $inTypes - ); - $v1 = [!$remove, $id, !$remove, $user, $inValues]; - // next, if we're not removing, add any new entries that need to be added - if (!$remove) { - $q2 = $this->db->prepare( - "INSERT INTO arsse_tag_members(tag,subscription) SELECT ?,id from arsse_subscriptions where id not in (select subscription from arsse_tag_members where tag = ?) and owner = ? and id in ($inClause)", - "int", - "int", - "str", - $inTypes - ); - $v2 = [$id, $id, $user, $inValues]; + $updateQ = "UPDATE arsse_tag_members set assigned = ?, modified = CURRENT_TIMESTAMP where tag = ? and assigned <> ? and subscription in (select id from arsse_subscriptions where owner = ? and id %in% ($inClause))"; + $updateT = ["bool", "int", "bool", "str", $inTypes]; + $insertQ = "INSERT INTO arsse_tag_members(tag,subscription) SELECT ?,id from arsse_subscriptions where id not in (select subscription from arsse_tag_members where tag = ?) and owner = ? and id in ($inClause)"; + $insertT = ["int", "int", "str", $inTypes]; + $clearQ = str_replace("%in%", "not in", $updateQ); + $clearT = $updateT; + $updateQ = str_replace("%in%", "in", $updateQ); + $qList = []; + switch ($mode) { + case self::ASSOC_REMOVE: + $qList[] = [$updateQ, $updateT, [0, $id, 0, $user, $inValues]]; // soft-delete any existing associations + break; + case self::ASSOC_ADD: + $qList[] = [$updateQ, $updateT, [1, $id, 1, $user, $inValues]]; // re-enable any previously soft-deleted association + $qList[] = [$insertQ, $insertT, [$id, $id, $user, $inValues]]; // insert any newly-required associations + break; + case self::ASSOC_REPLACE: + $qList[] = [$clearQ, $clearT, [0, $id, 0, $user, $inValues]]; // soft-delete any existing associations for subscriptions not in the list + $qList[] = [$updateQ, $updateT, [1, $id, 1, $user, $inValues]]; // re-enable any previously soft-deleted association + $qList[] = [$insertQ, $insertT, [$id, $id, $user, $inValues]]; // insert any newly-required associations + break; } // execute them in a transaction $out = 0; $tr = $this->begin(); - $out += $q1->run($v1)->changes(); - if (!$remove) { - $out += $q2->run($v2)->changes(); + foreach ($qList as list($q, $t, $v)) { + $out += $this->db->prepare($q, ...$t)->run(...$v)->changes(); } $tr->commit(); return $out; diff --git a/lib/REST/TinyTinyRSS/API.php b/lib/REST/TinyTinyRSS/API.php index 8bf85bcc..d29c0cf4 100644 --- a/lib/REST/TinyTinyRSS/API.php +++ b/lib/REST/TinyTinyRSS/API.php @@ -1017,6 +1017,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { $label = $this->labelIn($data['label_id']); $articles = explode(",", (string) $data['article_ids']); $assign = $data['assign'] ?? false; + $assign = $assign ? Database::ASSOC_ADD : Database::ASSOC_REMOVE; $out = 0; $in = array_chunk($articles, 50); for ($a = 0; $a < sizeof($in); $a++) { @@ -1024,7 +1025,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { $c = new Context; $c->articles($in[$a]); try { - $out += Arsse::$db->labelArticlesSet(Arsse::$user->id, $label, $c, !$assign); + $out += Arsse::$db->labelArticlesSet(Arsse::$user->id, $label, $c, $assign); } catch (ExceptionInput $e) { } } diff --git a/tests/cases/Database/SeriesLabel.php b/tests/cases/Database/SeriesLabel.php index 9ffc01bc..1f11004d 100644 --- a/tests/cases/Database/SeriesLabel.php +++ b/tests/cases/Database/SeriesLabel.php @@ -7,6 +7,7 @@ declare(strict_types=1); namespace JKingWeb\Arsse\TestCase\Database; use JKingWeb\Arsse\Arsse; +use JKingWeb\Arsse\Database; use JKingWeb\Arsse\Context\Context; use JKingWeb\Arsse\Misc\Date; use Phake; @@ -490,14 +491,14 @@ trait SeriesLabel { } public function testClearALabelFromArticles() { - Arsse::$db->labelArticlesSet("john.doe@example.com", 1, (new Context)->articles([1,5]), true); + Arsse::$db->labelArticlesSet("john.doe@example.com", 1, (new Context)->articles([1,5]), Database::ASSOC_REMOVE); $state = $this->primeExpectations($this->data, $this->checkMembers); $state['arsse_label_members']['rows'][0][3] = 0; $this->compareExpectations($state); } public function testApplyALabelToArticlesByName() { - Arsse::$db->labelArticlesSet("john.doe@example.com", "Interesting", (new Context)->articles([2,5]), false, true); + Arsse::$db->labelArticlesSet("john.doe@example.com", "Interesting", (new Context)->articles([2,5]), Database::ASSOC_ADD, true); $state = $this->primeExpectations($this->data, $this->checkMembers); $state['arsse_label_members']['rows'][4][3] = 1; $state['arsse_label_members']['rows'][] = [1,2,1,1]; @@ -505,12 +506,42 @@ trait SeriesLabel { } public function testClearALabelFromArticlesByName() { - Arsse::$db->labelArticlesSet("john.doe@example.com", "Interesting", (new Context)->articles([1,5]), true, true); + Arsse::$db->labelArticlesSet("john.doe@example.com", "Interesting", (new Context)->articles([1,5]), Database::ASSOC_REMOVE, true); $state = $this->primeExpectations($this->data, $this->checkMembers); $state['arsse_label_members']['rows'][0][3] = 0; $this->compareExpectations($state); } + public function testApplyALabelToNoArticles() { + Arsse::$db->labelArticlesSet("john.doe@example.com", 1, (new Context)->articles([10000])); + $state = $this->primeExpectations($this->data, $this->checkMembers); + $this->compareExpectations($state); + } + + public function testClearALabelFromNoArticles() { + Arsse::$db->labelArticlesSet("john.doe@example.com", 1, (new Context)->articles([10000]), Database::ASSOC_REMOVE); + $state = $this->primeExpectations($this->data, $this->checkMembers); + $this->compareExpectations($state); + } + + public function testReplaceArticlesOfALabel() { + Arsse::$db->labelArticlesSet("john.doe@example.com", 1, (new Context)->articles([2,5]), Database::ASSOC_REPLACE); + $state = $this->primeExpectations($this->data, $this->checkMembers); + $state['arsse_label_members']['rows'][0][3] = 0; + $state['arsse_label_members']['rows'][2][3] = 0; + $state['arsse_label_members']['rows'][4][3] = 1; + $state['arsse_label_members']['rows'][] = [1,2,1,1]; + $this->compareExpectations($state); + } + + public function testPurgeArticlesOfALabel() { + Arsse::$db->labelArticlesSet("john.doe@example.com", 1, (new Context)->articles([10000]), Database::ASSOC_REPLACE); + $state = $this->primeExpectations($this->data, $this->checkMembers); + $state['arsse_label_members']['rows'][0][3] = 0; + $state['arsse_label_members']['rows'][2][3] = 0; + $this->compareExpectations($state); + } + public function testApplyALabelToArticlesWithoutAuthority() { Phake::when(Arsse::$user)->authorize->thenReturn(false); $this->assertException("notAuthorized", "User", "ExceptionAuthz"); diff --git a/tests/cases/Database/SeriesTag.php b/tests/cases/Database/SeriesTag.php index 404e2f1b..b3ff4e48 100644 --- a/tests/cases/Database/SeriesTag.php +++ b/tests/cases/Database/SeriesTag.php @@ -7,6 +7,7 @@ declare(strict_types=1); namespace JKingWeb\Arsse\TestCase\Database; use JKingWeb\Arsse\Arsse; +use JKingWeb\Arsse\Database; use JKingWeb\Arsse\Misc\Date; use Phake; @@ -311,7 +312,7 @@ trait SeriesTag { Arsse::$db->tagPropertiesSet("john.doe@example.com", 1, ['name' => "Exciting"]); } - public function testListTagledSubscriptions() { + public function testListTaggedSubscriptions() { $exp = [1,5]; $this->assertEquals($exp, Arsse::$db->tagSubscriptionsGet("john.doe@example.com", 1)); $this->assertEquals($exp, Arsse::$db->tagSubscriptionsGet("john.doe@example.com", "Interesting", true)); @@ -323,17 +324,17 @@ trait SeriesTag { $this->assertEquals($exp, Arsse::$db->tagSubscriptionsGet("john.doe@example.com", "Lonely", true)); } - public function testListTagledSubscriptionsForAMissingTag() { + public function testListTaggedSubscriptionsForAMissingTag() { $this->assertException("subjectMissing", "Db", "ExceptionInput"); Arsse::$db->tagSubscriptionsGet("john.doe@example.com", 3); } - public function testListTagledSubscriptionsForAnInvalidTag() { + public function testListTaggedSubscriptionsForAnInvalidTag() { $this->assertException("typeViolation", "Db", "ExceptionInput"); Arsse::$db->tagSubscriptionsGet("john.doe@example.com", -1); } - public function testListTagledSubscriptionsWithoutAuthority() { + public function testListTaggedSubscriptionsWithoutAuthority() { Phake::when(Arsse::$user)->authorize->thenReturn(false); $this->assertException("notAuthorized", "User", "ExceptionAuthz"); Arsse::$db->tagSubscriptionsGet("john.doe@example.com", 1); @@ -348,14 +349,14 @@ trait SeriesTag { } public function testClearATagFromSubscriptions() { - Arsse::$db->tagSubscriptionsSet("john.doe@example.com", 1, [1,3], true); + Arsse::$db->tagSubscriptionsSet("john.doe@example.com", 1, [1,3], Database::ASSOC_REMOVE); $state = $this->primeExpectations($this->data, $this->checkMembers); $state['arsse_tag_members']['rows'][0][2] = 0; $this->compareExpectations($state); } public function testApplyATagToSubscriptionsByName() { - Arsse::$db->tagSubscriptionsSet("john.doe@example.com", "Interesting", [3,4], false, true); + Arsse::$db->tagSubscriptionsSet("john.doe@example.com", "Interesting", [3,4], Database::ASSOC_ADD, true); $state = $this->primeExpectations($this->data, $this->checkMembers); $state['arsse_tag_members']['rows'][1][2] = 1; $state['arsse_tag_members']['rows'][] = [1,4,1]; @@ -363,12 +364,42 @@ trait SeriesTag { } public function testClearATagFromSubscriptionsByName() { - Arsse::$db->tagSubscriptionsSet("john.doe@example.com", "Interesting", [1,3], true, true); + Arsse::$db->tagSubscriptionsSet("john.doe@example.com", "Interesting", [1,3], Database::ASSOC_REMOVE, true); $state = $this->primeExpectations($this->data, $this->checkMembers); $state['arsse_tag_members']['rows'][0][2] = 0; $this->compareExpectations($state); } + public function testApplyATagToNoSubscriptionsByName() { + Arsse::$db->tagSubscriptionsSet("john.doe@example.com", "Interesting", [], Database::ASSOC_ADD, true); + $state = $this->primeExpectations($this->data, $this->checkMembers); + $this->compareExpectations($state); + } + + public function testClearATagFromNoSubscriptionsByName() { + Arsse::$db->tagSubscriptionsSet("john.doe@example.com", "Interesting", [], Database::ASSOC_REMOVE, true); + $state = $this->primeExpectations($this->data, $this->checkMembers); + $this->compareExpectations($state); + } + + public function testReplaceSubscriptionsOfATag() { + Arsse::$db->tagSubscriptionsSet("john.doe@example.com", 1, [3,4], Database::ASSOC_REPLACE); + $state = $this->primeExpectations($this->data, $this->checkMembers); + $state['arsse_tag_members']['rows'][0][2] = 0; + $state['arsse_tag_members']['rows'][1][2] = 1; + $state['arsse_tag_members']['rows'][2][2] = 0; + $state['arsse_tag_members']['rows'][] = [1,4,1]; + $this->compareExpectations($state); + } + + public function testPurgeSubscriptionsOfATag() { + Arsse::$db->tagSubscriptionsSet("john.doe@example.com", 1, [], Database::ASSOC_REPLACE); + $state = $this->primeExpectations($this->data, $this->checkMembers); + $state['arsse_tag_members']['rows'][0][2] = 0; + $state['arsse_tag_members']['rows'][2][2] = 0; + $this->compareExpectations($state); + } + public function testApplyATagToSubscriptionsWithoutAuthority() { Phake::when(Arsse::$user)->authorize->thenReturn(false); $this->assertException("notAuthorized", "User", "ExceptionAuthz"); diff --git a/tests/cases/REST/TinyTinyRSS/TestAPI.php b/tests/cases/REST/TinyTinyRSS/TestAPI.php index 91b370cf..5cab9964 100644 --- a/tests/cases/REST/TinyTinyRSS/TestAPI.php +++ b/tests/cases/REST/TinyTinyRSS/TestAPI.php @@ -1289,18 +1289,18 @@ LONG_STRING; ]; Phake::when(Arsse::$db)->labelArticlesSet(Arsse::$user->id, $this->anything(), (new Context)->articles([]), $this->anything())->thenThrow(new ExceptionInput("tooShort")); // data model function requires one valid integer for multiples Phake::when(Arsse::$db)->labelArticlesSet(Arsse::$user->id, $this->anything(), (new Context)->articles($list[0]), $this->anything())->thenThrow(new ExceptionInput("tooLong")); // data model function limited to 50 items for multiples - Phake::when(Arsse::$db)->labelArticlesSet(Arsse::$user->id, 1088, (new Context)->articles($list[1]), true)->thenReturn(42); - Phake::when(Arsse::$db)->labelArticlesSet(Arsse::$user->id, 1088, (new Context)->articles($list[2]), true)->thenReturn(47); - Phake::when(Arsse::$db)->labelArticlesSet(Arsse::$user->id, 1088, (new Context)->articles($list[1]), false)->thenReturn(5); - Phake::when(Arsse::$db)->labelArticlesSet(Arsse::$user->id, 1088, (new Context)->articles($list[2]), false)->thenReturn(2); + Phake::when(Arsse::$db)->labelArticlesSet(Arsse::$user->id, 1088, (new Context)->articles($list[1]), Database::ASSOC_REMOVE)->thenReturn(42); + Phake::when(Arsse::$db)->labelArticlesSet(Arsse::$user->id, 1088, (new Context)->articles($list[2]), Database::ASSOC_REMOVE)->thenReturn(47); + Phake::when(Arsse::$db)->labelArticlesSet(Arsse::$user->id, 1088, (new Context)->articles($list[1]), Database::ASSOC_ADD)->thenReturn(5); + Phake::when(Arsse::$db)->labelArticlesSet(Arsse::$user->id, 1088, (new Context)->articles($list[2]), Database::ASSOC_ADD)->thenReturn(2); $exp = $this->respGood(['status' => "OK", 'updated' => 89]); $this->assertMessage($exp, $this->req($in[0])); - Phake::verify(Arsse::$db)->labelArticlesSet(Arsse::$user->id, 1088, (new Context)->articles($list[1]), true); - Phake::verify(Arsse::$db)->labelArticlesSet(Arsse::$user->id, 1088, (new Context)->articles($list[2]), true); + Phake::verify(Arsse::$db)->labelArticlesSet(Arsse::$user->id, 1088, (new Context)->articles($list[1]), Database::ASSOC_REMOVE); + Phake::verify(Arsse::$db)->labelArticlesSet(Arsse::$user->id, 1088, (new Context)->articles($list[2]), Database::ASSOC_REMOVE); $exp = $this->respGood(['status' => "OK", 'updated' => 7]); $this->assertMessage($exp, $this->req($in[1])); - Phake::verify(Arsse::$db)->labelArticlesSet(Arsse::$user->id, 1088, (new Context)->articles($list[1]), false); - Phake::verify(Arsse::$db)->labelArticlesSet(Arsse::$user->id, 1088, (new Context)->articles($list[2]), false); + Phake::verify(Arsse::$db)->labelArticlesSet(Arsse::$user->id, 1088, (new Context)->articles($list[1]), Database::ASSOC_ADD); + Phake::verify(Arsse::$db)->labelArticlesSet(Arsse::$user->id, 1088, (new Context)->articles($list[2]), Database::ASSOC_ADD); $exp = $this->respGood(['status' => "OK", 'updated' => 0]); $this->assertMessage($exp, $this->req($in[2])); $exp = $this->respErr("INCORRECT_USAGE"); From 67492cd7ef888e31bc3dc2b12027dec6011248a6 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Sat, 27 Apr 2019 19:50:03 -0400 Subject: [PATCH 109/142] Prototype OPML importer routine In theory the import (as opposed to parse) routine could be used for any format; this could be used to implement an ad hoc JSON format to avoid the loss of commas in tags with OPML --- lib/ImportExport/OPML.php | 149 ++++++++++++++++++++++++++++++++++---- 1 file changed, 134 insertions(+), 15 deletions(-) diff --git a/lib/ImportExport/OPML.php b/lib/ImportExport/OPML.php index 5a1da742..616fa82d 100644 --- a/lib/ImportExport/OPML.php +++ b/lib/ImportExport/OPML.php @@ -7,12 +7,134 @@ declare(strict_types=1); 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; class OPML { public function import(string $user, string $opml, bool $flat = false, bool $replace = false): bool { + // first extract useful information from the input list($feeds, $folders) = $this->parse($opml, $flat); - + $folderMap = []; + foreach ($folders as $f) { + // check to make sure folder names are all valid + if (!strlen(trim($f['name']))) { + throw new \Exception; + } + // check for duplicates + if (!isset($folderMap[$f['parent']])) { + $folderMap[$f['parent']] = []; + } + if (isset($folderMap[$f['parent']][$f['name']])) { + throw new \Exception; + } else { + $folderMap[$f['parent']][$f['name']] = true; + } + } + // get feed IDs for each URL, adding feeds where necessary + foreach ($feeds as $k => $f) { + $feeds[$k]['id'] = Arsse::$db->feedAdd(($f['url'])); + } + // start a transaction for atomic rollback + $tr = Arsse::$db->begin(); + // get current state of database + $foldersDb = iterator_to_array(Arsse::$db->folderList(Arsse::$user->id)); + $feedsDb = iterator_to_array(Arsse::$db->subscriptionList(Arsse::$user->id)); + $tagsDb = iterator_to_array(Arsse::$db->tagList(Arsse::$user->id)); + // reconcile folders + $folderMap = [0 => 0]; + foreach ($folders as $id => $f) { + $parent = $folderMap[$f['parent']]; + // find a match for the import folder in the existing folders + foreach ($foldersDb as $db) { + if ((int) $db['parent'] == $parent && $db['name'] === $f['name']) { + $folderMap[$id] = (int) $db['id']; + break; + } + } + if (!isset($folderMap[$id])) { + // if no existing folder exists, add one + $folderMap[$id] = Arsse::$db->folderAdd(Arsse::$user->id, ['name' => $f['name'], 'parent' -> $parent]); + } + } + // process newsfeed subscriptions + $feedMap = []; + $tagMap = []; + foreach ($feeds as $f) { + $folder = $folderMap[$f['folder']]; + $title = strlen(trim($f['title'])) ? $f['title'] : null; + $found = false; + // find a match for the import feed is existing subscriptions + foreach ($feedsDb as $db) { + if ((int) $db['feed'] == $f['id']) { + $found = true; + $feedMap[$f['id']] = (int) $db['id']; + break; + } + } + if (!$found) { + // if no subscription exists, add one + $feedMap[$f['id']] = Arsse::$db->subscriptionAdd(Arsse::$user->id, $f['url']); + } + if (!$found || $replace) { + // set the subscription's properties, if this is a new feed or we're doing a full replacement + Arsse::$db->subscriptionPropertiesSet(Arsse::$user->id, $feedMap[$f['id']], ['title' => $title, 'folder' => $folder]); + // compile the set of used tags, if this is a new feed or we're doing a full replacement + foreach ($f['tags'] as $t) { + if (!strlen(trim($t))) { + // ignore any blank tags + continue; + } + if (!isset($tagMap[$t])) { + // populate the tag map + $tagMap[$t] = []; + } + $tagMap[$t][] = $f['id']; + } + } + } + // set tags + $mode = $replace ? Database::ASSOC_REPLACE : Database::ASSOC_ADD; + foreach ($tagMap as $tag => $subs) { + // make sure the tag exists + $found = false; + foreach ($tagsDb as $db) { + if ($tag === $db['name']) { + $found = true; + break; + } + } + if (!$found) { + // add the tag if it wasn't found + Arsse::$db->tagAdd(Arsse::$user->id, ['name' => $tag]); + } + Arsse::$db->tagSubscriptionsSet(Arsse::$user->id, $tag, $subs, $mode, true); + } + // finally, if we're performing a replacement, delete any subscriptions, folders, or tags which were not present in the import + if ($replace) { + foreach (array_diff(array_column($feedsDb, "id"), $feedMap) as $id) { + try { + Arsse::$db->subscriptionRemove(Arsse::$user->id, $id); + } catch (InputException $e) { + // ignore errors + } + } + foreach (array_diff(array_column($foldersDb, "id"), $folderMap) as $id) { + try { + Arsse::$db->folderRemove(Arsse::$user->id, $id); + } catch (InputException $e) { + // ignore errors + } + } + foreach (array_diff(array_column($tagsDb, "name"), array_keys($tagMap)) as $id) { + try { + Arsse::$db->tagRemove(Arsse::$user->id, $id, true); + } catch (InputException $e) { + // ignore errors + } + } + } + $tr->commit(); return true; } @@ -41,21 +163,18 @@ class OPML { if ($node->getAttribute("type") === "rss") { // feed nodes $url = $node->getAttribute("xmlUrl"); - if (strlen($url)) { - // only process the node if it has a URL - $title = $node->getAttribute("text"); - $folder = $folderMap[$node->parentNode] ?? 0; - $categories = $node->getAttribute("category"); - if (strlen($categories)) { - // collapse and trim whitespace from category names, if any, splitting along commas - $categories = array_map(function($v) { - return trim(preg_replace("/\s+/g", " ", $v)); - }, explode(",", $categories)); - } else { - $categories = []; - } - $feeds[] = ['url' => $url, 'title' => $title, 'folder' => $folder, 'categories' => $categories]; + $title = $node->getAttribute("text"); + $folder = $folderMap[$node->parentNode] ?? 0; + $categories = $node->getAttribute("category"); + if (strlen($categories)) { + // collapse and trim whitespace from category names, if any, splitting along commas + $categories = array_map(function($v) { + return trim(preg_replace("/\s+/g", " ", $v)); + }, explode(",", $categories)); + } else { + $categories = []; } + $feeds[] = ['url' => $url, 'title' => $title, 'folder' => $folder, 'tags' => $categories]; // skip any child nodes of a feed outline-entry $node = $node->nextSibling ?: $node->parentNode; } else { From b9821d925afe3d4350adbe479c806d95f48cdf7a Mon Sep 17 00:00:00 2001 From: "J. King" Date: Wed, 1 May 2019 10:46:44 -0400 Subject: [PATCH 110/142] CLI for OPML import, and proper exceptions --- lib/AbstractException.php | 6 ++++++ lib/CLI.php | 11 +++++++++-- lib/ImportExport/OPML.php | 19 +++++++++++++++---- locale/en.php | 18 ++++++++---------- 4 files changed, 38 insertions(+), 16 deletions(-) diff --git a/lib/AbstractException.php b/lib/AbstractException.php index e4f22a9a..1e3a2dd3 100644 --- a/lib/AbstractException.php +++ b/lib/AbstractException.php @@ -86,8 +86,14 @@ abstract class AbstractException extends \Exception { "Feed/Exception.xmlEntity" => 10512, "Feed/Exception.subscriptionNotFound" => 10521, "Feed/Exception.unsupportedFeedFormat" => 10522, + "ImportExport/Exception.fileMissing" => 10601, + "ImportExport/Exception.fileUnreadable" => 10603, "ImportExport/Exception.fileUnwritable" => 10604, "ImportExport/Exception.fileUncreatable" => 10605, + "ImportExport/Exception.invalidSyntax" => 10611, + "ImportExport/Exception.invalidSemantics" => 10612, + "ImportExport/Exception.invalidFolderName" => 10613, + "ImportExport/Exception.invalidFolderCopy" => 10614, ]; public function __construct(string $msgID = "", $vars = null, \Throwable $e = null) { diff --git a/lib/CLI.php b/lib/CLI.php index 218e3d30..e8998962 100644 --- a/lib/CLI.php +++ b/lib/CLI.php @@ -24,7 +24,10 @@ Usage: arsse.php user unset-pass [--oldpass=] [--fever] arsse.php user auth [--fever] - arsse.php export [] [-f | --flat] + arsse.php export [] + [-f | --flat] + arsse.php import [] + [-f | --flat] [-r | --replace] arsse.php --version arsse.php --help | -h @@ -70,7 +73,7 @@ USAGE_TEXT; 'help' => false, ]); try { - $cmd = $this->command(["--help", "--version", "daemon", "feed refresh", "feed refresh-all", "conf save-defaults", "user", "export"], $args); + $cmd = $this->command(["--help", "--version", "daemon", "feed refresh", "feed refresh-all", "conf save-defaults", "user", "export", "import"], $args); if ($cmd && !in_array($cmd, ["--help", "--version", "conf save-defaults"])) { // only certain commands don't require configuration to be loaded $this->loadConf(); @@ -99,6 +102,10 @@ USAGE_TEXT; $u = $args['']; $file = $this->resolveFile($args[''], "w"); return (int) !$this->getInstance(OPML::class)->exportFile($file, $u, $args['--flat']); + case "import": + $u = $args['']; + $file = $this->resolveFile($args[''], "w"); + return (int) !$this->getInstance(OPML::class)->importFile($file, $u, $args['--flat'], $args['--replace']); } } catch (AbstractException $e) { $this->logError($e->getMessage()); diff --git a/lib/ImportExport/OPML.php b/lib/ImportExport/OPML.php index 616fa82d..be7365a7 100644 --- a/lib/ImportExport/OPML.php +++ b/lib/ImportExport/OPML.php @@ -19,14 +19,14 @@ class OPML { foreach ($folders as $f) { // check to make sure folder names are all valid if (!strlen(trim($f['name']))) { - throw new \Exception; + throw new Exception("invalidFolderName"); } // check for duplicates if (!isset($folderMap[$f['parent']])) { $folderMap[$f['parent']] = []; } if (isset($folderMap[$f['parent']][$f['name']])) { - throw new \Exception; + throw new Exception("invalidFolderCopy"); } else { $folderMap[$f['parent']][$f['name']] = true; } @@ -142,12 +142,13 @@ class OPML { $d = new \DOMDocument; if (!@$d->loadXML($opml)) { // not a valid XML document - throw new \Exception; + $err = libxml_get_last_error(); + throw new Exception("invalidSyntax", ['line' => $err->line, 'column' => $err->column]); } $body = $d->getElementsByTagName("body"); if ($d->documentElement->nodeName !== "opml" || !$body->length || $body->item(0)->parentNode != $d->documentElement) { // not a valid OPML document - throw new \Exception; + throw new Exception("invalidSemantics", ['type' => "OPML"]); } $body = $body->item(0); $folders = []; @@ -268,4 +269,14 @@ class OPML { } return true; } + + public function imortFile(string $file, string $user, bool $flat = false, bool $replace): bool { + $data = @file_get_contents($file); + if ($data === false) { + // if it fails throw an exception + $err = file_exists($file) ? "fileUnreadable" : "fileMissing"; + throw new Exception($err, ['file' => $file, 'format' => str_replace(__NAMESPACE__."\\", "", __CLASS__)]); + } + return $this->import($user, $data, $flat, $replace); + } } diff --git a/locale/en.php b/locale/en.php index a9fa045f..19fc7241 100644 --- a/locale/en.php +++ b/locale/en.php @@ -155,14 +155,12 @@ return [ 'Exception.JKingWeb/Arsse/Feed/Exception.xmlEntity' => 'Refused to parse feed "{url}" because it contains an XXE attack', 'Exception.JKingWeb/Arsse/Feed/Exception.subscriptionNotFound' => 'Unable to find a feed at location "{url}"', 'Exception.JKingWeb/Arsse/Feed/Exception.unsupportedFeedFormat' => 'Feed "{url}" is of an unsupported format', - 'Exception.JKingWeb/Arsse/ImportExport/Exception.fileUncreatable' => - 'Insufficient permissions to write {type, select, - OPML {OPML} - other {"{type}"} - } export to file "{file}"', - 'Exception.JKingWeb/Arsse/ImportExport/Exception.fileUnwritable' => - 'Insufficient permissions to write {type, select, - OPML {OPML} - other {"{type}"} - } export to existing file "{file}"', + 'Exception.JKingWeb/Arsse/ImportExport/Exception.fileMissing' => 'Import {type} file "{file}" does not exist', + 'Exception.JKingWeb/Arsse/ImportExport/Exception.fileUnreadable' => 'Insufficient permissions to read {type} file "{file}" for import', + 'Exception.JKingWeb/Arsse/ImportExport/Exception.fileUncreatable' => 'Insufficient permissions to write {type} export to file "{file}"', + 'Exception.JKingWeb/Arsse/ImportExport/Exception.fileUnwritable' => 'Insufficient permissions to write {type} export to existing file "{file}"', + 'Exception.JKingWeb/Arsse/ImportExport/Exception.invalidSyntax' => 'Input data syntax error at line {line}, column {column}', + 'Exception.JKingWeb/Arsse/ImportExport/Exception.invalidSemantics' => 'Input data is not valid {type} data', + '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', ]; From 6ef13d08804dd9fabd143f7edb2e0f1cae9b00b1 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Wed, 1 May 2019 22:52:20 -0400 Subject: [PATCH 111/142] Style fixes --- lib/CLI.php | 1 + lib/Database.php | 246 +++++++++++----------- lib/Db/Driver.php | 14 +- lib/Db/SQLite3/PDODriver.php | 4 +- lib/Db/SQLite3/PDOStatement.php | 2 +- lib/Db/Statement.php | 2 +- lib/ImportExport/OPML.php | 4 +- lib/REST/TinyTinyRSS/Search.php | 8 +- tests/cases/Database/SeriesArticle.php | 2 +- tests/cases/ImportExport/TestOPMLFile.php | 1 - tests/cases/REST/Fever/TestAPI.php | 1 - 11 files changed, 145 insertions(+), 140 deletions(-) diff --git a/lib/CLI.php b/lib/CLI.php index e8998962..5634ba6d 100644 --- a/lib/CLI.php +++ b/lib/CLI.php @@ -136,6 +136,7 @@ USAGE_TEXT; } else { return $this->userAddOrSetPassword("passwordSet", $args[""], $args[""], $args["--oldpass"]); } + // no break case "unset-pass": if ($args['--fever']) { $this->getInstance(Fever::class)->unregister($args[""]); diff --git a/lib/Database.php b/lib/Database.php index 72a06e45..ef91591f 100644 --- a/lib/Database.php +++ b/lib/Database.php @@ -14,9 +14,9 @@ use JKingWeb\Arsse\Misc\Date; use JKingWeb\Arsse\Misc\ValueInfo; /** The high-level interface with the database - * + * * The database stores information on the following things: - * + * * - Users * - Subscriptions to feeds, which belong to users * - Folders, which belong to users and contain subscriptions @@ -28,9 +28,9 @@ use JKingWeb\Arsse\Misc\ValueInfo; * - Sessions, used by some protocols to identify users across periods of time * - Tokens, similar to sessions, but with more control over their properties * - Metadata, used internally by the server - * + * * The various methods of this class perform operations on these things, with - * each public method prefixed with the thing it concerns e.g. userRemove() + * each public method prefixed with the thing it concerns e.g. userRemove() * 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 @@ -60,7 +60,7 @@ class Database { public $db; /** Constructs the database interface - * + * * @param boolean $initialize Whether to attempt to upgrade the databse schema when constructing */ public function __construct($initialize = true) { @@ -77,7 +77,7 @@ class Database { return debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 3)[2]['function']; } - /** Lists the available database drivers, as an associative array with + /** Lists the available database drivers, as an associative array with * fully-qualified class names as keys, and human-readable descriptions as values */ public static function driverList(): array { @@ -111,9 +111,9 @@ class Database { } /** Computes the column and value text of an SQL "SET" clause, validating arbitrary input against a whitelist - * + * * Returns an indexed array containing the clause text, an array of types, and another array of values - * + * * @param array $props An associative array containing untrusted data; keys are column names * @param array $valid An associative array containing a whitelist: keys are column names, and values are strings representing data types */ @@ -136,9 +136,9 @@ class Database { } /** Computes the contents of an SQL "IN()" clause, for each input value either embedding the value or producing a parameter placeholder - * + * * Returns an indexed array containing the clause text, an array of types, and an array of values. Note that the array of output values may not match the array of input values - * + * * @param array $values Arbitrary values * @param string $type A single data type applied to each value */ @@ -153,7 +153,7 @@ class Database { $params = []; $count = 0; $convType = Db\AbstractStatement::TYPE_NORM_MAP[Statement::TYPES[$type]]; - foreach($values as $v) { + foreach ($values as $v) { $v = ValueInfo::normalize($v, $convType, null, "sql"); if (is_null($v)) { // nulls are pointless to have @@ -182,11 +182,11 @@ class Database { } /** Computes basic LIKE-based text search constraints for use in a WHERE clause - * + * * Returns an indexed array containing the clause text, an array of types, and another array of values - * + * * The clause is structured such that all terms must be present across any of the columns - * + * * @param string[] $terms The terms to search for * @param string[] $cols The columns to match against; these are -not- sanitized, so much -not- come directly from user input * @param boolean $matchAny Whether the search is successful when it matches any (true) or all (false) terms @@ -200,7 +200,7 @@ class Database { $values = []; $like = $this->db->sqlToken("like"); $embedSet = sizeof($terms) > ((int) (self::LIMIT_SET_SIZE / sizeof($cols))); - foreach($terms as $term) { + foreach ($terms as $term) { $embedTerm = ($embedSet && strlen($term) <= self::LIMIT_SET_STRING_LENGTH); $term = str_replace(["%", "_", "^"], ["^%", "^_", "^^"], $term); $term = "%$term%"; @@ -255,7 +255,7 @@ 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 */ @@ -304,7 +304,7 @@ 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 */ @@ -335,10 +335,10 @@ class Database { } /** Explicitly removes a session from the database - * - * Sessions may also be invalidated as they expire, and then be automatically pruned. + * + * Sessions may also be invalidated as they expire, and then be automatically pruned. * This function can be used to explicitly invalidate a session after a user logs out - * + * * @param string $user The user who owns the session to be destroyed * @param string $id The identifier of the session to destroy */ @@ -352,7 +352,7 @@ class Database { } /** Resumes a session, returning available session data - * + * * This also has the side effect of refreshing the session if it is near its timeout */ public function sessionResume(string $id): array { @@ -386,8 +386,8 @@ class Database { return (($now + $diff) >= $expiry->getTimestamp()); } - /** Creates a new token for the given user in the given class - * + /** Creates a new token for the given user in the given class + * * @param string $user The user for whom to create the token * @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 @@ -409,7 +409,7 @@ class Database { } /** Revokes one or all tokens for a user in a class - * + * * @param string $user The user who owns the token to be revoked * @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 @@ -442,14 +442,14 @@ class Database { } /** Adds a folder for containing newsfeed subscriptions, returning an integer identifying the created folder - * + * * The $data array may contain the following keys: - * + * * - "name": A folder name, which must be a non-empty string not composed solely of whitespace; this key is required * - "parent": An integer (or null) identifying a parent folder; this key is optional - * + * * If a folder with the same name and parent already exists, this is an error - * + * * @param string $user The user who will own the folder * @param array $data An associative array defining the folder */ @@ -468,15 +468,15 @@ class Database { } /** Returns a result set listing a user's folders - * + * * Each record in the result set contains: - * + * * - "id": The folder identifier, an integer * - "name": The folder's name, a string * - "parent": The integer identifier of the folder's parent, or null * - "children": The number of child folders contained in the given folder - * - "feeds": The number of newsfeed subscriptions contained in the given folder, not including subscriptions in descendent folders - * + * - "feeds": The number of newsfeed subscriptions contained in the given folder, not including subscriptions in descendent folders + * * @param string $uer The user whose folders are to be listed * @param integer|null $parent Restricts the list to the descendents of the specified folder identifier * @param boolean $recursive Whether to list all descendents (true) or only direct children (false) @@ -511,9 +511,9 @@ class Database { } /** Deletes a folder from the database - * + * * Any descendent folders are also deleted, as are all newsfeed subscriptions contained in the deleted folder tree - * + * * @param string $user The user to whom the folder to be deleted belongs * @param integer $id The identifier of the folder to delete */ @@ -547,14 +547,14 @@ class Database { } /** Modifies the properties of a folder - * + * * The $data array must contain one or more of the following keys: - * + * * - "name": A new folder name, which must be a non-empty string not composed solely of whitespace * - "parent": An integer (or null) identifying a parent folder - * + * * If a folder with the new name and parent combination already exists, this is an error; it is also an error to move a folder to itself or one of its descendents - * + * * @param string $user The user who owns the folder to be modified * @param integer $id The identifier of the folder to be modified * @param array $data An associative array of properties to modify. Anything not specified will remain unchanged @@ -596,9 +596,9 @@ class Database { } /** Ensures the specified folder exists and raises an exception otherwise - * - * Returns an associative array containing the id, name, and parent of the folder if it exists - * + * + * Returns an associative array containing the id, name, and parent of the folder if it exists + * * @param string $user The user who owns the folder to be validated * @param integer|null $id The identifier of the folder to validate; null or zero represent the implied root folder * @param boolean $subject Whether the folder 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 @@ -674,7 +674,7 @@ class Database { } /** Ensures a prospective folder name is valid, and optionally ensure it is not a duplicate if renamed - * + * * @param string $name The name to check * @param boolean $checkDuplicates Whether to also check if the new name would cause a collision * @param integer|null $parent The parent folder context in which to check for duplication @@ -701,7 +701,7 @@ class Database { } /** Adds a subscription to a newsfeed, and returns the numeric identifier of the added subscription - * + * * @param string $user The user which will own the subscription * @param string $url The URL of the newsfeed or discovery source * @param string $fetchUser The user name required to access the newsfeed, if applicable @@ -719,7 +719,7 @@ class Database { } /** Lists a user's subscriptions, returning various data - * + * * @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 * @param boolean $recursive Whether to list subscriptions of descendent folders as well as the selected folder @@ -790,8 +790,8 @@ class Database { } /** Deletes a subscription from the database - * - * This has the side effect of deleting all marks the user has set on articles + * + * This has the side effect of deleting all marks the user has set on articles * belonging to the newsfeed, but may not delete the articles themselves, as * other users may also be subscribed to the same newsfeed. There is also a * configurable retention period for newsfeeds @@ -811,7 +811,7 @@ class Database { } /** 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 @@ -843,14 +843,14 @@ class Database { } /** Modifies the properties of a subscription - * + * * The $data array must contain one or more of the following keys: - * + * * - "title": The title of the newsfeed * - "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) - * + * * @param string $user The user whose subscription is to be modified * @param integer $id the numeric identifier of the subscription to modfify * @param array $data An associative array of properties to modify; any keys not specified will be left unchanged @@ -896,7 +896,7 @@ class Database { } /** Returns an indexed array listing the tags assigned to a subscription - * + * * @param string $user The user whose tags are to be listed * @param integer $id The numeric identifier of the subscription whose tags are to be listed * @param boolean $byName Whether to return the tag names (true) instead of the numeric tag identifiers (false) @@ -912,14 +912,14 @@ class Database { } /** 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 + * 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 */ @@ -953,9 +953,9 @@ 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 - * + * * @param string $user The user who owns the subscription to be validated * @param integer $id The identifier of the subscription to validate * @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 @@ -972,9 +972,9 @@ class Database { } /** Adds a newsfeed to the database without adding any subscriptions, and returns the numeric identifier of the added feed - * + * * If the feed already exists in the database, the existing ID is returned - * + * * @param string $url The URL of the newsfeed or discovery source * @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 @@ -1011,7 +1011,7 @@ class Database { } /** Attempts to refresh a newsfeed, returning an indication of success - * + * * @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 */ @@ -1167,7 +1167,7 @@ class Database { } /** Deletes orphaned newsfeeds from the database - * + * * Newsfeeds are orphaned if no users are subscribed to them. Deleting a newsfeed also deletes its articles */ public function feedCleanup(): bool { @@ -1188,14 +1188,14 @@ class Database { } /** Retrieves various identifiers for the latest $count articles in the given newsfeed. The identifiers are: - * + * * - "id": The database record key for the article * - "guid": The (theoretically) unique identifier for the article * - "edited": The time at which the article was last edited, per the newsfeed * - "url_title_hash": A cryptographic hash of the article URL and its title * - "url_content_hash": A cryptographic hash of the article URL and its content * - "title_content_hash": A cryptographic hash of the article title and its content - * + * * @param integer $feedID The numeric identifier of the feed * @param integer $count The number of records to return */ @@ -1208,14 +1208,14 @@ class Database { } /** Retrieves various identifiers for articles in the given newsfeed which match the input identifiers. The output identifiers are: - * + * * - "id": The database record key for the article * - "guid": The (theoretically) unique identifier for the article * - "edited": The time at which the article was last edited, per the newsfeed * - "url_title_hash": A cryptographic hash of the article URL and its title * - "url_content_hash": A cryptographic hash of the article URL and its content * - "title_content_hash": A cryptographic hash of the article title and its content - * + * * @param integer $feedID The numeric identifier of the feed * @param array $ids An array of GUIDs of articles * @param array $hashesUT An array of hashes of articles' URL and title @@ -1240,9 +1240,9 @@ class Database { } /** Computes an SQL query to find and retrieve data about articles in the database - * + * * If an empty column list is supplied, a count of articles matching the context is queried instead - * + * * @param string $user The user whose articles are to be queried * @param Context $context The search context * @param array $cols The columns to request in the result set @@ -1254,13 +1254,13 @@ class Database { } if ($context->folder()) { $this->folderValidateId($user, $context->folder); - } + } if ($context->folderShallow()) { $this->folderValidateId($user, $context->folderShallow); } if ($context->edition()) { $this->articleValidateEdition($user, $context->edition); - } + } if ($context->article()) { $this->articleValidateId($user, $context->article); } @@ -1362,7 +1362,7 @@ class Database { } elseif ($pair && $context->$pair()) { // option is paired with another which is also being used if ($op === ">=") { - $q->setWhere("{$colDefs[$col]} BETWEEN ? AND ?", [$type, $type], [$context->$m, $context->$pair]); + $q->setWhere("{$colDefs[$col]} BETWEEN ? AND ?", [$type, $type], [$context->$m, $context->$pair]); } else { // option has already been paired continue; @@ -1386,7 +1386,7 @@ class Database { } elseif ($pair && $context->not->$pair()) { // option is paired with another which is also being used if ($op === ">=") { - $q->setWhereNot("{$colDefs[$col]} BETWEEN ? AND ?", [$type, $type], [$context->not->$m, $context->not->$pair]); + $q->setWhereNot("{$colDefs[$col]} BETWEEN ? AND ?", [$type, $type], [$context->not->$m, $context->not->$pair]); } else { // option has already been paired continue; @@ -1406,7 +1406,7 @@ class Database { $q->setWhere("coalesce(label_stats.assigned,0) $op 0"); } if ($context->label() || $context->not->label() || $context->labelName() || $context->not->labelName()) { - $q->setCTE("labelled(article,label_id,label_name)","SELECT m.article, l.id, l.name from arsse_label_members as m join arsse_labels as l on l.id = m.label where l.owner = ? and m.assigned = 1", "str", $user); + $q->setCTE("labelled(article,label_id,label_name)", "SELECT m.article, l.id, l.name from arsse_label_members as m join arsse_labels as l on l.id = m.label where l.owner = ? and m.assigned = 1", "str", $user); if ($context->label()) { $q->setWhere("arsse_articles.id in (select article from labelled where label_id = ?)", "int", $context->label); } @@ -1421,7 +1421,7 @@ class Database { } } if ($context->tag() || $context->not->tag() || $context->tagName() || $context->not->tagName()) { - $q->setCTE("tagged(id,name,subscription)","SELECT arsse_tags.id, arsse_tags.name, arsse_tag_members.subscription FROM arsse_tag_members join arsse_tags on arsse_tags.id = arsse_tag_members.tag WHERE arsse_tags.owner = ? and assigned = 1", "str", $user); + $q->setCTE("tagged(id,name,subscription)", "SELECT arsse_tags.id, arsse_tags.name, arsse_tag_members.subscription FROM arsse_tag_members join arsse_tags on arsse_tags.id = arsse_tag_members.tag WHERE arsse_tags.owner = ? and assigned = 1", "str", $user); if ($context->tag()) { $q->setWhere("arsse_subscriptions.id in (select subscription from tagged where id = ?)", "int", $context->tag); } @@ -1474,9 +1474,9 @@ class Database { } /** Lists articles in the database which match a given query context - * + * * If an empty column list is supplied, a count of articles is returned instead - * + * * @param string $user The user whose articles are to be listed * @param Context $context The search context * @param array $cols The columns to return in the result set, any of: id, edition, url, title, author, content, guid, fingerprint, folder, subscription, feed, starred, unread, note, published_date, edited_date, modified_date, marked_date, subscription_title, media_url, media_type @@ -1494,7 +1494,7 @@ class Database { } /** Returns a count of articles which match the given query context - * + * * @param string $user The user whose articles are to be counted * @param Context $context The search context */ @@ -1508,13 +1508,13 @@ class Database { } /** Applies one or multiple modifications to all articles matching the given query context - * + * * The $data array enumerates the modifications to perform and must contain one or more of the following keys: - * + * * - "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 * - "note": A string containing a freeform plain-text note for the article - * + * * @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 @@ -1598,9 +1598,9 @@ class Database { } /** Returns statistics about the articles starred by the given user - * + * * The associative array returned has the following keys: - * + * * - "total": The count of all starred articles * - "unread": The count of starred articles which are unread * - "read": The count of starred articles which are read @@ -1622,7 +1622,7 @@ class Database { } /** Returns an indexed array listing the labels assigned to an article - * + * * @param string $user The user whose labels are to be listed * @param integer $id The numeric identifier of the article whose labels are to be listed * @param boolean $byName Whether to return the label names (true) instead of the numeric label identifiers (false) @@ -1686,9 +1686,9 @@ class Database { } /** Ensures the specified article exists and raises an exception otherwise - * - * Returns an associative array containing the id and latest edition of the article if it exists - * + * + * Returns an associative array containing the id and latest edition of the article if it exists + * * @param string $user The user who owns the article to be validated * @param integer $id The identifier of the article to validate */ @@ -1713,9 +1713,9 @@ class Database { } /** Ensures the specified article edition exists and raises an exception otherwise - * - * Returns an associative array containing the edition id, article id, and latest edition of the edition if it exists - * + * + * Returns an associative array containing the edition id, article id, and latest edition of the edition if it exists + * * @param string $user The user who owns the edition to be validated * @param integer $id The identifier of the edition to validate */ @@ -1766,9 +1766,9 @@ class Database { } /** Creates a label, and returns its numeric identifier - * + * * Labels are discrete objects in the database and can be associated with multiple articles; an article may in turn be associated with multiple labels - * + * * @param string $user The user who will own the created label * @param array $data An associative array defining the label's properties; currently only "name" is understood */ @@ -1785,14 +1785,14 @@ class Database { } /** Lists a user's article labels - * + * * The following keys are included in each record: - * + * * - "id": The label's numeric identifier * - "name" The label's textual name * - "articles": The count of articles which have the label assigned to them * - "read": How many of the total articles assigned to the label are read - * + * * @param string $user The user whose labels are to be listed * @param boolean $includeEmpty Whether to include (true) or supress (false) labels which have no articles assigned to them */ @@ -1829,9 +1829,9 @@ class Database { } /** Deletes a label from the database - * + * * Any articles associated with the label remains untouched - * + * * @param string $user The owner of the label to remove * @param integer|string $id The numeric identifier or name of the label * @param boolean $byName Whether to interpret the $id parameter as the label's name (true) or identifier (false) @@ -1851,14 +1851,14 @@ class Database { } /** Retrieves the properties of a label - * + * * The following keys are included in the output array: - * + * * - "id": The label's numeric identifier * - "name" The label's textual name * - "articles": The count of articles which have the label assigned to them * - "read": How many of the total articles assigned to the label are read - * + * * @param string $user The owner of the label to remove * @param integer|string $id The numeric identifier or name of the label * @param boolean $byName Whether to interpret the $id parameter as the label's name (true) or identifier (false) @@ -1899,7 +1899,7 @@ class Database { } /** Sets the properties of a label - * + * * @param string $user The owner of the label to query * @param integer|string $id The numeric identifier or name of the label * @param array $data An associative array defining the label's properties; currently only "name" is understood @@ -1931,7 +1931,7 @@ class Database { } /** Returns an indexed array of article identifiers assigned to a label - * + * * @param string $user The owner of the label to query * @param integer|string $id The numeric identifier or name of the label * @param boolean $byName Whether to interpret the $id parameter as the label's name (true) or identifier (false) @@ -1957,7 +1957,7 @@ class Database { } /** Makes or breaks associations between a given label and articles matching the given query context - * + * * @param string $user The owner of the label * @param integer|string $id The numeric identifier or name of the label * @param Context $context The query context matching the desired articles @@ -2022,9 +2022,9 @@ class Database { } /** Ensures the specified label identifier or name is valid (and optionally whether it exists) and raises an exception otherwise - * - * Returns an associative array containing the id, name of the label if it exists - * + * + * Returns an associative array containing the id, name of the label if it exists + * * @param string $user The user who owns the label to be validated * @param integer|string $id The numeric identifier or name of the label to validate * @param boolean $byName Whether to interpret the $id parameter as the label's name (true) or identifier (false) @@ -2069,9 +2069,9 @@ class Database { } /** Creates a tag, and returns its numeric identifier - * + * * Tags are discrete objects in the database and can be associated with multiple subscriptions; a subscription may in turn be associated with multiple tags - * + * * @param string $user The user who will own the created tag * @param array $data An associative array defining the tag's properties; currently only "name" is understood */ @@ -2088,13 +2088,13 @@ class Database { } /** Lists a user's subscription tags - * + * * The following keys are included in each record: - * + * * - "id": The tag's numeric identifier * - "name" The tag's textual name * - "subscriptions": The count of subscriptions which have the tag assigned to them - * + * * @param string $user The user whose tags are to be listed * @param boolean $includeEmpty Whether to include (true) or supress (false) tags which have no subscriptions assigned to them */ @@ -2119,14 +2119,14 @@ class Database { } /** Lists the associations between all tags and subscription - * + * * The following keys are included in each record: - * + * * - "tag_id": The tag's numeric identifier * - "tag_name" The tag's textual name * - "subscription_id": The numeric identifier of the associated subscription * - "subscription_name" The subscription's textual name - * + * * @param string $user The user whose tags are to be listed */ public function tagSummarize(string $user): Db\Result { @@ -2147,9 +2147,9 @@ class Database { } /** Deletes a tag from the database - * + * * Any subscriptions associated with the tag remains untouched - * + * * @param string $user The owner of the tag to remove * @param integer|string $id The numeric identifier or name of the tag * @param boolean $byName Whether to interpret the $id parameter as the tag's name (true) or identifier (false) @@ -2169,13 +2169,13 @@ class Database { } /** Retrieves the properties of a tag - * + * * The following keys are included in the output array: - * + * * - "id": The tag's numeric identifier * - "name" The tag's textual name * - "subscriptions": The count of subscriptions which have the tag assigned to them - * + * * @param string $user The owner of the tag to remove * @param integer|string $id The numeric identifier or name of the tag * @param boolean $byName Whether to interpret the $id parameter as the tag's name (true) or identifier (false) @@ -2204,7 +2204,7 @@ class Database { } /** Sets the properties of a tag - * + * * @param string $user The owner of the tag to query * @param integer|string $id The numeric identifier or name of the tag * @param array $data An associative array defining the tag's properties; currently only "name" is understood @@ -2236,7 +2236,7 @@ class Database { } /** Returns an indexed array of subscription identifiers assigned to a tag - * + * * @param string $user The owner of the tag to query * @param integer|string $id The numeric identifier or name of the tag * @param boolean $byName Whether to interpret the $id parameter as the tag's name (true) or identifier (false) @@ -2262,7 +2262,7 @@ class Database { } /** Makes or breaks associations between a given tag and specified subscriptions - * + * * @param string $user The owner of the tag * @param integer|string $id The numeric identifier or name of the tag * @param integer[] $subscriptions An array listing the desired subscriptions @@ -2323,9 +2323,9 @@ class Database { } /** Ensures the specified tag identifier or name is valid (and optionally whether it exists) and raises an exception otherwise - * - * Returns an associative array containing the id, name of the tag if it exists - * + * + * Returns an associative array containing the id, name of the tag if it exists + * * @param string $user The user who owns the tag to be validated * @param integer|string $id The numeric identifier or name of the tag to validate * @param boolean $byName Whether to interpret the $id parameter as the tag's name (true) or identifier (false) diff --git a/lib/Db/Driver.php b/lib/Db/Driver.php index b0f572c8..7f04dc6c 100644 --- a/lib/Db/Driver.php +++ b/lib/Db/Driver.php @@ -20,7 +20,7 @@ interface Driver { public static function driverName(): string; /** Returns the version of the schema of the opened database; if uninitialized should return 0 - * + * * Normally the version is stored under the 'schema_version' key in the arsse_meta table, but another method may be used if appropriate */ public function schemaVersion(): int; @@ -32,7 +32,7 @@ interface Driver { public function begin(bool $lock = false): Transaction; /** Manually begins a real or synthetic transactions, with real or synthetic nesting, and returns its numeric ID - * + * * If the database backend does not implement savepoints, IDs must still be tracked as if it does */ public function savepointCreate(): int; @@ -44,7 +44,7 @@ interface Driver { public function savepointUndo(int $index = null): bool; /** Performs an in-place upgrade of the database schema - * + * * The driver may choose not to implement in-place upgrading, in which case an exception should be thrown */ public function schemaUpdate(int $to): bool; @@ -62,15 +62,15 @@ interface Driver { public function prepareArray(string $query, array $paramTypes): Statement; /** Reports whether the database character set is correct/acceptable - * + * * The backend must be able to accept and provide UTF-8 text; information may be stored in any encoding capable of representing the entire range of Unicode */ public function charsetAcceptable(): bool; /** Returns an implementation-dependent form of a reference SQL function or operator - * + * * The tokens the implementation must understand are: - * + * * - "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 @@ -78,7 +78,7 @@ interface Driver { public function sqlToken(string $token): string; /** Returns a string literal which is properly escaped to guard against SQL injections. Delimiters are included in the output string - * + * * This functionality should be avoided in favour of using statement parameters whenever possible */ public function literalString(string $str): string; diff --git a/lib/Db/SQLite3/PDODriver.php b/lib/Db/SQLite3/PDODriver.php index b1cff198..c6d7ad40 100644 --- a/lib/Db/SQLite3/PDODriver.php +++ b/lib/Db/SQLite3/PDODriver.php @@ -50,7 +50,7 @@ class PDODriver extends AbstractPDODriver { /** @codeCoverageIgnore */ public function exec(string $query): bool { - // because PDO uses sqlite3_prepare() internally instead of sqlite3_prepare_v2(), + // because PDO uses sqlite3_prepare() internally instead of sqlite3_prepare_v2(), // we have to retry ourselves in cases of schema changes // the SQLite3 class is not similarly affected $attempts = 0; @@ -68,7 +68,7 @@ class PDODriver extends AbstractPDODriver { /** @codeCoverageIgnore */ public function query(string $query): \JKingWeb\Arsse\Db\Result { - // because PDO uses sqlite3_prepare() internally instead of sqlite3_prepare_v2(), + // because PDO uses sqlite3_prepare() internally instead of sqlite3_prepare_v2(), // we have to retry ourselves in cases of schema changes // the SQLite3 class is not similarly affected $attempts = 0; diff --git a/lib/Db/SQLite3/PDOStatement.php b/lib/Db/SQLite3/PDOStatement.php index 166fe313..eb4fdfe4 100644 --- a/lib/Db/SQLite3/PDOStatement.php +++ b/lib/Db/SQLite3/PDOStatement.php @@ -12,7 +12,7 @@ class PDOStatement extends \JKingWeb\Arsse\Db\PDOStatement { /** @codeCoverageIgnore */ public function runArray(array $values = []): \JKingWeb\Arsse\Db\Result { - // because PDO uses sqlite3_prepare() internally instead of sqlite3_prepare_v2(), + // because PDO uses sqlite3_prepare() internally instead of sqlite3_prepare_v2(), // we have to retry ourselves in cases of schema changes // the SQLite3 class is not similarly affected $attempts = 0; diff --git a/lib/Db/Statement.php b/lib/Db/Statement.php index b85ceca4..0ed86856 100644 --- a/lib/Db/Statement.php +++ b/lib/Db/Statement.php @@ -24,7 +24,7 @@ interface Statement { 'str' => self::T_STRING, 'bool' => self::T_BOOLEAN, 'boolean' => self::T_BOOLEAN, - 'bit' => self::T_BOOLEAN, + 'bit' => self::T_BOOLEAN, 'strict int' => self::T_NOT_NULL + self::T_INTEGER, 'strict integer' => self::T_NOT_NULL + self::T_INTEGER, 'strict float' => self::T_NOT_NULL + self::T_FLOAT, diff --git a/lib/ImportExport/OPML.php b/lib/ImportExport/OPML.php index be7365a7..fd599a2d 100644 --- a/lib/ImportExport/OPML.php +++ b/lib/ImportExport/OPML.php @@ -69,7 +69,7 @@ class OPML { if ((int) $db['feed'] == $f['id']) { $found = true; $feedMap[$f['id']] = (int) $db['id']; - break; + break; } } if (!$found) { @@ -138,7 +138,7 @@ class OPML { return true; } - protected function parse(string $opml, bool $flat): array { + public function parse(string $opml, bool $flat): array { $d = new \DOMDocument; if (!@$d->loadXML($opml)) { // not a valid XML document diff --git a/lib/REST/TinyTinyRSS/Search.php b/lib/REST/TinyTinyRSS/Search.php index 4ff634b6..f7913616 100644 --- a/lib/REST/TinyTinyRSS/Search.php +++ b/lib/REST/TinyTinyRSS/Search.php @@ -82,6 +82,7 @@ class Search { $state = self::STATE_IN_TOKEN_OR_TAG; continue 3; } + // no break case self::STATE_BEFORE_TOKEN_QUOTED: switch ($char) { case "": @@ -130,6 +131,7 @@ class Search { $state = self::STATE_IN_TOKEN_OR_TAG_QUOTED; continue 3; } + // no break case self::STATE_IN_DATE: while ($pos < $stop && $search[$pos] !== " ") { $buffer .= $search[$pos++]; @@ -169,6 +171,7 @@ class Search { $buffer .= $char; continue 3; } + // no break case self::STATE_IN_TOKEN: while ($pos < $stop && $search[$pos] !== " ") { $buffer .= $search[$pos++]; @@ -214,6 +217,7 @@ class Search { $buffer .= $char; continue 3; } + // no break case self::STATE_IN_TOKEN_OR_TAG: switch ($char) { case "": @@ -223,7 +227,7 @@ class Search { $flag_negative = false; $buffer = $tag = ""; continue 3; - case ":"; + case ":": $tag = $buffer; $buffer = ""; $state = self::STATE_IN_TOKEN; @@ -232,6 +236,7 @@ class Search { $buffer .= $char; continue 3; } + // no break case self::STATE_IN_TOKEN_OR_TAG_QUOTED: switch ($char) { case "": @@ -267,6 +272,7 @@ class Search { $buffer .= $char; continue 3; } + // no break default: throw new \Exception; // @codeCoverageIgnore } diff --git a/tests/cases/Database/SeriesArticle.php b/tests/cases/Database/SeriesArticle.php index 5340fcc7..048cd184 100644 --- a/tests/cases/Database/SeriesArticle.php +++ b/tests/cases/Database/SeriesArticle.php @@ -491,7 +491,7 @@ trait SeriesArticle { 'Excluded folder tree' => [(new Context)->not->folder(1), [1,2,3,4,19,20]], 'Excluding label ID 2' => [(new Context)->not->label(2), [2,3,4,6,7,8,19]], 'Excluding label "Fascinating"' => [(new Context)->not->labelName("Fascinating"), [2,3,4,6,7,8,19]], - 'Search 501 terms' => [(new Context)->searchTerms(array_merge(range(1,500),[str_repeat("a", 1000)])), []], + 'Search 501 terms' => [(new Context)->searchTerms(array_merge(range(1, 500), [str_repeat("a", 1000)])), []], 'With tag ID 1' => [(new Context)->tag(1), [5,6,7,8]], 'With tag ID 5' => [(new Context)->tag(5), [7,8,19,20]], 'With tag "Technology"' => [(new Context)->tagName("Technology"), [5,6,7,8]], diff --git a/tests/cases/ImportExport/TestOPMLFile.php b/tests/cases/ImportExport/TestOPMLFile.php index ecb601d1..37b9e61d 100644 --- a/tests/cases/ImportExport/TestOPMLFile.php +++ b/tests/cases/ImportExport/TestOPMLFile.php @@ -10,7 +10,6 @@ use JKingWeb\Arsse\ImportExport\OPML; use JKingWeb\Arsse\ImportExport\Exception; use org\bovigo\vfs\vfsStream; - /** @covers \JKingWeb\Arsse\ImportExport\OPML */ class TestOPMLFile extends \JKingWeb\Arsse\Test\AbstractTest { protected $vfs; diff --git a/tests/cases/REST/Fever/TestAPI.php b/tests/cases/REST/Fever/TestAPI.php index 272a25fc..1986db07 100644 --- a/tests/cases/REST/Fever/TestAPI.php +++ b/tests/cases/REST/Fever/TestAPI.php @@ -26,7 +26,6 @@ use Zend\Diactoros\Response\EmptyResponse; /** @covers \JKingWeb\Arsse\REST\Fever\API */ class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest { - protected function v($value) { return $value; } From 5ba009cfed30a0612028586f615888c4f38cc4a3 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Thu, 2 May 2019 12:52:52 -0400 Subject: [PATCH 112/142] First set of OPML parser tests --- lib/ImportExport/OPML.php | 2 +- tests/bootstrap.php | 1 + tests/cases/ImportExport/TestOPML.php | 22 +++++++++++++++++++++ tests/docroot/Import/OPML/BrokenOPML.1.opml | 1 + tests/docroot/Import/OPML/BrokenOPML.2.opml | 1 + tests/docroot/Import/OPML/BrokenOPML.3.opml | 5 +++++ tests/docroot/Import/OPML/BrokenXML.opml | 1 + 7 files changed, 32 insertions(+), 1 deletion(-) create mode 100644 tests/docroot/Import/OPML/BrokenOPML.1.opml create mode 100644 tests/docroot/Import/OPML/BrokenOPML.2.opml create mode 100644 tests/docroot/Import/OPML/BrokenOPML.3.opml create mode 100644 tests/docroot/Import/OPML/BrokenXML.opml diff --git a/lib/ImportExport/OPML.php b/lib/ImportExport/OPML.php index fd599a2d..b8711bdd 100644 --- a/lib/ImportExport/OPML.php +++ b/lib/ImportExport/OPML.php @@ -146,7 +146,7 @@ class OPML { throw new Exception("invalidSyntax", ['line' => $err->line, 'column' => $err->column]); } $body = $d->getElementsByTagName("body"); - if ($d->documentElement->nodeName !== "opml" || !$body->length || $body->item(0)->parentNode != $d->documentElement) { + if ($d->documentElement->nodeName !== "opml" || !$body->length || !$body->item(0)->parentNode->isSameNode($d->documentElement)) { // not a valid OPML document throw new Exception("invalidSemantics", ['type' => "OPML"]); } diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 59c04a1f..68c7ea8b 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -8,6 +8,7 @@ namespace JKingWeb\Arsse; const NS_BASE = __NAMESPACE__."\\"; define(NS_BASE."BASE", dirname(__DIR__).DIRECTORY_SEPARATOR); +const DOCROOT = BASE."tests".DIRECTORY_SEPARATOR."docroot".DIRECTORY_SEPARATOR; ini_set("memory_limit", "-1"); error_reporting(\E_ALL); require_once BASE."vendor".DIRECTORY_SEPARATOR."autoload.php"; diff --git a/tests/cases/ImportExport/TestOPML.php b/tests/cases/ImportExport/TestOPML.php index 2c8d7d29..a17cdaff 100644 --- a/tests/cases/ImportExport/TestOPML.php +++ b/tests/cases/ImportExport/TestOPML.php @@ -9,6 +9,7 @@ namespace JKingWeb\Arsse\TestCase\ImportExport; use JKingWeb\Arsse\Arsse; use JKingWeb\Arsse\Test\Result; use JKingWeb\Arsse\ImportExport\OPML; +use JKingWeb\Arsse\ImportExport\Exception; /** @covers \JKingWeb\Arsse\ImportExport\OPML */ class TestOPML extends \JKingWeb\Arsse\Test\AbstractTest { @@ -104,4 +105,25 @@ OPML_EXPORT_SERIALIZATION; $this->assertException("doesNotExist", "User"); (new OPML)->export("john.doe@example.com"); } + + /** @dataProvider provideParserData */ + public function testParseOpmlForImport(string $file, bool $flat, $exp) { + $data = file_get_contents(\JKingWeb\Arsse\DOCROOT."Import/OPML/$file"); + $parser = new OPML; + if ($exp instanceof \JKingWeb\Arsse\AbstractException) { + $this->assertException($exp); + $parser->parse($data, $flat); + } else { + $this->assertSame($exp, $parse->parse($data, $flat)); + } + } + + public function provideParserData() { + return [ + ["BrokenXML.opml", false, new Exception("invalidSyntax")], + ["BrokenOPML.1.opml", false, new Exception("invalidSemantics")], + ["BrokenOPML.2.opml", false, new Exception("invalidSemantics")], + ["BrokenOPML.3.opml", false, new Exception("invalidSemantics")], + ]; + } } diff --git a/tests/docroot/Import/OPML/BrokenOPML.1.opml b/tests/docroot/Import/OPML/BrokenOPML.1.opml new file mode 100644 index 00000000..1f551eab --- /dev/null +++ b/tests/docroot/Import/OPML/BrokenOPML.1.opml @@ -0,0 +1 @@ + diff --git a/tests/docroot/Import/OPML/BrokenOPML.2.opml b/tests/docroot/Import/OPML/BrokenOPML.2.opml new file mode 100644 index 00000000..a6c08015 --- /dev/null +++ b/tests/docroot/Import/OPML/BrokenOPML.2.opml @@ -0,0 +1 @@ + diff --git a/tests/docroot/Import/OPML/BrokenOPML.3.opml b/tests/docroot/Import/OPML/BrokenOPML.3.opml new file mode 100644 index 00000000..466ca0ca --- /dev/null +++ b/tests/docroot/Import/OPML/BrokenOPML.3.opml @@ -0,0 +1,5 @@ + + + + + diff --git a/tests/docroot/Import/OPML/BrokenXML.opml b/tests/docroot/Import/OPML/BrokenXML.opml new file mode 100644 index 00000000..95028ac2 --- /dev/null +++ b/tests/docroot/Import/OPML/BrokenXML.opml @@ -0,0 +1 @@ + From cdd9f4dfbeb26faa4f077d4fb091a0219e36e764 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Thu, 2 May 2019 21:54:49 -0400 Subject: [PATCH 113/142] More OPML parser tests --- lib/ImportExport/OPML.php | 4 ++-- tests/cases/ImportExport/TestOPML.php | 6 +++++- tests/docroot/Import/OPML/BrokenOPML.1.opml | 1 + tests/docroot/Import/OPML/BrokenOPML.2.opml | 1 + tests/docroot/Import/OPML/BrokenOPML.3.opml | 1 + tests/docroot/Import/OPML/BrokenOPML.4.opml | 5 +++++ tests/docroot/Import/OPML/BrokenXML.opml | 1 + tests/docroot/Import/OPML/Empty.1.opml | 4 ++++ tests/docroot/Import/OPML/Empty.2.opml | 9 +++++++++ tests/docroot/Import/OPML/Empty.3.opml | 11 +++++++++++ 10 files changed, 40 insertions(+), 3 deletions(-) create mode 100644 tests/docroot/Import/OPML/BrokenOPML.4.opml create mode 100644 tests/docroot/Import/OPML/Empty.1.opml create mode 100644 tests/docroot/Import/OPML/Empty.2.opml create mode 100644 tests/docroot/Import/OPML/Empty.3.opml diff --git a/lib/ImportExport/OPML.php b/lib/ImportExport/OPML.php index b8711bdd..ca02f2d4 100644 --- a/lib/ImportExport/OPML.php +++ b/lib/ImportExport/OPML.php @@ -145,8 +145,8 @@ class OPML { $err = libxml_get_last_error(); throw new Exception("invalidSyntax", ['line' => $err->line, 'column' => $err->column]); } - $body = $d->getElementsByTagName("body"); - if ($d->documentElement->nodeName !== "opml" || !$body->length || !$body->item(0)->parentNode->isSameNode($d->documentElement)) { + $body = (new \DOMXPath($d))->query("/opml/body"); + if ($body->length != 1) { // not a valid OPML document throw new Exception("invalidSemantics", ['type' => "OPML"]); } diff --git a/tests/cases/ImportExport/TestOPML.php b/tests/cases/ImportExport/TestOPML.php index a17cdaff..33904447 100644 --- a/tests/cases/ImportExport/TestOPML.php +++ b/tests/cases/ImportExport/TestOPML.php @@ -114,7 +114,7 @@ OPML_EXPORT_SERIALIZATION; $this->assertException($exp); $parser->parse($data, $flat); } else { - $this->assertSame($exp, $parse->parse($data, $flat)); + $this->assertSame($exp, $parser->parse($data, $flat)); } } @@ -124,6 +124,10 @@ OPML_EXPORT_SERIALIZATION; ["BrokenOPML.1.opml", false, new Exception("invalidSemantics")], ["BrokenOPML.2.opml", false, new Exception("invalidSemantics")], ["BrokenOPML.3.opml", false, new Exception("invalidSemantics")], + ["BrokenOPML.4.opml", false, new Exception("invalidSemantics")], + ["Empty.1.opml", false, [[], []]], + ["Empty.2.opml", false, [[], []]], + ["Empty.3.opml", false, [[], []]], ]; } } diff --git a/tests/docroot/Import/OPML/BrokenOPML.1.opml b/tests/docroot/Import/OPML/BrokenOPML.1.opml index 1f551eab..a626ae06 100644 --- a/tests/docroot/Import/OPML/BrokenOPML.1.opml +++ b/tests/docroot/Import/OPML/BrokenOPML.1.opml @@ -1 +1,2 @@ + diff --git a/tests/docroot/Import/OPML/BrokenOPML.2.opml b/tests/docroot/Import/OPML/BrokenOPML.2.opml index a6c08015..ac70153f 100644 --- a/tests/docroot/Import/OPML/BrokenOPML.2.opml +++ b/tests/docroot/Import/OPML/BrokenOPML.2.opml @@ -1 +1,2 @@ + diff --git a/tests/docroot/Import/OPML/BrokenOPML.3.opml b/tests/docroot/Import/OPML/BrokenOPML.3.opml index 466ca0ca..b087a1b6 100644 --- a/tests/docroot/Import/OPML/BrokenOPML.3.opml +++ b/tests/docroot/Import/OPML/BrokenOPML.3.opml @@ -3,3 +3,4 @@ + diff --git a/tests/docroot/Import/OPML/BrokenOPML.4.opml b/tests/docroot/Import/OPML/BrokenOPML.4.opml new file mode 100644 index 00000000..544e4c36 --- /dev/null +++ b/tests/docroot/Import/OPML/BrokenOPML.4.opml @@ -0,0 +1,5 @@ + + + + + diff --git a/tests/docroot/Import/OPML/BrokenXML.opml b/tests/docroot/Import/OPML/BrokenXML.opml index 95028ac2..0cbc6fe2 100644 --- a/tests/docroot/Import/OPML/BrokenXML.opml +++ b/tests/docroot/Import/OPML/BrokenXML.opml @@ -1 +1,2 @@ + diff --git a/tests/docroot/Import/OPML/Empty.1.opml b/tests/docroot/Import/OPML/Empty.1.opml new file mode 100644 index 00000000..4999faaf --- /dev/null +++ b/tests/docroot/Import/OPML/Empty.1.opml @@ -0,0 +1,4 @@ + + + + diff --git a/tests/docroot/Import/OPML/Empty.2.opml b/tests/docroot/Import/OPML/Empty.2.opml new file mode 100644 index 00000000..6dcd03f2 --- /dev/null +++ b/tests/docroot/Import/OPML/Empty.2.opml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/tests/docroot/Import/OPML/Empty.3.opml b/tests/docroot/Import/OPML/Empty.3.opml new file mode 100644 index 00000000..59fd9b46 --- /dev/null +++ b/tests/docroot/Import/OPML/Empty.3.opml @@ -0,0 +1,11 @@ + + + + + + + + + + + From a30114807fd28019fabd8d36dd2c789ad7a8e51d Mon Sep 17 00:00:00 2001 From: "J. King" Date: Sun, 5 May 2019 20:29:44 -0400 Subject: [PATCH 114/142] Tests and fixed for OPML feed parsing --- lib/ImportExport/OPML.php | 4 +-- tests/cases/ImportExport/TestOPML.php | 38 ++++++++++++++++++++++++ tests/docroot/Import/OPML/FeedsOnly.opml | 12 ++++++++ 3 files changed, 52 insertions(+), 2 deletions(-) create mode 100644 tests/docroot/Import/OPML/FeedsOnly.opml diff --git a/lib/ImportExport/OPML.php b/lib/ImportExport/OPML.php index ca02f2d4..73284135 100644 --- a/lib/ImportExport/OPML.php +++ b/lib/ImportExport/OPML.php @@ -158,7 +158,7 @@ class OPML { $folderMap[$body] = sizeof($folderMap); // iterate through each node in the body $node = $body->firstChild; - while ($node && $node != $body) { + while ($node && !$node->isSameNode($body)) { if ($node->nodeType == \XML_ELEMENT_NODE && $node->nodeName === "outline") { // process any nodes which are outlines if ($node->getAttribute("type") === "rss") { @@ -170,7 +170,7 @@ class OPML { if (strlen($categories)) { // collapse and trim whitespace from category names, if any, splitting along commas $categories = array_map(function($v) { - return trim(preg_replace("/\s+/g", " ", $v)); + return trim(preg_replace("/\s+/", " ", $v)); }, explode(",", $categories)); } else { $categories = []; diff --git a/tests/cases/ImportExport/TestOPML.php b/tests/cases/ImportExport/TestOPML.php index 33904447..94d1b057 100644 --- a/tests/cases/ImportExport/TestOPML.php +++ b/tests/cases/ImportExport/TestOPML.php @@ -128,6 +128,44 @@ OPML_EXPORT_SERIALIZATION; ["Empty.1.opml", false, [[], []]], ["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' => "", + 'title' => "", + 'folder' => 0, + 'tags' => ["whee"], + ], + [ + 'url' => "", + 'title' => "", + 'folder' => 0, + 'tags' => ["whee", "whoo", ""], + ], + ], []]], ]; } } diff --git a/tests/docroot/Import/OPML/FeedsOnly.opml b/tests/docroot/Import/OPML/FeedsOnly.opml new file mode 100644 index 00000000..4e682600 --- /dev/null +++ b/tests/docroot/Import/OPML/FeedsOnly.opml @@ -0,0 +1,12 @@ + + + + + + + + + + + + From 644750487cc6e37dcf674198206a1107fee82922 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Mon, 6 May 2019 00:02:59 -0400 Subject: [PATCH 115/142] Command line documentation and fixes --- CHANGELOG | 4 ++ lib/CLI.php | 112 +++++++++++++++++++++++++++++++++--- tests/cases/CLI/TestCLI.php | 3 + 3 files changed, 111 insertions(+), 8 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index edc4b0ab..c2ce3366 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -5,6 +5,10 @@ New features: - Support for the Fever protocol (see README.md for details) - Command line functionality for clearing a password, disabling the account - Command line options for dealing with Fever passwords +- Command line documentation of all commands and options + +Bug fixes: +- Treat command line option -h the same as --help Version 0.7.1 (2019-03-25) ========================== diff --git a/lib/CLI.php b/lib/CLI.php index 5634ba6d..7f22f949 100644 --- a/lib/CLI.php +++ b/lib/CLI.php @@ -24,16 +24,111 @@ Usage: arsse.php user unset-pass [--oldpass=] [--fever] arsse.php user auth [--fever] + arsse.php import [] + [-f | --flat] [-r | --replace] arsse.php export [] [-f | --flat] - arsse.php import [] - [-f | --flat] [-r | --replace] arsse.php --version - arsse.php --help | -h + arsse.php -h | --help -The Arsse command-line interface currently allows you to start the refresh -daemon, refresh all feeds or a specific feed by numeric ID, manage users, -or save default configuration to a sample file. +The Arsse command-line interface can be used to perform various administrative +tasks such as starting the newsfeed refresh service, managing users, and +importing or exporting data. + +Commands: + + daemon + + Starts the newsfeed refreshing service, which will refresh stale feeds at + the configured interval automatically. + + feed refresh-all + + Refreshes any stale feeds once, then exits. This performs the same + function as the daemon command without looping; this is useful if use of + a scheduler such a cron is preferred over a persitent service. + + feed refresh + + Refreshes a single feed by numeric ID. This is principally for internal + use as the feed ID numbers are not usually exposed to the user. + + conf save-defaults [] + + Prints default configuration parameters to standard output, or to + if specified. Each parameter is annotated with a short description of its + purpose and usage. + + user [list] + + Prints a list of all existing users, one per line. + + user add [] + + 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. + + user remove + + Removes the user specified by . Data related to the user, + including folders and subscriptions, are immediately deleted. Feeds to + which the user was subscribed will be retained and refreshed until the + configured retention time elapses. + + user set-pass [] + + Changes 's password to . If not password is + specified, a random password will be generated and printed to standard + output. + + The --oldpass= option can be used to supply a user's exiting + password if this is required by the authentication driver to change a + password. Currently this is not used by any existing driver. + + The --fever option sets a user's Fever protocol password instead of their + general password. As Fever requires that passwords be stored insecurely, + users do not have Fever passwords by default, and logging in to the Fever + protocol is disabled until a password is set. It is highly recommended + that a user's Fever password be different from their general password. + + user unset-pass + + Unsets a user's password, effectively disabling their account. As with + password setting, the --oldpass and --fever options may be used. + + user auth + + Tests logging in as with password . This only checks + that the user's password is currectly recognized; it has no side effects. + + The --fever option may be used to test the user's Fever protocol password, + if any. + + import [] + + Imports the feeds, folders, and tags found in the OPML formatted + into the account of . If no file is specified, data is instead + read from standard input. + + The --replace option interprets the OPML file as the list of all desired + feeds, folders and tags, performing any deletion or moving of existing + entries which do not appear in the flle. If this option is not specified, + the file is assumed to list desired additions only. + + The --flat option can be used to ignore any folder structures in the file, + importing any feeds only into the root folder. + + export [] + + Exports 's feeds, folders, and tags to the OPML file specified + by , or standard output if none is provided. Note that due to a + limitation of the OPML format, any commas present in tag names will not be + retained in the export. + + 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. USAGE_TEXT; protected function usage($prog): string { @@ -73,12 +168,13 @@ USAGE_TEXT; 'help' => false, ]); try { - $cmd = $this->command(["--help", "--version", "daemon", "feed refresh", "feed refresh-all", "conf save-defaults", "user", "export", "import"], $args); - if ($cmd && !in_array($cmd, ["--help", "--version", "conf save-defaults"])) { + $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"])) { // 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; diff --git a/tests/cases/CLI/TestCLI.php b/tests/cases/CLI/TestCLI.php index 56202d94..46290fc6 100644 --- a/tests/cases/CLI/TestCLI.php +++ b/tests/cases/CLI/TestCLI.php @@ -63,6 +63,9 @@ class TestCLI extends \JKingWeb\Arsse\Test\AbstractTest { ["arsse.php --help", "arsse.php"], ["arsse --help", "arsse"], ["thearsse --help", "thearsse"], + ["arsse.php -h", "arsse.php"], + ["arsse -h", "arsse"], + ["thearsse -h", "thearsse"], ]; } From 0f7d49c21e23d7cca9bba57c12111338f0d2a2f8 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Mon, 6 May 2019 19:36:39 -0400 Subject: [PATCH 116/142] More OPML tests and fixes --- lib/CLI.php | 5 ++-- lib/ImportExport/OPML.php | 23 +++++++++++++-- tests/cases/ImportExport/TestOPML.php | 33 ++++++++++++++++++++++ tests/docroot/Import/OPML/FoldersOnly.opml | 12 ++++++++ 4 files changed, 67 insertions(+), 6 deletions(-) create mode 100644 tests/docroot/Import/OPML/FoldersOnly.opml diff --git a/lib/CLI.php b/lib/CLI.php index 7f22f949..0b1f3b97 100644 --- a/lib/CLI.php +++ b/lib/CLI.php @@ -78,9 +78,8 @@ Commands: user set-pass [] - Changes 's password to . If not password is - specified, a random password will be generated and printed to standard - output. + Changes 's password to . If no password is specified, + a random password will be generated and printed to standard output. The --oldpass= option can be used to supply a user's exiting password if this is required by the authentication driver to change a diff --git a/lib/ImportExport/OPML.php b/lib/ImportExport/OPML.php index 73284135..a45d4e1a 100644 --- a/lib/ImportExport/OPML.php +++ b/lib/ImportExport/OPML.php @@ -151,6 +151,23 @@ class OPML { throw new Exception("invalidSemantics", ['type' => "OPML"]); } $body = $body->item(0); + // function to find the next node in the tree + $next = function(\DOMNode $node, bool $visitChildren = true) use ($body) { + if ($visitChildren && $node->hasChildNodes()) { + return $node->firstChild; + } elseif ($node->nextSibling) { + return $node->nextSibling; + } else { + while (!$node->nextSibling && !$node->isSameNode($body)) { + $node = $node->parentNode; + } + if (!$node->isSameNode($body)) { + return $node->nextSibling; + } else { + return null; + } + } + }; $folders = []; $feeds = []; // add the root folder to a map from folder DOM nodes to folder ID numbers @@ -158,7 +175,7 @@ class OPML { $folderMap[$body] = sizeof($folderMap); // iterate through each node in the body $node = $body->firstChild; - while ($node && !$node->isSameNode($body)) { + while ($node) { if ($node->nodeType == \XML_ELEMENT_NODE && $node->nodeName === "outline") { // process any nodes which are outlines if ($node->getAttribute("type") === "rss") { @@ -187,11 +204,11 @@ class OPML { $folders[$id] = ['id' => $id, 'name' => $node->getAttribute("text"), 'parent' => $folderMap[$node->parentNode]]; } // proceed to child nodes, if any - $node = $node->hasChildNodes() ? $node->firstChild : ($node->nextSibling ?: $node->parentNode); + $node = $next($node); } } else { // skip any node which is not an outline element; if the node has descendents they are skipped as well - $node = $node->nextSibling ?: $node->parentNode; + $node = $next($node, false); } } return [$feeds, $folders]; diff --git a/tests/cases/ImportExport/TestOPML.php b/tests/cases/ImportExport/TestOPML.php index 94d1b057..59ea9c15 100644 --- a/tests/cases/ImportExport/TestOPML.php +++ b/tests/cases/ImportExport/TestOPML.php @@ -166,6 +166,39 @@ OPML_EXPORT_SERIALIZATION; 'tags' => ["whee", "whoo", ""], ], ], []]], + ["FoldersOnly.opml", true, [[], []]], + ["FoldersOnly.opml", false, [[], [1 => + [ + 'id' => 1, + 'name' => "Folder 1", + 'parent' => 0, + ], + [ + 'id' => 2, + 'name' => "Folder 2", + 'parent' => 0, + ], + [ + 'id' => 3, + 'name' => "Also a folder", + 'parent' => 2, + ], + [ + 'id' => 4, + 'name' => "Still a folder", + 'parent' => 2, + ], + [ + 'id' => 5, + 'name' => "Folder 5", + 'parent' => 4, + ], + [ + 'id' => 6, + 'name' => "Folder 6", + 'parent' => 0, + ], + ]]], ]; } } diff --git a/tests/docroot/Import/OPML/FoldersOnly.opml b/tests/docroot/Import/OPML/FoldersOnly.opml new file mode 100644 index 00000000..34b7a69e --- /dev/null +++ b/tests/docroot/Import/OPML/FoldersOnly.opml @@ -0,0 +1,12 @@ + + + + + + + + + + + + From be5a1fb94f3e27b2e3864e17ac2e592e394b0601 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Wed, 8 May 2019 20:24:16 -0400 Subject: [PATCH 117/142] Mixed content test for OPML --- tests/cases/ImportExport/TestOPML.php | 92 ++++++--------------- tests/docroot/Import/OPML/MixedContent.opml | 20 +++++ 2 files changed, 46 insertions(+), 66 deletions(-) create mode 100644 tests/docroot/Import/OPML/MixedContent.opml diff --git a/tests/cases/ImportExport/TestOPML.php b/tests/cases/ImportExport/TestOPML.php index 59ea9c15..0002f2c8 100644 --- a/tests/cases/ImportExport/TestOPML.php +++ b/tests/cases/ImportExport/TestOPML.php @@ -129,75 +129,35 @@ 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' => "", - 'title' => "", - 'folder' => 0, - 'tags' => ["whee"], - ], - [ - 'url' => "", - 'title' => "", - 'folder' => 0, - 'tags' => ["whee", "whoo", ""], - ], + ['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' => "", 'title' => "", 'folder' => 0, 'tags' => ["whee"]], + ['url' => "", 'title' => "", 'folder' => 0, 'tags' => ["whee", "whoo", ""]], ], []]], ["FoldersOnly.opml", true, [[], []]], ["FoldersOnly.opml", false, [[], [1 => - [ - 'id' => 1, - 'name' => "Folder 1", - 'parent' => 0, - ], - [ - 'id' => 2, - 'name' => "Folder 2", - 'parent' => 0, - ], - [ - 'id' => 3, - 'name' => "Also a folder", - 'parent' => 2, - ], - [ - 'id' => 4, - 'name' => "Still a folder", - 'parent' => 2, - ], - [ - 'id' => 5, - 'name' => "Folder 5", - 'parent' => 4, - ], - [ - 'id' => 6, - 'name' => "Folder 6", - 'parent' => 0, - ], + ['id' => 1, 'name' => "Folder 1", 'parent' => 0], + ['id' => 2, 'name' => "Folder 2", 'parent' => 0], + ['id' => 3, 'name' => "Also a folder", 'parent' => 2], + ['id' => 4, 'name' => "Still a folder", 'parent' => 2], + ['id' => 5, 'name' => "Folder 5", 'parent' => 4], + ['id' => 6, 'name' => "Folder 6", 'parent' => 0], + ]]], + ["MixedContent.opml", false, [[ + ['url' => "https://www.jpl.nasa.gov/multimedia/rss/news.xml", 'title' => "NASA JPL", 'folder' => 3, 'tags' => ["tech"]], + ['url' => "http://feeds.arstechnica.com/arstechnica/index/", 'title' => "Ars Technica", 'folder' => 2, 'tags' => ["frequent", "tech"]], + ['url' => "https://www.thestar.com/content/thestar/feed.RSSManagerServlet.topstories.rss", 'title' => "Toronto Star", 'folder' => 5, 'tags' => ["news", "canada", "toronto"]], + ['url' => "http://rss.canada.com/get/?F239", 'title' => "Ottawa Citizen", 'folder' => 6, 'tags' => ["news", "canada"]], + ['url' => "https://www.eurogamer.net/?format=rss", 'title' => "Eurogamer", 'folder' => 0, 'tags' => ["gaming", "frequent"]], + ], [1 => + ['id' => 1, 'name' => "Photography", 'parent' => 0], + ['id' => 2, 'name' => "Science", 'parent' => 0], + ['id' => 3, 'name' => "Rocketry", 'parent' => 2], + ['id' => 4, 'name' => "Politics", 'parent' => 0], + ['id' => 5, 'name' => "Local", 'parent' => 4], + ['id' => 6, 'name' => "National", 'parent' => 4], ]]], ]; } diff --git a/tests/docroot/Import/OPML/MixedContent.opml b/tests/docroot/Import/OPML/MixedContent.opml new file mode 100644 index 00000000..db65a2c6 --- /dev/null +++ b/tests/docroot/Import/OPML/MixedContent.opml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + From c1e13e619944359f07245be24f7fc08b12c9c923 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Sun, 12 May 2019 16:33:19 -0400 Subject: [PATCH 118/142] Tests for file imports --- lib/ImportExport/OPML.php | 24 +++++------ tests/cases/ImportExport/TestOPMLFile.php | 50 +++++++++++++++++++++++ 2 files changed, 62 insertions(+), 12 deletions(-) diff --git a/lib/ImportExport/OPML.php b/lib/ImportExport/OPML.php index a45d4e1a..5c633b4b 100644 --- a/lib/ImportExport/OPML.php +++ b/lib/ImportExport/OPML.php @@ -38,9 +38,9 @@ class OPML { // start a transaction for atomic rollback $tr = Arsse::$db->begin(); // get current state of database - $foldersDb = iterator_to_array(Arsse::$db->folderList(Arsse::$user->id)); - $feedsDb = iterator_to_array(Arsse::$db->subscriptionList(Arsse::$user->id)); - $tagsDb = iterator_to_array(Arsse::$db->tagList(Arsse::$user->id)); + $foldersDb = iterator_to_array(Arsse::$db->folderList($user)); + $feedsDb = iterator_to_array(Arsse::$db->subscriptionList($user)); + $tagsDb = iterator_to_array(Arsse::$db->tagList($user)); // reconcile folders $folderMap = [0 => 0]; foreach ($folders as $id => $f) { @@ -54,7 +54,7 @@ class OPML { } if (!isset($folderMap[$id])) { // if no existing folder exists, add one - $folderMap[$id] = Arsse::$db->folderAdd(Arsse::$user->id, ['name' => $f['name'], 'parent' -> $parent]); + $folderMap[$id] = Arsse::$db->folderAdd($user, ['name' => $f['name'], 'parent' -> $parent]); } } // process newsfeed subscriptions @@ -74,11 +74,11 @@ class OPML { } if (!$found) { // if no subscription exists, add one - $feedMap[$f['id']] = Arsse::$db->subscriptionAdd(Arsse::$user->id, $f['url']); + $feedMap[$f['id']] = Arsse::$db->subscriptionAdd($user, $f['url']); } if (!$found || $replace) { // set the subscription's properties, if this is a new feed or we're doing a full replacement - Arsse::$db->subscriptionPropertiesSet(Arsse::$user->id, $feedMap[$f['id']], ['title' => $title, 'folder' => $folder]); + Arsse::$db->subscriptionPropertiesSet($user, $feedMap[$f['id']], ['title' => $title, 'folder' => $folder]); // compile the set of used tags, if this is a new feed or we're doing a full replacement foreach ($f['tags'] as $t) { if (!strlen(trim($t))) { @@ -106,29 +106,29 @@ class OPML { } if (!$found) { // add the tag if it wasn't found - Arsse::$db->tagAdd(Arsse::$user->id, ['name' => $tag]); + Arsse::$db->tagAdd($user, ['name' => $tag]); } - Arsse::$db->tagSubscriptionsSet(Arsse::$user->id, $tag, $subs, $mode, true); + Arsse::$db->tagSubscriptionsSet($user, $tag, $subs, $mode, true); } // finally, if we're performing a replacement, delete any subscriptions, folders, or tags which were not present in the import if ($replace) { foreach (array_diff(array_column($feedsDb, "id"), $feedMap) as $id) { try { - Arsse::$db->subscriptionRemove(Arsse::$user->id, $id); + Arsse::$db->subscriptionRemove($user, $id); } catch (InputException $e) { // ignore errors } } foreach (array_diff(array_column($foldersDb, "id"), $folderMap) as $id) { try { - Arsse::$db->folderRemove(Arsse::$user->id, $id); + Arsse::$db->folderRemove($user, $id); } catch (InputException $e) { // ignore errors } } foreach (array_diff(array_column($tagsDb, "name"), array_keys($tagMap)) as $id) { try { - Arsse::$db->tagRemove(Arsse::$user->id, $id, true); + Arsse::$db->tagRemove($user, $id, true); } catch (InputException $e) { // ignore errors } @@ -287,7 +287,7 @@ class OPML { return true; } - public function imortFile(string $file, string $user, bool $flat = false, bool $replace): bool { + public function importFile(string $file, string $user, bool $flat = false, bool $replace): bool { $data = @file_get_contents($file); if ($data === false) { // if it fails throw an exception diff --git a/tests/cases/ImportExport/TestOPMLFile.php b/tests/cases/ImportExport/TestOPMLFile.php index 37b9e61d..35147ef8 100644 --- a/tests/cases/ImportExport/TestOPMLFile.php +++ b/tests/cases/ImportExport/TestOPMLFile.php @@ -21,16 +21,20 @@ class TestOPMLFile extends \JKingWeb\Arsse\Test\AbstractTest { // create a mock OPML processor with stubbed underlying import/export routines $this->opml = \Phake::partialMock(OPML::class); \Phake::when($this->opml)->export->thenReturn("OPML_FILE"); + \Phake::when($this->opml)->import->thenReturn(true); $this->vfs = vfsStream::setup("root", null, [ 'exportGoodFile' => "", 'exportGoodDir' => [], 'exportBadFile' => "", 'exportBadDir' => [], + 'importGoodFile' => "", + 'importBadFile' => "", ]); $this->path = $this->vfs->url()."/"; // make the "bad" entries inaccessible chmod($this->path."exportBadFile", 0000); chmod($this->path."exportBadDir", 0000); + chmod($this->path."importBadFile", 0000); } public function tearDown() { @@ -78,4 +82,50 @@ class TestOPMLFile extends \JKingWeb\Arsse\Test\AbstractTest { ["exportBadDir/file", "jane.doe@example.com", false, $createException], ]; } + + /** @dataProvider provideFileImports */ + public function testImportFromOpmlFile(string $file, string $user, bool $flat, bool $replace, $exp) { + $path = $this->path.$file; + try { + if ($exp instanceof \JKingWeb\Arsse\AbstractException) { + $this->assertException($exp); + $this->opml->importFile($path, $user, $flat, $replace); + } else { + $this->assertSame($exp, $this->opml->importFile($path, $user, $flat, $replace)); + } + } finally { + \Phake::verify($this->opml, \Phake::times((int) ($exp === true)))->import($user, "", $flat, $replace); + } + } + + public function provideFileImports() { + $missingException = new Exception("fileMissing"); + $permissionException = new Exception("fileUnreadable"); + return [ + ["importGoodFile", "john.doe@example.com", true, true, true], + ["importBadFile", "john.doe@example.com", true, true, $permissionException], + ["importNonFile", "john.doe@example.com", true, true, $missingException], + ["importGoodFile", "john.doe@example.com", true, false, true], + ["importBadFile", "john.doe@example.com", true, false, $permissionException], + ["importNonFile", "john.doe@example.com", true, false, $missingException], + ["importGoodFile", "john.doe@example.com", false, true, true], + ["importBadFile", "john.doe@example.com", false, true, $permissionException], + ["importNonFile", "john.doe@example.com", false, true, $missingException], + ["importGoodFile", "john.doe@example.com", false, false, true], + ["importBadFile", "john.doe@example.com", false, false, $permissionException], + ["importNonFile", "john.doe@example.com", false, false, $missingException], + ["importGoodFile", "jane.doe@example.com", true, true, true], + ["importBadFile", "jane.doe@example.com", true, true, $permissionException], + ["importNonFile", "jane.doe@example.com", true, true, $missingException], + ["importGoodFile", "jane.doe@example.com", true, false, true], + ["importBadFile", "jane.doe@example.com", true, false, $permissionException], + ["importNonFile", "jane.doe@example.com", true, false, $missingException], + ["importGoodFile", "jane.doe@example.com", false, true, true], + ["importBadFile", "jane.doe@example.com", false, true, $permissionException], + ["importNonFile", "jane.doe@example.com", false, true, $missingException], + ["importGoodFile", "jane.doe@example.com", false, false, true], + ["importBadFile", "jane.doe@example.com", false, false, $permissionException], + ["importNonFile", "jane.doe@example.com", false, false, $missingException], + ]; + } } From 54aaab50b5e0850d57491c3ad22eb4aa3c9d3b72 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Thu, 20 Jun 2019 15:57:49 -0400 Subject: [PATCH 119/142] Update tools --- vendor-bin/csfixer/composer.lock | 238 ++++++++++---------- vendor-bin/phpunit/composer.json | 2 +- vendor-bin/phpunit/composer.lock | 375 +++++++++++++++++-------------- vendor-bin/robo/composer.lock | 169 +++++++------- 4 files changed, 404 insertions(+), 380 deletions(-) diff --git a/vendor-bin/csfixer/composer.lock b/vendor-bin/csfixer/composer.lock index e48cd3d7..2b7494e7 100644 --- a/vendor-bin/csfixer/composer.lock +++ b/vendor-bin/csfixer/composer.lock @@ -70,16 +70,16 @@ }, { "name": "composer/xdebug-handler", - "version": "1.3.2", + "version": "1.3.3", "source": { "type": "git", "url": "https://github.com/composer/xdebug-handler.git", - "reference": "d17708133b6c276d6e42ef887a877866b909d892" + "reference": "46867cbf8ca9fb8d60c506895449eb799db1184f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/xdebug-handler/zipball/d17708133b6c276d6e42ef887a877866b909d892", - "reference": "d17708133b6c276d6e42ef887a877866b909d892", + "url": "https://api.github.com/repos/composer/xdebug-handler/zipball/46867cbf8ca9fb8d60c506895449eb799db1184f", + "reference": "46867cbf8ca9fb8d60c506895449eb799db1184f", "shasum": "" }, "require": { @@ -110,34 +110,34 @@ "Xdebug", "performance" ], - "time": "2019-01-28T20:25:53+00:00" + "time": "2019-05-27T17:52:04+00:00" }, { "name": "doctrine/annotations", - "version": "v1.6.1", + "version": "v1.4.0", "source": { "type": "git", "url": "https://github.com/doctrine/annotations.git", - "reference": "53120e0eb10355388d6ccbe462f1fea34ddadb24" + "reference": "54cacc9b81758b14e3ce750f205a393d52339e97" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/annotations/zipball/53120e0eb10355388d6ccbe462f1fea34ddadb24", - "reference": "53120e0eb10355388d6ccbe462f1fea34ddadb24", + "url": "https://api.github.com/repos/doctrine/annotations/zipball/54cacc9b81758b14e3ce750f205a393d52339e97", + "reference": "54cacc9b81758b14e3ce750f205a393d52339e97", "shasum": "" }, "require": { "doctrine/lexer": "1.*", - "php": "^7.1" + "php": "^5.6 || ^7.0" }, "require-dev": { "doctrine/cache": "1.*", - "phpunit/phpunit": "^6.4" + "phpunit/phpunit": "^5.7" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.6.x-dev" + "dev-master": "1.4.x-dev" } }, "autoload": { @@ -178,25 +178,28 @@ "docblock", "parser" ], - "time": "2019-03-25T19:12:02+00:00" + "time": "2017-02-24T16:22:25+00:00" }, { "name": "doctrine/lexer", - "version": "v1.0.1", + "version": "1.0.2", "source": { "type": "git", "url": "https://github.com/doctrine/lexer.git", - "reference": "83893c552fd2045dd78aef794c31e694c37c0b8c" + "reference": "1febd6c3ef84253d7c815bed85fc622ad207a9f8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/lexer/zipball/83893c552fd2045dd78aef794c31e694c37c0b8c", - "reference": "83893c552fd2045dd78aef794c31e694c37c0b8c", + "url": "https://api.github.com/repos/doctrine/lexer/zipball/1febd6c3ef84253d7c815bed85fc622ad207a9f8", + "reference": "1febd6c3ef84253d7c815bed85fc622ad207a9f8", "shasum": "" }, "require": { "php": ">=5.3.2" }, + "require-dev": { + "phpunit/phpunit": "^4.5" + }, "type": "library", "extra": { "branch-alias": { @@ -204,8 +207,8 @@ } }, "autoload": { - "psr-0": { - "Doctrine\\Common\\Lexer\\": "lib/" + "psr-4": { + "Doctrine\\Common\\Lexer\\": "lib/Doctrine/Common/Lexer" } }, "notification-url": "https://packagist.org/downloads/", @@ -226,26 +229,29 @@ "email": "schmittjoh@gmail.com" } ], - "description": "Base library for a lexer that can be used in Top-Down, Recursive Descent Parsers.", - "homepage": "http://www.doctrine-project.org", + "description": "PHP Doctrine Lexer parser library that can be used in Top-Down, Recursive Descent Parsers.", + "homepage": "https://www.doctrine-project.org/projects/lexer.html", "keywords": [ + "annotations", + "docblock", "lexer", - "parser" + "parser", + "php" ], - "time": "2014-09-09T13:34:57+00:00" + "time": "2019-06-08T11:03:04+00:00" }, { "name": "friendsofphp/php-cs-fixer", - "version": "v2.14.2", + "version": "v2.15.1", "source": { "type": "git", "url": "https://github.com/FriendsOfPHP/PHP-CS-Fixer.git", - "reference": "ff401e58261ffc5934a58f795b3f95b355e276cb" + "reference": "20064511ab796593a3990669eff5f5b535001f7c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/FriendsOfPHP/PHP-CS-Fixer/zipball/ff401e58261ffc5934a58f795b3f95b355e276cb", - "reference": "ff401e58261ffc5934a58f795b3f95b355e276cb", + "url": "https://api.github.com/repos/FriendsOfPHP/PHP-CS-Fixer/zipball/20064511ab796593a3990669eff5f5b535001f7c", + "reference": "20064511ab796593a3990669eff5f5b535001f7c", "shasum": "" }, "require": { @@ -273,11 +279,11 @@ "mikey179/vfsstream": "^1.6", "php-coveralls/php-coveralls": "^2.1", "php-cs-fixer/accessible-object": "^1.0", - "php-cs-fixer/phpunit-constraint-isidenticalstring": "^1.0.1", - "php-cs-fixer/phpunit-constraint-xmlmatchesxsd": "^1.0.1", + "php-cs-fixer/phpunit-constraint-isidenticalstring": "^1.1", + "php-cs-fixer/phpunit-constraint-xmlmatchesxsd": "^1.1", "phpunit/phpunit": "^5.7.27 || ^6.5.8 || ^7.1", - "phpunitgoodpractices/traits": "^1.5.1", - "symfony/phpunit-bridge": "^4.0" + "phpunitgoodpractices/traits": "^1.8", + "symfony/phpunit-bridge": "^4.3" }, "suggest": { "ext-mbstring": "For handling non-UTF8 characters in cache signature.", @@ -320,7 +326,7 @@ } ], "description": "A tool to automatically fix PHP code style", - "time": "2019-02-17T17:44:13+00:00" + "time": "2019-06-01T10:32:12+00:00" }, { "name": "paragonie/random_compat", @@ -467,21 +473,21 @@ }, { "name": "symfony/console", - "version": "v4.2.4", + "version": "v3.4.28", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "9dc2299a016497f9ee620be94524e6c0af0280a9" + "reference": "8e1d1e406dd31727fa70cd5a99cda202e9d6a5c6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/9dc2299a016497f9ee620be94524e6c0af0280a9", - "reference": "9dc2299a016497f9ee620be94524e6c0af0280a9", + "url": "https://api.github.com/repos/symfony/console/zipball/8e1d1e406dd31727fa70cd5a99cda202e9d6a5c6", + "reference": "8e1d1e406dd31727fa70cd5a99cda202e9d6a5c6", "shasum": "" }, "require": { - "php": "^7.1.3", - "symfony/contracts": "^1.0", + "php": "^5.5.9|>=7.0.8", + "symfony/debug": "~2.8|~3.0|~4.0", "symfony/polyfill-mbstring": "~1.0" }, "conflict": { @@ -493,11 +499,11 @@ }, "require-dev": { "psr/log": "~1.0", - "symfony/config": "~3.4|~4.0", + "symfony/config": "~3.3|~4.0", "symfony/dependency-injection": "~3.4|~4.0", - "symfony/event-dispatcher": "~3.4|~4.0", + "symfony/event-dispatcher": "~2.8|~3.0|~4.0", "symfony/lock": "~3.4|~4.0", - "symfony/process": "~3.4|~4.0" + "symfony/process": "~3.3|~4.0" }, "suggest": { "psr/log": "For using the console logger", @@ -508,7 +514,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "4.2-dev" + "dev-master": "3.4-dev" } }, "autoload": { @@ -535,48 +541,44 @@ ], "description": "Symfony Console Component", "homepage": "https://symfony.com", - "time": "2019-02-23T15:17:42+00:00" + "time": "2019-05-09T08:42:51+00:00" }, { - "name": "symfony/contracts", - "version": "v1.0.2", + "name": "symfony/debug", + "version": "v3.4.28", "source": { "type": "git", - "url": "https://github.com/symfony/contracts.git", - "reference": "1aa7ab2429c3d594dd70689604b5cf7421254cdf" + "url": "https://github.com/symfony/debug.git", + "reference": "671fc55bd14800668b1d0a3708c3714940e30a8c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/contracts/zipball/1aa7ab2429c3d594dd70689604b5cf7421254cdf", - "reference": "1aa7ab2429c3d594dd70689604b5cf7421254cdf", + "url": "https://api.github.com/repos/symfony/debug/zipball/671fc55bd14800668b1d0a3708c3714940e30a8c", + "reference": "671fc55bd14800668b1d0a3708c3714940e30a8c", "shasum": "" }, "require": { - "php": "^7.1.3" + "php": "^5.5.9|>=7.0.8", + "psr/log": "~1.0" + }, + "conflict": { + "symfony/http-kernel": ">=2.3,<2.3.24|~2.4.0|>=2.5,<2.5.9|>=2.6,<2.6.2" }, "require-dev": { - "psr/cache": "^1.0", - "psr/container": "^1.0" - }, - "suggest": { - "psr/cache": "When using the Cache contracts", - "psr/container": "When using the Service contracts", - "symfony/cache-contracts-implementation": "", - "symfony/service-contracts-implementation": "", - "symfony/translation-contracts-implementation": "" + "symfony/http-kernel": "~2.8|~3.0|~4.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.0-dev" + "dev-master": "3.4-dev" } }, "autoload": { "psr-4": { - "Symfony\\Contracts\\": "" + "Symfony\\Component\\Debug\\": "" }, "exclude-from-classmap": [ - "**/Tests/" + "/Tests/" ] }, "notification-url": "https://packagist.org/downloads/", @@ -585,53 +587,44 @@ ], "authors": [ { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" + "name": "Fabien Potencier", + "email": "fabien@symfony.com" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "A set of abstractions extracted out of the Symfony components", + "description": "Symfony Debug Component", "homepage": "https://symfony.com", - "keywords": [ - "abstractions", - "contracts", - "decoupling", - "interfaces", - "interoperability", - "standards" - ], - "time": "2018-12-05T08:06:11+00:00" + "time": "2019-05-18T13:32:47+00:00" }, { "name": "symfony/event-dispatcher", - "version": "v4.2.4", + "version": "v3.4.28", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher.git", - "reference": "3354d2e6af986dd71f68b4e5cf4a933ab58697fb" + "reference": "a088aafcefb4eef2520a290ed82e4374092a6dff" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/3354d2e6af986dd71f68b4e5cf4a933ab58697fb", - "reference": "3354d2e6af986dd71f68b4e5cf4a933ab58697fb", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/a088aafcefb4eef2520a290ed82e4374092a6dff", + "reference": "a088aafcefb4eef2520a290ed82e4374092a6dff", "shasum": "" }, "require": { - "php": "^7.1.3", - "symfony/contracts": "^1.0" + "php": "^5.5.9|>=7.0.8" }, "conflict": { - "symfony/dependency-injection": "<3.4" + "symfony/dependency-injection": "<3.3" }, "require-dev": { "psr/log": "~1.0", - "symfony/config": "~3.4|~4.0", - "symfony/dependency-injection": "~3.4|~4.0", - "symfony/expression-language": "~3.4|~4.0", - "symfony/stopwatch": "~3.4|~4.0" + "symfony/config": "~2.8|~3.0|~4.0", + "symfony/dependency-injection": "~3.3|~4.0", + "symfony/expression-language": "~2.8|~3.0|~4.0", + "symfony/stopwatch": "~2.8|~3.0|~4.0" }, "suggest": { "symfony/dependency-injection": "", @@ -640,7 +633,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "4.2-dev" + "dev-master": "3.4-dev" } }, "autoload": { @@ -667,30 +660,30 @@ ], "description": "Symfony EventDispatcher Component", "homepage": "https://symfony.com", - "time": "2019-02-23T15:17:42+00:00" + "time": "2019-04-02T08:51:52+00:00" }, { "name": "symfony/filesystem", - "version": "v4.2.4", + "version": "v3.4.28", "source": { "type": "git", "url": "https://github.com/symfony/filesystem.git", - "reference": "e16b9e471703b2c60b95f14d31c1239f68f11601" + "reference": "acf99758b1df8e9295e6b85aa69f294565c9fedb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/e16b9e471703b2c60b95f14d31c1239f68f11601", - "reference": "e16b9e471703b2c60b95f14d31c1239f68f11601", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/acf99758b1df8e9295e6b85aa69f294565c9fedb", + "reference": "acf99758b1df8e9295e6b85aa69f294565c9fedb", "shasum": "" }, "require": { - "php": "^7.1.3", + "php": "^5.5.9|>=7.0.8", "symfony/polyfill-ctype": "~1.8" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "4.2-dev" + "dev-master": "3.4-dev" } }, "autoload": { @@ -717,29 +710,29 @@ ], "description": "Symfony Filesystem Component", "homepage": "https://symfony.com", - "time": "2019-02-07T11:40:08+00:00" + "time": "2019-02-04T21:34:32+00:00" }, { "name": "symfony/finder", - "version": "v4.2.4", + "version": "v3.4.28", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "267b7002c1b70ea80db0833c3afe05f0fbde580a" + "reference": "fa5d962a71f2169dfe1cbae217fa5a2799859f6c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/267b7002c1b70ea80db0833c3afe05f0fbde580a", - "reference": "267b7002c1b70ea80db0833c3afe05f0fbde580a", + "url": "https://api.github.com/repos/symfony/finder/zipball/fa5d962a71f2169dfe1cbae217fa5a2799859f6c", + "reference": "fa5d962a71f2169dfe1cbae217fa5a2799859f6c", "shasum": "" }, "require": { - "php": "^7.1.3" + "php": "^5.5.9|>=7.0.8" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "4.2-dev" + "dev-master": "3.4-dev" } }, "autoload": { @@ -766,29 +759,29 @@ ], "description": "Symfony Finder Component", "homepage": "https://symfony.com", - "time": "2019-02-23T15:42:05+00:00" + "time": "2019-05-24T12:25:55+00:00" }, { "name": "symfony/options-resolver", - "version": "v4.2.4", + "version": "v3.4.28", "source": { "type": "git", "url": "https://github.com/symfony/options-resolver.git", - "reference": "3896e5a7d06fd15fa4947694c8dcdd371ff147d1" + "reference": "ed3b397f9c07c8ca388b2a1ef744403b4d4ecc44" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/options-resolver/zipball/3896e5a7d06fd15fa4947694c8dcdd371ff147d1", - "reference": "3896e5a7d06fd15fa4947694c8dcdd371ff147d1", + "url": "https://api.github.com/repos/symfony/options-resolver/zipball/ed3b397f9c07c8ca388b2a1ef744403b4d4ecc44", + "reference": "ed3b397f9c07c8ca388b2a1ef744403b4d4ecc44", "shasum": "" }, "require": { - "php": "^7.1.3" + "php": "^5.5.9|>=7.0.8" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "4.2-dev" + "dev-master": "3.4-dev" } }, "autoload": { @@ -820,7 +813,7 @@ "configuration", "options" ], - "time": "2019-02-23T15:17:42+00:00" + "time": "2019-04-10T16:00:48+00:00" }, { "name": "symfony/polyfill-ctype", @@ -1055,25 +1048,25 @@ }, { "name": "symfony/process", - "version": "v4.2.4", + "version": "v3.4.28", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "6c05edb11fbeff9e2b324b4270ecb17911a8b7ad" + "reference": "afe411c2a6084f25cff55a01d0d4e1474c97ff13" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/6c05edb11fbeff9e2b324b4270ecb17911a8b7ad", - "reference": "6c05edb11fbeff9e2b324b4270ecb17911a8b7ad", + "url": "https://api.github.com/repos/symfony/process/zipball/afe411c2a6084f25cff55a01d0d4e1474c97ff13", + "reference": "afe411c2a6084f25cff55a01d0d4e1474c97ff13", "shasum": "" }, "require": { - "php": "^7.1.3" + "php": "^5.5.9|>=7.0.8" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "4.2-dev" + "dev-master": "3.4-dev" } }, "autoload": { @@ -1100,30 +1093,29 @@ ], "description": "Symfony Process Component", "homepage": "https://symfony.com", - "time": "2019-01-24T22:05:03+00:00" + "time": "2019-05-22T12:54:11+00:00" }, { "name": "symfony/stopwatch", - "version": "v4.2.4", + "version": "v3.4.28", "source": { "type": "git", "url": "https://github.com/symfony/stopwatch.git", - "reference": "b1a5f646d56a3290230dbc8edf2a0d62cda23f67" + "reference": "2a651c2645c10bbedd21170771f122d935e0dd58" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/stopwatch/zipball/b1a5f646d56a3290230dbc8edf2a0d62cda23f67", - "reference": "b1a5f646d56a3290230dbc8edf2a0d62cda23f67", + "url": "https://api.github.com/repos/symfony/stopwatch/zipball/2a651c2645c10bbedd21170771f122d935e0dd58", + "reference": "2a651c2645c10bbedd21170771f122d935e0dd58", "shasum": "" }, "require": { - "php": "^7.1.3", - "symfony/contracts": "^1.0" + "php": "^5.5.9|>=7.0.8" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "4.2-dev" + "dev-master": "3.4-dev" } }, "autoload": { @@ -1150,7 +1142,7 @@ ], "description": "Symfony Stopwatch Component", "homepage": "https://symfony.com", - "time": "2019-01-16T20:31:39+00:00" + "time": "2019-01-16T09:39:14+00:00" } ], "packages-dev": [], diff --git a/vendor-bin/phpunit/composer.json b/vendor-bin/phpunit/composer.json index d6c1f867..3afc6e79 100644 --- a/vendor-bin/phpunit/composer.json +++ b/vendor-bin/phpunit/composer.json @@ -1,6 +1,6 @@ { "require": { - "phpunit/phpunit": "7.*", + "phpunit/phpunit": "6.* | 7.*", "phake/phake": "^3.0", "clue/arguments": "^2.0", "mikey179/vfsStream": "^1.6", diff --git a/vendor-bin/phpunit/composer.lock b/vendor-bin/phpunit/composer.lock index 32cf6449..3f6a3a4e 100644 --- a/vendor-bin/phpunit/composer.lock +++ b/vendor-bin/phpunit/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "e69de7425d904e9dadfed81536ecd712", + "content-hash": "57ab1526d0611e8766f67b8e0fa16912", "packages": [ { "name": "clue/arguments", @@ -58,34 +58,32 @@ }, { "name": "doctrine/instantiator", - "version": "1.2.0", + "version": "1.0.5", "source": { "type": "git", "url": "https://github.com/doctrine/instantiator.git", - "reference": "a2c590166b2133a4633738648b6b064edae0814a" + "reference": "8e884e78f9f0eb1329e445619e04456e64d8051d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/instantiator/zipball/a2c590166b2133a4633738648b6b064edae0814a", - "reference": "a2c590166b2133a4633738648b6b064edae0814a", + "url": "https://api.github.com/repos/doctrine/instantiator/zipball/8e884e78f9f0eb1329e445619e04456e64d8051d", + "reference": "8e884e78f9f0eb1329e445619e04456e64d8051d", "shasum": "" }, "require": { - "php": "^7.1" + "php": ">=5.3,<8.0-DEV" }, "require-dev": { - "doctrine/coding-standard": "^6.0", + "athletic/athletic": "~0.1.8", "ext-pdo": "*", "ext-phar": "*", - "phpbench/phpbench": "^0.13", - "phpstan/phpstan-phpunit": "^0.11", - "phpstan/phpstan-shim": "^0.11", - "phpunit/phpunit": "^7.0" + "phpunit/phpunit": "~4.0", + "squizlabs/php_codesniffer": "~2.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.2.x-dev" + "dev-master": "1.0.x-dev" } }, "autoload": { @@ -105,25 +103,25 @@ } ], "description": "A small, lightweight utility to instantiate objects in PHP without invoking their constructors", - "homepage": "https://www.doctrine-project.org/projects/instantiator.html", + "homepage": "https://github.com/doctrine/instantiator", "keywords": [ "constructor", "instantiate" ], - "time": "2019-03-17T17:37:11+00:00" + "time": "2015-06-14T21:17:01+00:00" }, { - "name": "mikey179/vfsStream", - "version": "v1.6.5", + "name": "mikey179/vfsstream", + "version": "v1.6.6", "source": { "type": "git", - "url": "https://github.com/mikey179/vfsStream.git", - "reference": "d5fec95f541d4d71c4823bb5e30cf9b9e5b96145" + "url": "https://github.com/bovigo/vfsStream.git", + "reference": "095238a0711c974ae5b4ebf4c4534a23f3f6c99d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/mikey179/vfsStream/zipball/d5fec95f541d4d71c4823bb5e30cf9b9e5b96145", - "reference": "d5fec95f541d4d71c4823bb5e30cf9b9e5b96145", + "url": "https://api.github.com/repos/bovigo/vfsStream/zipball/095238a0711c974ae5b4ebf4c4534a23f3f6c99d", + "reference": "095238a0711c974ae5b4ebf4c4534a23f3f6c99d", "shasum": "" }, "require": { @@ -156,32 +154,29 @@ ], "description": "Virtual file system to mock the real file system in unit tests.", "homepage": "http://vfs.bovigo.org/", - "time": "2017-08-01T08:02:14+00:00" + "time": "2019-04-08T13:54:32+00:00" }, { "name": "myclabs/deep-copy", - "version": "1.8.1", + "version": "1.7.0", "source": { "type": "git", "url": "https://github.com/myclabs/DeepCopy.git", - "reference": "3e01bdad3e18354c3dce54466b7fbe33a9f9f7f8" + "reference": "3b8a3a99ba1f6a3952ac2747d989303cbd6b7a3e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/3e01bdad3e18354c3dce54466b7fbe33a9f9f7f8", - "reference": "3e01bdad3e18354c3dce54466b7fbe33a9f9f7f8", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/3b8a3a99ba1f6a3952ac2747d989303cbd6b7a3e", + "reference": "3b8a3a99ba1f6a3952ac2747d989303cbd6b7a3e", "shasum": "" }, "require": { - "php": "^7.1" - }, - "replace": { - "myclabs/deep-copy": "self.version" + "php": "^5.6 || ^7.0" }, "require-dev": { "doctrine/collections": "^1.0", "doctrine/common": "^2.6", - "phpunit/phpunit": "^7.1" + "phpunit/phpunit": "^4.1" }, "type": "library", "autoload": { @@ -204,20 +199,20 @@ "object", "object graph" ], - "time": "2018-06-11T23:09:50+00:00" + "time": "2017-10-19T19:58:43+00:00" }, { "name": "phake/phake", - "version": "v3.1.3", + "version": "v3.1.6", "source": { "type": "git", "url": "https://github.com/mlively/Phake.git", - "reference": "5208167c10f3c0b8e87066d6d5b41e6b754bd4d4" + "reference": "3848901ed8e236534ae684dd5cf0f3bfc4c8a24c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/mlively/Phake/zipball/5208167c10f3c0b8e87066d6d5b41e6b754bd4d4", - "reference": "5208167c10f3c0b8e87066d6d5b41e6b754bd4d4", + "url": "https://api.github.com/repos/mlively/Phake/zipball/3848901ed8e236534ae684dd5cf0f3bfc4c8a24c", + "reference": "3848901ed8e236534ae684dd5cf0f3bfc4c8a24c", "shasum": "" }, "require": { @@ -262,26 +257,26 @@ "mock", "testing" ], - "time": "2018-08-04T00:42:49+00:00" + "time": "2019-06-06T22:41:35+00:00" }, { "name": "phar-io/manifest", - "version": "1.0.3", + "version": "1.0.1", "source": { "type": "git", "url": "https://github.com/phar-io/manifest.git", - "reference": "7761fcacf03b4d4f16e7ccb606d4879ca431fcf4" + "reference": "2df402786ab5368a0169091f61a7c1e0eb6852d0" }, "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/2df402786ab5368a0169091f61a7c1e0eb6852d0", + "reference": "2df402786ab5368a0169091f61a7c1e0eb6852d0", "shasum": "" }, "require": { "ext-dom": "*", "ext-phar": "*", - "phar-io/version": "^2.0", + "phar-io/version": "^1.0.1", "php": "^5.6 || ^7.0" }, "type": "library", @@ -317,20 +312,20 @@ } ], "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)", - "time": "2018-07-08T19:23:20+00:00" + "time": "2017-03-05T18:14:27+00:00" }, { "name": "phar-io/version", - "version": "2.0.1", + "version": "1.0.1", "source": { "type": "git", "url": "https://github.com/phar-io/version.git", - "reference": "45a2ec53a73c70ce41d55cedef9063630abaf1b6" + "reference": "a70c0ced4be299a63d32fa96d9281d03e94041df" }, "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/a70c0ced4be299a63d32fa96d9281d03e94041df", + "reference": "a70c0ced4be299a63d32fa96d9281d03e94041df", "shasum": "" }, "require": { @@ -364,7 +359,7 @@ } ], "description": "Library for handling version information and constraints", - "time": "2018-07-08T19:19:57+00:00" + "time": "2017-03-05T17:38:23+00:00" }, { "name": "phpdocumentor/reflection-common", @@ -422,16 +417,16 @@ }, { "name": "phpdocumentor/reflection-docblock", - "version": "4.3.0", + "version": "4.3.1", "source": { "type": "git", "url": "https://github.com/phpDocumentor/ReflectionDocBlock.git", - "reference": "94fd0001232e47129dd3504189fa1c7225010d08" + "reference": "bdd9f737ebc2a01c06ea7ff4308ec6697db9b53c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/94fd0001232e47129dd3504189fa1c7225010d08", - "reference": "94fd0001232e47129dd3504189fa1c7225010d08", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/bdd9f737ebc2a01c06ea7ff4308ec6697db9b53c", + "reference": "bdd9f737ebc2a01c06ea7ff4308ec6697db9b53c", "shasum": "" }, "require": { @@ -469,7 +464,7 @@ } ], "description": "With this component, a library can provide support for annotations via DocBlocks or otherwise retrieve information that is embedded in a DocBlock.", - "time": "2017-11-30T07:14:17+00:00" + "time": "2019-04-30T17:48:53+00:00" }, { "name": "phpdocumentor/type-resolver", @@ -520,16 +515,16 @@ }, { "name": "phpspec/prophecy", - "version": "1.8.0", + "version": "1.8.1", "source": { "type": "git", "url": "https://github.com/phpspec/prophecy.git", - "reference": "4ba436b55987b4bf311cb7c6ba82aa528aac0a06" + "reference": "1927e75f4ed19131ec9bcc3b002e07fb1173ee76" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpspec/prophecy/zipball/4ba436b55987b4bf311cb7c6ba82aa528aac0a06", - "reference": "4ba436b55987b4bf311cb7c6ba82aa528aac0a06", + "url": "https://api.github.com/repos/phpspec/prophecy/zipball/1927e75f4ed19131ec9bcc3b002e07fb1173ee76", + "reference": "1927e75f4ed19131ec9bcc3b002e07fb1173ee76", "shasum": "" }, "require": { @@ -550,8 +545,8 @@ } }, "autoload": { - "psr-0": { - "Prophecy\\": "src/" + "psr-4": { + "Prophecy\\": "src/Prophecy" } }, "notification-url": "https://packagist.org/downloads/", @@ -579,44 +574,44 @@ "spy", "stub" ], - "time": "2018-08-05T17:53:17+00:00" + "time": "2019-06-13T12:50:23+00:00" }, { "name": "phpunit/php-code-coverage", - "version": "6.1.4", + "version": "5.3.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-code-coverage.git", - "reference": "807e6013b00af69b6c5d9ceb4282d0393dbb9d8d" + "reference": "c89677919c5dd6d3b3852f230a663118762218ac" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/807e6013b00af69b6c5d9ceb4282d0393dbb9d8d", - "reference": "807e6013b00af69b6c5d9ceb4282d0393dbb9d8d", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/c89677919c5dd6d3b3852f230a663118762218ac", + "reference": "c89677919c5dd6d3b3852f230a663118762218ac", "shasum": "" }, "require": { "ext-dom": "*", "ext-xmlwriter": "*", - "php": "^7.1", - "phpunit/php-file-iterator": "^2.0", + "php": "^7.0", + "phpunit/php-file-iterator": "^1.4.2", "phpunit/php-text-template": "^1.2.1", - "phpunit/php-token-stream": "^3.0", + "phpunit/php-token-stream": "^2.0.1", "sebastian/code-unit-reverse-lookup": "^1.0.1", - "sebastian/environment": "^3.1 || ^4.0", + "sebastian/environment": "^3.0", "sebastian/version": "^2.0.1", "theseer/tokenizer": "^1.1" }, "require-dev": { - "phpunit/phpunit": "^7.0" + "phpunit/phpunit": "^6.0" }, "suggest": { - "ext-xdebug": "^2.6.0" + "ext-xdebug": "^2.5.5" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "6.1-dev" + "dev-master": "5.3.x-dev" } }, "autoload": { @@ -642,32 +637,29 @@ "testing", "xunit" ], - "time": "2018-10-31T16:06:48+00:00" + "time": "2018-04-06T15:36:58+00:00" }, { "name": "phpunit/php-file-iterator", - "version": "2.0.2", + "version": "1.4.5", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-file-iterator.git", - "reference": "050bedf145a257b1ff02746c31894800e5122946" + "reference": "730b01bc3e867237eaac355e06a36b85dd93a8b4" }, "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/730b01bc3e867237eaac355e06a36b85dd93a8b4", + "reference": "730b01bc3e867237eaac355e06a36b85dd93a8b4", "shasum": "" }, "require": { - "php": "^7.1" - }, - "require-dev": { - "phpunit/phpunit": "^7.1" + "php": ">=5.3.3" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "2.0.x-dev" + "dev-master": "1.4.x-dev" } }, "autoload": { @@ -682,7 +674,7 @@ "authors": [ { "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", + "email": "sb@sebastian-bergmann.de", "role": "lead" } ], @@ -692,7 +684,7 @@ "filesystem", "iterator" ], - "time": "2018-09-13T20:33:42+00:00" + "time": "2017-11-27T13:52:08+00:00" }, { "name": "phpunit/php-text-template", @@ -737,28 +729,28 @@ }, { "name": "phpunit/php-timer", - "version": "2.1.1", + "version": "1.0.9", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-timer.git", - "reference": "8b389aebe1b8b0578430bda0c7c95a829608e059" + "reference": "3dcf38ca72b158baf0bc245e9184d3fdffa9c46f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/8b389aebe1b8b0578430bda0c7c95a829608e059", - "reference": "8b389aebe1b8b0578430bda0c7c95a829608e059", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/3dcf38ca72b158baf0bc245e9184d3fdffa9c46f", + "reference": "3dcf38ca72b158baf0bc245e9184d3fdffa9c46f", "shasum": "" }, "require": { - "php": "^7.1" + "php": "^5.3.3 || ^7.0" }, "require-dev": { - "phpunit/phpunit": "^7.0" + "phpunit/phpunit": "^4.8.35 || ^5.7 || ^6.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "2.1-dev" + "dev-master": "1.0-dev" } }, "autoload": { @@ -773,7 +765,7 @@ "authors": [ { "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", + "email": "sb@sebastian-bergmann.de", "role": "lead" } ], @@ -782,33 +774,33 @@ "keywords": [ "timer" ], - "time": "2019-02-20T10:12:59+00:00" + "time": "2017-02-26T11:10:40+00:00" }, { "name": "phpunit/php-token-stream", - "version": "3.0.1", + "version": "2.0.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-token-stream.git", - "reference": "c99e3be9d3e85f60646f152f9002d46ed7770d18" + "reference": "791198a2c6254db10131eecfe8c06670700904db" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-token-stream/zipball/c99e3be9d3e85f60646f152f9002d46ed7770d18", - "reference": "c99e3be9d3e85f60646f152f9002d46ed7770d18", + "url": "https://api.github.com/repos/sebastianbergmann/php-token-stream/zipball/791198a2c6254db10131eecfe8c06670700904db", + "reference": "791198a2c6254db10131eecfe8c06670700904db", "shasum": "" }, "require": { "ext-tokenizer": "*", - "php": "^7.1" + "php": "^7.0" }, "require-dev": { - "phpunit/phpunit": "^7.0" + "phpunit/phpunit": "^6.2.4" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "3.0-dev" + "dev-master": "2.0-dev" } }, "autoload": { @@ -831,57 +823,57 @@ "keywords": [ "tokenizer" ], - "time": "2018-10-30T05:52:18+00:00" + "time": "2017-11-27T05:48:46+00:00" }, { "name": "phpunit/phpunit", - "version": "7.5.8", + "version": "6.5.14", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "c29c0525cf4572c11efe1db49a8b8aee9dfac58a" + "reference": "bac23fe7ff13dbdb461481f706f0e9fe746334b7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/c29c0525cf4572c11efe1db49a8b8aee9dfac58a", - "reference": "c29c0525cf4572c11efe1db49a8b8aee9dfac58a", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/bac23fe7ff13dbdb461481f706f0e9fe746334b7", + "reference": "bac23fe7ff13dbdb461481f706f0e9fe746334b7", "shasum": "" }, "require": { - "doctrine/instantiator": "^1.1", "ext-dom": "*", "ext-json": "*", "ext-libxml": "*", "ext-mbstring": "*", "ext-xml": "*", - "myclabs/deep-copy": "^1.7", - "phar-io/manifest": "^1.0.2", - "phar-io/version": "^2.0", - "php": "^7.1", + "myclabs/deep-copy": "^1.6.1", + "phar-io/manifest": "^1.0.1", + "phar-io/version": "^1.0", + "php": "^7.0", "phpspec/prophecy": "^1.7", - "phpunit/php-code-coverage": "^6.0.7", - "phpunit/php-file-iterator": "^2.0.1", + "phpunit/php-code-coverage": "^5.3", + "phpunit/php-file-iterator": "^1.4.3", "phpunit/php-text-template": "^1.2.1", - "phpunit/php-timer": "^2.1", - "sebastian/comparator": "^3.0", - "sebastian/diff": "^3.0", - "sebastian/environment": "^4.0", + "phpunit/php-timer": "^1.0.9", + "phpunit/phpunit-mock-objects": "^5.0.9", + "sebastian/comparator": "^2.1", + "sebastian/diff": "^2.0", + "sebastian/environment": "^3.1", "sebastian/exporter": "^3.1", "sebastian/global-state": "^2.0", "sebastian/object-enumerator": "^3.0.3", - "sebastian/resource-operations": "^2.0", + "sebastian/resource-operations": "^1.0", "sebastian/version": "^2.0.1" }, "conflict": { - "phpunit/phpunit-mock-objects": "*" + "phpdocumentor/reflection-docblock": "3.0.2", + "phpunit/dbunit": "<3.0" }, "require-dev": { "ext-pdo": "*" }, "suggest": { - "ext-soap": "*", "ext-xdebug": "*", - "phpunit/php-invoker": "^2.0" + "phpunit/php-invoker": "^1.1" }, "bin": [ "phpunit" @@ -889,7 +881,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "7.5-dev" + "dev-master": "6.5.x-dev" } }, "autoload": { @@ -915,7 +907,67 @@ "testing", "xunit" ], - "time": "2019-03-26T13:23:54+00:00" + "time": "2019-02-01T05:22:47+00:00" + }, + { + "name": "phpunit/phpunit-mock-objects", + "version": "5.0.10", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/phpunit-mock-objects.git", + "reference": "cd1cf05c553ecfec36b170070573e540b67d3f1f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit-mock-objects/zipball/cd1cf05c553ecfec36b170070573e540b67d3f1f", + "reference": "cd1cf05c553ecfec36b170070573e540b67d3f1f", + "shasum": "" + }, + "require": { + "doctrine/instantiator": "^1.0.5", + "php": "^7.0", + "phpunit/php-text-template": "^1.2.1", + "sebastian/exporter": "^3.1" + }, + "conflict": { + "phpunit/phpunit": "<6.0" + }, + "require-dev": { + "phpunit/phpunit": "^6.5.11" + }, + "suggest": { + "ext-soap": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Mock Object library for PHPUnit", + "homepage": "https://github.com/sebastianbergmann/phpunit-mock-objects/", + "keywords": [ + "mock", + "xunit" + ], + "abandoned": true, + "time": "2018-08-09T05:50:03+00:00" }, { "name": "sebastian/code-unit-reverse-lookup", @@ -964,30 +1016,30 @@ }, { "name": "sebastian/comparator", - "version": "3.0.2", + "version": "2.1.3", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/comparator.git", - "reference": "5de4fc177adf9bce8df98d8d141a7559d7ccf6da" + "reference": "34369daee48eafb2651bea869b4b15d75ccc35f9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/5de4fc177adf9bce8df98d8d141a7559d7ccf6da", - "reference": "5de4fc177adf9bce8df98d8d141a7559d7ccf6da", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/34369daee48eafb2651bea869b4b15d75ccc35f9", + "reference": "34369daee48eafb2651bea869b4b15d75ccc35f9", "shasum": "" }, "require": { - "php": "^7.1", - "sebastian/diff": "^3.0", + "php": "^7.0", + "sebastian/diff": "^2.0 || ^3.0", "sebastian/exporter": "^3.1" }, "require-dev": { - "phpunit/phpunit": "^7.1" + "phpunit/phpunit": "^6.4" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "3.0-dev" + "dev-master": "2.1.x-dev" } }, "autoload": { @@ -1024,33 +1076,32 @@ "compare", "equality" ], - "time": "2018-07-12T15:12:46+00:00" + "time": "2018-02-01T13:46:46+00:00" }, { "name": "sebastian/diff", - "version": "3.0.2", + "version": "2.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/diff.git", - "reference": "720fcc7e9b5cf384ea68d9d930d480907a0c1a29" + "reference": "347c1d8b49c5c3ee30c7040ea6fc446790e6bddd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/720fcc7e9b5cf384ea68d9d930d480907a0c1a29", - "reference": "720fcc7e9b5cf384ea68d9d930d480907a0c1a29", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/347c1d8b49c5c3ee30c7040ea6fc446790e6bddd", + "reference": "347c1d8b49c5c3ee30c7040ea6fc446790e6bddd", "shasum": "" }, "require": { - "php": "^7.1" + "php": "^7.0" }, "require-dev": { - "phpunit/phpunit": "^7.5 || ^8.0", - "symfony/process": "^2 || ^3.3 || ^4" + "phpunit/phpunit": "^6.2" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "3.0-dev" + "dev-master": "2.0-dev" } }, "autoload": { @@ -1075,40 +1126,34 @@ "description": "Diff implementation", "homepage": "https://github.com/sebastianbergmann/diff", "keywords": [ - "diff", - "udiff", - "unidiff", - "unified diff" + "diff" ], - "time": "2019-02-04T06:01:07+00:00" + "time": "2017-08-03T08:09:46+00:00" }, { "name": "sebastian/environment", - "version": "4.1.0", + "version": "3.1.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/environment.git", - "reference": "6fda8ce1974b62b14935adc02a9ed38252eca656" + "reference": "cd0871b3975fb7fc44d11314fd1ee20925fce4f5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/6fda8ce1974b62b14935adc02a9ed38252eca656", - "reference": "6fda8ce1974b62b14935adc02a9ed38252eca656", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/cd0871b3975fb7fc44d11314fd1ee20925fce4f5", + "reference": "cd0871b3975fb7fc44d11314fd1ee20925fce4f5", "shasum": "" }, "require": { - "php": "^7.1" + "php": "^7.0" }, "require-dev": { - "phpunit/phpunit": "^7.5" - }, - "suggest": { - "ext-posix": "*" + "phpunit/phpunit": "^6.1" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "4.1-dev" + "dev-master": "3.1.x-dev" } }, "autoload": { @@ -1133,7 +1178,7 @@ "environment", "hhvm" ], - "time": "2019-02-01T05:27:49+00:00" + "time": "2017-07-01T08:51:00+00:00" }, { "name": "sebastian/exporter", @@ -1400,25 +1445,25 @@ }, { "name": "sebastian/resource-operations", - "version": "2.0.1", + "version": "1.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/resource-operations.git", - "reference": "4d7a795d35b889bf80a0cc04e08d77cedfa917a9" + "reference": "ce990bb21759f94aeafd30209e8cfcdfa8bc3f52" }, "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/ce990bb21759f94aeafd30209e8cfcdfa8bc3f52", + "reference": "ce990bb21759f94aeafd30209e8cfcdfa8bc3f52", "shasum": "" }, "require": { - "php": "^7.1" + "php": ">=5.6.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "2.0-dev" + "dev-master": "1.0.x-dev" } }, "autoload": { @@ -1438,7 +1483,7 @@ ], "description": "Provides a list of PHP built-in functions that operate on resources", "homepage": "https://www.github.com/sebastianbergmann/resource-operations", - "time": "2018-10-04T04:07:39+00:00" + "time": "2015-07-28T20:34:47+00:00" }, { "name": "sebastian/version", @@ -1543,16 +1588,16 @@ }, { "name": "theseer/tokenizer", - "version": "1.1.0", + "version": "1.1.3", "source": { "type": "git", "url": "https://github.com/theseer/tokenizer.git", - "reference": "cb2f008f3f05af2893a87208fe6a6c4985483f8b" + "reference": "11336f6f84e16a720dae9d8e6ed5019efa85a0f9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/theseer/tokenizer/zipball/cb2f008f3f05af2893a87208fe6a6c4985483f8b", - "reference": "cb2f008f3f05af2893a87208fe6a6c4985483f8b", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/11336f6f84e16a720dae9d8e6ed5019efa85a0f9", + "reference": "11336f6f84e16a720dae9d8e6ed5019efa85a0f9", "shasum": "" }, "require": { @@ -1579,7 +1624,7 @@ } ], "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", - "time": "2017-04-07T12:08:54+00:00" + "time": "2019-06-13T22:48:21+00:00" }, { "name": "webmozart/assert", diff --git a/vendor-bin/robo/composer.lock b/vendor-bin/robo/composer.lock index 8458df82..2c7301c5 100644 --- a/vendor-bin/robo/composer.lock +++ b/vendor-bin/robo/composer.lock @@ -275,16 +275,16 @@ }, { "name": "consolidation/output-formatters", - "version": "3.4.1", + "version": "3.5.0", "source": { "type": "git", "url": "https://github.com/consolidation/output-formatters.git", - "reference": "0881112642ad9059071f13f397f571035b527cb9" + "reference": "99ec998ffb697e0eada5aacf81feebfb13023605" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/consolidation/output-formatters/zipball/0881112642ad9059071f13f397f571035b527cb9", - "reference": "0881112642ad9059071f13f397f571035b527cb9", + "url": "https://api.github.com/repos/consolidation/output-formatters/zipball/99ec998ffb697e0eada5aacf81feebfb13023605", + "reference": "99ec998ffb697e0eada5aacf81feebfb13023605", "shasum": "" }, "require": { @@ -372,7 +372,7 @@ } ], "description": "Format text by applying transformations provided by plug-in formatters.", - "time": "2019-03-14T03:45:44+00:00" + "time": "2019-05-30T23:16:01+00:00" }, { "name": "consolidation/robo", @@ -784,16 +784,16 @@ }, { "name": "pear/archive_tar", - "version": "1.4.6", + "version": "1.4.7", "source": { "type": "git", "url": "https://github.com/pear/Archive_Tar.git", - "reference": "b8e33f9063a7cd1d20f079014f8382b3a7aee47e" + "reference": "7e48add6f8edc3027dd98ad15964b1a28fd0c845" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/pear/Archive_Tar/zipball/b8e33f9063a7cd1d20f079014f8382b3a7aee47e", - "reference": "b8e33f9063a7cd1d20f079014f8382b3a7aee47e", + "url": "https://api.github.com/repos/pear/Archive_Tar/zipball/7e48add6f8edc3027dd98ad15964b1a28fd0c845", + "reference": "7e48add6f8edc3027dd98ad15964b1a28fd0c845", "shasum": "" }, "require": { @@ -846,7 +846,7 @@ "archive", "tar" ], - "time": "2019-02-01T11:10:38+00:00" + "time": "2019-04-08T13:15:55+00:00" }, { "name": "pear/console_getopt", @@ -1092,21 +1092,21 @@ }, { "name": "symfony/console", - "version": "v4.2.4", + "version": "v3.4.28", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "9dc2299a016497f9ee620be94524e6c0af0280a9" + "reference": "8e1d1e406dd31727fa70cd5a99cda202e9d6a5c6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/9dc2299a016497f9ee620be94524e6c0af0280a9", - "reference": "9dc2299a016497f9ee620be94524e6c0af0280a9", + "url": "https://api.github.com/repos/symfony/console/zipball/8e1d1e406dd31727fa70cd5a99cda202e9d6a5c6", + "reference": "8e1d1e406dd31727fa70cd5a99cda202e9d6a5c6", "shasum": "" }, "require": { - "php": "^7.1.3", - "symfony/contracts": "^1.0", + "php": "^5.5.9|>=7.0.8", + "symfony/debug": "~2.8|~3.0|~4.0", "symfony/polyfill-mbstring": "~1.0" }, "conflict": { @@ -1118,11 +1118,11 @@ }, "require-dev": { "psr/log": "~1.0", - "symfony/config": "~3.4|~4.0", + "symfony/config": "~3.3|~4.0", "symfony/dependency-injection": "~3.4|~4.0", - "symfony/event-dispatcher": "~3.4|~4.0", + "symfony/event-dispatcher": "~2.8|~3.0|~4.0", "symfony/lock": "~3.4|~4.0", - "symfony/process": "~3.4|~4.0" + "symfony/process": "~3.3|~4.0" }, "suggest": { "psr/log": "For using the console logger", @@ -1133,7 +1133,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "4.2-dev" + "dev-master": "3.4-dev" } }, "autoload": { @@ -1160,48 +1160,44 @@ ], "description": "Symfony Console Component", "homepage": "https://symfony.com", - "time": "2019-02-23T15:17:42+00:00" + "time": "2019-05-09T08:42:51+00:00" }, { - "name": "symfony/contracts", - "version": "v1.0.2", + "name": "symfony/debug", + "version": "v3.4.28", "source": { "type": "git", - "url": "https://github.com/symfony/contracts.git", - "reference": "1aa7ab2429c3d594dd70689604b5cf7421254cdf" + "url": "https://github.com/symfony/debug.git", + "reference": "671fc55bd14800668b1d0a3708c3714940e30a8c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/contracts/zipball/1aa7ab2429c3d594dd70689604b5cf7421254cdf", - "reference": "1aa7ab2429c3d594dd70689604b5cf7421254cdf", + "url": "https://api.github.com/repos/symfony/debug/zipball/671fc55bd14800668b1d0a3708c3714940e30a8c", + "reference": "671fc55bd14800668b1d0a3708c3714940e30a8c", "shasum": "" }, "require": { - "php": "^7.1.3" + "php": "^5.5.9|>=7.0.8", + "psr/log": "~1.0" + }, + "conflict": { + "symfony/http-kernel": ">=2.3,<2.3.24|~2.4.0|>=2.5,<2.5.9|>=2.6,<2.6.2" }, "require-dev": { - "psr/cache": "^1.0", - "psr/container": "^1.0" - }, - "suggest": { - "psr/cache": "When using the Cache contracts", - "psr/container": "When using the Service contracts", - "symfony/cache-contracts-implementation": "", - "symfony/service-contracts-implementation": "", - "symfony/translation-contracts-implementation": "" + "symfony/http-kernel": "~2.8|~3.0|~4.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.0-dev" + "dev-master": "3.4-dev" } }, "autoload": { "psr-4": { - "Symfony\\Contracts\\": "" + "Symfony\\Component\\Debug\\": "" }, "exclude-from-classmap": [ - "**/Tests/" + "/Tests/" ] }, "notification-url": "https://packagist.org/downloads/", @@ -1210,53 +1206,44 @@ ], "authors": [ { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" + "name": "Fabien Potencier", + "email": "fabien@symfony.com" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "A set of abstractions extracted out of the Symfony components", + "description": "Symfony Debug Component", "homepage": "https://symfony.com", - "keywords": [ - "abstractions", - "contracts", - "decoupling", - "interfaces", - "interoperability", - "standards" - ], - "time": "2018-12-05T08:06:11+00:00" + "time": "2019-05-18T13:32:47+00:00" }, { "name": "symfony/event-dispatcher", - "version": "v4.2.4", + "version": "v3.4.28", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher.git", - "reference": "3354d2e6af986dd71f68b4e5cf4a933ab58697fb" + "reference": "a088aafcefb4eef2520a290ed82e4374092a6dff" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/3354d2e6af986dd71f68b4e5cf4a933ab58697fb", - "reference": "3354d2e6af986dd71f68b4e5cf4a933ab58697fb", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/a088aafcefb4eef2520a290ed82e4374092a6dff", + "reference": "a088aafcefb4eef2520a290ed82e4374092a6dff", "shasum": "" }, "require": { - "php": "^7.1.3", - "symfony/contracts": "^1.0" + "php": "^5.5.9|>=7.0.8" }, "conflict": { - "symfony/dependency-injection": "<3.4" + "symfony/dependency-injection": "<3.3" }, "require-dev": { "psr/log": "~1.0", - "symfony/config": "~3.4|~4.0", - "symfony/dependency-injection": "~3.4|~4.0", - "symfony/expression-language": "~3.4|~4.0", - "symfony/stopwatch": "~3.4|~4.0" + "symfony/config": "~2.8|~3.0|~4.0", + "symfony/dependency-injection": "~3.3|~4.0", + "symfony/expression-language": "~2.8|~3.0|~4.0", + "symfony/stopwatch": "~2.8|~3.0|~4.0" }, "suggest": { "symfony/dependency-injection": "", @@ -1265,7 +1252,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "4.2-dev" + "dev-master": "3.4-dev" } }, "autoload": { @@ -1292,30 +1279,30 @@ ], "description": "Symfony EventDispatcher Component", "homepage": "https://symfony.com", - "time": "2019-02-23T15:17:42+00:00" + "time": "2019-04-02T08:51:52+00:00" }, { "name": "symfony/filesystem", - "version": "v4.2.4", + "version": "v3.4.28", "source": { "type": "git", "url": "https://github.com/symfony/filesystem.git", - "reference": "e16b9e471703b2c60b95f14d31c1239f68f11601" + "reference": "acf99758b1df8e9295e6b85aa69f294565c9fedb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/e16b9e471703b2c60b95f14d31c1239f68f11601", - "reference": "e16b9e471703b2c60b95f14d31c1239f68f11601", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/acf99758b1df8e9295e6b85aa69f294565c9fedb", + "reference": "acf99758b1df8e9295e6b85aa69f294565c9fedb", "shasum": "" }, "require": { - "php": "^7.1.3", + "php": "^5.5.9|>=7.0.8", "symfony/polyfill-ctype": "~1.8" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "4.2-dev" + "dev-master": "3.4-dev" } }, "autoload": { @@ -1342,29 +1329,29 @@ ], "description": "Symfony Filesystem Component", "homepage": "https://symfony.com", - "time": "2019-02-07T11:40:08+00:00" + "time": "2019-02-04T21:34:32+00:00" }, { "name": "symfony/finder", - "version": "v4.2.4", + "version": "v3.4.28", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "267b7002c1b70ea80db0833c3afe05f0fbde580a" + "reference": "fa5d962a71f2169dfe1cbae217fa5a2799859f6c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/267b7002c1b70ea80db0833c3afe05f0fbde580a", - "reference": "267b7002c1b70ea80db0833c3afe05f0fbde580a", + "url": "https://api.github.com/repos/symfony/finder/zipball/fa5d962a71f2169dfe1cbae217fa5a2799859f6c", + "reference": "fa5d962a71f2169dfe1cbae217fa5a2799859f6c", "shasum": "" }, "require": { - "php": "^7.1.3" + "php": "^5.5.9|>=7.0.8" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "4.2-dev" + "dev-master": "3.4-dev" } }, "autoload": { @@ -1391,7 +1378,7 @@ ], "description": "Symfony Finder Component", "homepage": "https://symfony.com", - "time": "2019-02-23T15:42:05+00:00" + "time": "2019-05-24T12:25:55+00:00" }, { "name": "symfony/polyfill-ctype", @@ -1512,16 +1499,16 @@ }, { "name": "symfony/process", - "version": "v3.4.23", + "version": "v3.4.28", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "009f8dda80930e89e8344a4e310b08f9ff07dd2e" + "reference": "afe411c2a6084f25cff55a01d0d4e1474c97ff13" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/009f8dda80930e89e8344a4e310b08f9ff07dd2e", - "reference": "009f8dda80930e89e8344a4e310b08f9ff07dd2e", + "url": "https://api.github.com/repos/symfony/process/zipball/afe411c2a6084f25cff55a01d0d4e1474c97ff13", + "reference": "afe411c2a6084f25cff55a01d0d4e1474c97ff13", "shasum": "" }, "require": { @@ -1557,24 +1544,24 @@ ], "description": "Symfony Process Component", "homepage": "https://symfony.com", - "time": "2019-01-16T13:27:11+00:00" + "time": "2019-05-22T12:54:11+00:00" }, { "name": "symfony/yaml", - "version": "v4.2.4", + "version": "v3.4.28", "source": { "type": "git", "url": "https://github.com/symfony/yaml.git", - "reference": "761fa560a937fd7686e5274ff89dcfa87a5047df" + "reference": "212a27b731e5bfb735679d1ffaac82bd6a1dc996" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/761fa560a937fd7686e5274ff89dcfa87a5047df", - "reference": "761fa560a937fd7686e5274ff89dcfa87a5047df", + "url": "https://api.github.com/repos/symfony/yaml/zipball/212a27b731e5bfb735679d1ffaac82bd6a1dc996", + "reference": "212a27b731e5bfb735679d1ffaac82bd6a1dc996", "shasum": "" }, "require": { - "php": "^7.1.3", + "php": "^5.5.9|>=7.0.8", "symfony/polyfill-ctype": "~1.8" }, "conflict": { @@ -1589,7 +1576,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "4.2-dev" + "dev-master": "3.4-dev" } }, "autoload": { @@ -1616,7 +1603,7 @@ ], "description": "Symfony Yaml Component", "homepage": "https://symfony.com", - "time": "2019-02-23T15:17:42+00:00" + "time": "2019-03-25T07:48:46+00:00" } ], "packages-dev": [], From 62fe3a7298483cd7f18ebeafc2bc67dce673ca2b Mon Sep 17 00:00:00 2001 From: "J. King" Date: Fri, 21 Jun 2019 10:30:36 -0400 Subject: [PATCH 120/142] Fix case of vfsstream tool dependency --- vendor-bin/csfixer/composer.lock | 436 ++++++++++++++++++++++--------- vendor-bin/phpunit/composer.json | 2 +- vendor-bin/phpunit/composer.lock | 317 ++++++++++------------ vendor-bin/robo/composer.lock | 338 ++++++++++++++++-------- 4 files changed, 677 insertions(+), 416 deletions(-) diff --git a/vendor-bin/csfixer/composer.lock b/vendor-bin/csfixer/composer.lock index 2b7494e7..a5945c46 100644 --- a/vendor-bin/csfixer/composer.lock +++ b/vendor-bin/csfixer/composer.lock @@ -114,30 +114,30 @@ }, { "name": "doctrine/annotations", - "version": "v1.4.0", + "version": "v1.6.1", "source": { "type": "git", "url": "https://github.com/doctrine/annotations.git", - "reference": "54cacc9b81758b14e3ce750f205a393d52339e97" + "reference": "53120e0eb10355388d6ccbe462f1fea34ddadb24" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/annotations/zipball/54cacc9b81758b14e3ce750f205a393d52339e97", - "reference": "54cacc9b81758b14e3ce750f205a393d52339e97", + "url": "https://api.github.com/repos/doctrine/annotations/zipball/53120e0eb10355388d6ccbe462f1fea34ddadb24", + "reference": "53120e0eb10355388d6ccbe462f1fea34ddadb24", "shasum": "" }, "require": { "doctrine/lexer": "1.*", - "php": "^5.6 || ^7.0" + "php": "^7.1" }, "require-dev": { "doctrine/cache": "1.*", - "phpunit/phpunit": "^5.7" + "phpunit/phpunit": "^6.4" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.4.x-dev" + "dev-master": "1.6.x-dev" } }, "autoload": { @@ -178,7 +178,7 @@ "docblock", "parser" ], - "time": "2017-02-24T16:22:25+00:00" + "time": "2019-03-25T19:12:02+00:00" }, { "name": "doctrine/lexer", @@ -424,6 +424,55 @@ ], "time": "2018-02-15T16:58:55+00:00" }, + { + "name": "psr/container", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/container.git", + "reference": "b7ce3b176482dbbc1245ebf52b181af44c2cf55f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/container/zipball/b7ce3b176482dbbc1245ebf52b181af44c2cf55f", + "reference": "b7ce3b176482dbbc1245ebf52b181af44c2cf55f", + "shasum": "" + }, + "require": { + "php": ">=5.3.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Container\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Common Container Interface (PHP FIG PSR-11)", + "homepage": "https://github.com/php-fig/container", + "keywords": [ + "PSR-11", + "container", + "container-interface", + "container-interop", + "psr" + ], + "time": "2017-02-14T16:28:37+00:00" + }, { "name": "psr/log", "version": "1.1.0", @@ -473,25 +522,27 @@ }, { "name": "symfony/console", - "version": "v3.4.28", + "version": "v4.3.1", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "8e1d1e406dd31727fa70cd5a99cda202e9d6a5c6" + "reference": "d50bbeeb0e17e6dd4124ea391eff235e932cbf64" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/8e1d1e406dd31727fa70cd5a99cda202e9d6a5c6", - "reference": "8e1d1e406dd31727fa70cd5a99cda202e9d6a5c6", + "url": "https://api.github.com/repos/symfony/console/zipball/d50bbeeb0e17e6dd4124ea391eff235e932cbf64", + "reference": "d50bbeeb0e17e6dd4124ea391eff235e932cbf64", "shasum": "" }, "require": { - "php": "^5.5.9|>=7.0.8", - "symfony/debug": "~2.8|~3.0|~4.0", - "symfony/polyfill-mbstring": "~1.0" + "php": "^7.1.3", + "symfony/polyfill-mbstring": "~1.0", + "symfony/polyfill-php73": "^1.8", + "symfony/service-contracts": "^1.1" }, "conflict": { "symfony/dependency-injection": "<3.4", + "symfony/event-dispatcher": "<4.3", "symfony/process": "<3.3" }, "provide": { @@ -499,11 +550,12 @@ }, "require-dev": { "psr/log": "~1.0", - "symfony/config": "~3.3|~4.0", + "symfony/config": "~3.4|~4.0", "symfony/dependency-injection": "~3.4|~4.0", - "symfony/event-dispatcher": "~2.8|~3.0|~4.0", + "symfony/event-dispatcher": "^4.3", "symfony/lock": "~3.4|~4.0", - "symfony/process": "~3.3|~4.0" + "symfony/process": "~3.4|~4.0", + "symfony/var-dumper": "^4.3" }, "suggest": { "psr/log": "For using the console logger", @@ -514,7 +566,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "3.4-dev" + "dev-master": "4.3-dev" } }, "autoload": { @@ -541,90 +593,41 @@ ], "description": "Symfony Console Component", "homepage": "https://symfony.com", - "time": "2019-05-09T08:42:51+00:00" - }, - { - "name": "symfony/debug", - "version": "v3.4.28", - "source": { - "type": "git", - "url": "https://github.com/symfony/debug.git", - "reference": "671fc55bd14800668b1d0a3708c3714940e30a8c" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/debug/zipball/671fc55bd14800668b1d0a3708c3714940e30a8c", - "reference": "671fc55bd14800668b1d0a3708c3714940e30a8c", - "shasum": "" - }, - "require": { - "php": "^5.5.9|>=7.0.8", - "psr/log": "~1.0" - }, - "conflict": { - "symfony/http-kernel": ">=2.3,<2.3.24|~2.4.0|>=2.5,<2.5.9|>=2.6,<2.6.2" - }, - "require-dev": { - "symfony/http-kernel": "~2.8|~3.0|~4.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "3.4-dev" - } - }, - "autoload": { - "psr-4": { - "Symfony\\Component\\Debug\\": "" - }, - "exclude-from-classmap": [ - "/Tests/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Symfony Debug Component", - "homepage": "https://symfony.com", - "time": "2019-05-18T13:32:47+00:00" + "time": "2019-06-05T13:25:51+00:00" }, { "name": "symfony/event-dispatcher", - "version": "v3.4.28", + "version": "v4.3.1", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher.git", - "reference": "a088aafcefb4eef2520a290ed82e4374092a6dff" + "reference": "4e6c670af81c4fb0b6c08b035530a9915d0b691f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/a088aafcefb4eef2520a290ed82e4374092a6dff", - "reference": "a088aafcefb4eef2520a290ed82e4374092a6dff", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/4e6c670af81c4fb0b6c08b035530a9915d0b691f", + "reference": "4e6c670af81c4fb0b6c08b035530a9915d0b691f", "shasum": "" }, "require": { - "php": "^5.5.9|>=7.0.8" + "php": "^7.1.3", + "symfony/event-dispatcher-contracts": "^1.1" }, "conflict": { - "symfony/dependency-injection": "<3.3" + "symfony/dependency-injection": "<3.4" + }, + "provide": { + "psr/event-dispatcher-implementation": "1.0", + "symfony/event-dispatcher-implementation": "1.1" }, "require-dev": { "psr/log": "~1.0", - "symfony/config": "~2.8|~3.0|~4.0", - "symfony/dependency-injection": "~3.3|~4.0", - "symfony/expression-language": "~2.8|~3.0|~4.0", - "symfony/stopwatch": "~2.8|~3.0|~4.0" + "symfony/config": "~3.4|~4.0", + "symfony/dependency-injection": "~3.4|~4.0", + "symfony/expression-language": "~3.4|~4.0", + "symfony/http-foundation": "^3.4|^4.0", + "symfony/service-contracts": "^1.1", + "symfony/stopwatch": "~3.4|~4.0" }, "suggest": { "symfony/dependency-injection": "", @@ -633,7 +636,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "3.4-dev" + "dev-master": "4.3-dev" } }, "autoload": { @@ -660,30 +663,88 @@ ], "description": "Symfony EventDispatcher Component", "homepage": "https://symfony.com", - "time": "2019-04-02T08:51:52+00:00" + "time": "2019-05-30T16:10:05+00:00" }, { - "name": "symfony/filesystem", - "version": "v3.4.28", + "name": "symfony/event-dispatcher-contracts", + "version": "v1.1.5", "source": { "type": "git", - "url": "https://github.com/symfony/filesystem.git", - "reference": "acf99758b1df8e9295e6b85aa69f294565c9fedb" + "url": "https://github.com/symfony/event-dispatcher-contracts.git", + "reference": "c61766f4440ca687de1084a5c00b08e167a2575c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/acf99758b1df8e9295e6b85aa69f294565c9fedb", - "reference": "acf99758b1df8e9295e6b85aa69f294565c9fedb", + "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/c61766f4440ca687de1084a5c00b08e167a2575c", + "reference": "c61766f4440ca687de1084a5c00b08e167a2575c", "shasum": "" }, "require": { - "php": "^5.5.9|>=7.0.8", + "php": "^7.1.3" + }, + "suggest": { + "psr/event-dispatcher": "", + "symfony/event-dispatcher-implementation": "" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.1-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\EventDispatcher\\": "" + } + }, + "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": "Generic abstractions related to dispatching event", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "time": "2019-06-20T06:46:26+00:00" + }, + { + "name": "symfony/filesystem", + "version": "v4.3.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/filesystem.git", + "reference": "bf2af40d738dec5e433faea7b00daa4431d0a4cf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/bf2af40d738dec5e433faea7b00daa4431d0a4cf", + "reference": "bf2af40d738dec5e433faea7b00daa4431d0a4cf", + "shasum": "" + }, + "require": { + "php": "^7.1.3", "symfony/polyfill-ctype": "~1.8" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "3.4-dev" + "dev-master": "4.3-dev" } }, "autoload": { @@ -710,29 +771,29 @@ ], "description": "Symfony Filesystem Component", "homepage": "https://symfony.com", - "time": "2019-02-04T21:34:32+00:00" + "time": "2019-06-03T20:27:40+00:00" }, { "name": "symfony/finder", - "version": "v3.4.28", + "version": "v4.3.1", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "fa5d962a71f2169dfe1cbae217fa5a2799859f6c" + "reference": "b3d4f4c0e4eadfdd8b296af9ca637cfbf51d8176" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/fa5d962a71f2169dfe1cbae217fa5a2799859f6c", - "reference": "fa5d962a71f2169dfe1cbae217fa5a2799859f6c", + "url": "https://api.github.com/repos/symfony/finder/zipball/b3d4f4c0e4eadfdd8b296af9ca637cfbf51d8176", + "reference": "b3d4f4c0e4eadfdd8b296af9ca637cfbf51d8176", "shasum": "" }, "require": { - "php": "^5.5.9|>=7.0.8" + "php": "^7.1.3" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "3.4-dev" + "dev-master": "4.3-dev" } }, "autoload": { @@ -759,29 +820,29 @@ ], "description": "Symfony Finder Component", "homepage": "https://symfony.com", - "time": "2019-05-24T12:25:55+00:00" + "time": "2019-05-26T20:47:49+00:00" }, { "name": "symfony/options-resolver", - "version": "v3.4.28", + "version": "v4.3.1", "source": { "type": "git", "url": "https://github.com/symfony/options-resolver.git", - "reference": "ed3b397f9c07c8ca388b2a1ef744403b4d4ecc44" + "reference": "914e0edcb7cd0c9f494bc023b1d47534f4542332" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/options-resolver/zipball/ed3b397f9c07c8ca388b2a1ef744403b4d4ecc44", - "reference": "ed3b397f9c07c8ca388b2a1ef744403b4d4ecc44", + "url": "https://api.github.com/repos/symfony/options-resolver/zipball/914e0edcb7cd0c9f494bc023b1d47534f4542332", + "reference": "914e0edcb7cd0c9f494bc023b1d47534f4542332", "shasum": "" }, "require": { - "php": "^5.5.9|>=7.0.8" + "php": "^7.1.3" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "3.4-dev" + "dev-master": "4.3-dev" } }, "autoload": { @@ -813,7 +874,7 @@ "configuration", "options" ], - "time": "2019-04-10T16:00:48+00:00" + "time": "2019-05-10T05:38:46+00:00" }, { "name": "symfony/polyfill-ctype", @@ -1047,26 +1108,84 @@ "time": "2019-02-06T07:57:58+00:00" }, { - "name": "symfony/process", - "version": "v3.4.28", + "name": "symfony/polyfill-php73", + "version": "v1.11.0", "source": { "type": "git", - "url": "https://github.com/symfony/process.git", - "reference": "afe411c2a6084f25cff55a01d0d4e1474c97ff13" + "url": "https://github.com/symfony/polyfill-php73.git", + "reference": "d1fb4abcc0c47be136208ad9d68bf59f1ee17abd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/afe411c2a6084f25cff55a01d0d4e1474c97ff13", - "reference": "afe411c2a6084f25cff55a01d0d4e1474c97ff13", + "url": "https://api.github.com/repos/symfony/polyfill-php73/zipball/d1fb4abcc0c47be136208ad9d68bf59f1ee17abd", + "reference": "d1fb4abcc0c47be136208ad9d68bf59f1ee17abd", "shasum": "" }, "require": { - "php": "^5.5.9|>=7.0.8" + "php": ">=5.3.3" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "3.4-dev" + "dev-master": "1.11-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Polyfill\\Php73\\": "" + }, + "files": [ + "bootstrap.php" + ], + "classmap": [ + "Resources/stubs" + ] + }, + "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": "Symfony polyfill backporting some PHP 7.3+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "time": "2019-02-06T07:57:58+00:00" + }, + { + "name": "symfony/process", + "version": "v4.3.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/process.git", + "reference": "856d35814cf287480465bb7a6c413bb7f5f5e69c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/process/zipball/856d35814cf287480465bb7a6c413bb7f5f5e69c", + "reference": "856d35814cf287480465bb7a6c413bb7f5f5e69c", + "shasum": "" + }, + "require": { + "php": "^7.1.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.3-dev" } }, "autoload": { @@ -1093,29 +1212,88 @@ ], "description": "Symfony Process Component", "homepage": "https://symfony.com", - "time": "2019-05-22T12:54:11+00:00" + "time": "2019-05-30T16:10:05+00:00" }, { - "name": "symfony/stopwatch", - "version": "v3.4.28", + "name": "symfony/service-contracts", + "version": "v1.1.5", "source": { "type": "git", - "url": "https://github.com/symfony/stopwatch.git", - "reference": "2a651c2645c10bbedd21170771f122d935e0dd58" + "url": "https://github.com/symfony/service-contracts.git", + "reference": "f391a00de78ec7ec8cf5cdcdae59ec7b883edb8d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/stopwatch/zipball/2a651c2645c10bbedd21170771f122d935e0dd58", - "reference": "2a651c2645c10bbedd21170771f122d935e0dd58", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/f391a00de78ec7ec8cf5cdcdae59ec7b883edb8d", + "reference": "f391a00de78ec7ec8cf5cdcdae59ec7b883edb8d", "shasum": "" }, "require": { - "php": "^5.5.9|>=7.0.8" + "php": "^7.1.3", + "psr/container": "^1.0" + }, + "suggest": { + "symfony/service-implementation": "" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "3.4-dev" + "dev-master": "1.1-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Service\\": "" + } + }, + "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": "Generic abstractions related to writing services", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "time": "2019-06-13T11:15:36+00:00" + }, + { + "name": "symfony/stopwatch", + "version": "v4.3.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/stopwatch.git", + "reference": "6b100e9309e8979cf1978ac1778eb155c1f7d93b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/stopwatch/zipball/6b100e9309e8979cf1978ac1778eb155c1f7d93b", + "reference": "6b100e9309e8979cf1978ac1778eb155c1f7d93b", + "shasum": "" + }, + "require": { + "php": "^7.1.3", + "symfony/service-contracts": "^1.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.3-dev" } }, "autoload": { @@ -1142,7 +1320,7 @@ ], "description": "Symfony Stopwatch Component", "homepage": "https://symfony.com", - "time": "2019-01-16T09:39:14+00:00" + "time": "2019-05-27T08:16:38+00:00" } ], "packages-dev": [], diff --git a/vendor-bin/phpunit/composer.json b/vendor-bin/phpunit/composer.json index 3afc6e79..7faefcbf 100644 --- a/vendor-bin/phpunit/composer.json +++ b/vendor-bin/phpunit/composer.json @@ -3,7 +3,7 @@ "phpunit/phpunit": "6.* | 7.*", "phake/phake": "^3.0", "clue/arguments": "^2.0", - "mikey179/vfsStream": "^1.6", + "mikey179/vfsstream": "^1.6", "webmozart/glob": "^4.1" } } diff --git a/vendor-bin/phpunit/composer.lock b/vendor-bin/phpunit/composer.lock index 3f6a3a4e..bc435909 100644 --- a/vendor-bin/phpunit/composer.lock +++ b/vendor-bin/phpunit/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "57ab1526d0611e8766f67b8e0fa16912", + "content-hash": "0efc271cb10b6582cac5f373a48fc969", "packages": [ { "name": "clue/arguments", @@ -58,32 +58,34 @@ }, { "name": "doctrine/instantiator", - "version": "1.0.5", + "version": "1.2.0", "source": { "type": "git", "url": "https://github.com/doctrine/instantiator.git", - "reference": "8e884e78f9f0eb1329e445619e04456e64d8051d" + "reference": "a2c590166b2133a4633738648b6b064edae0814a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/instantiator/zipball/8e884e78f9f0eb1329e445619e04456e64d8051d", - "reference": "8e884e78f9f0eb1329e445619e04456e64d8051d", + "url": "https://api.github.com/repos/doctrine/instantiator/zipball/a2c590166b2133a4633738648b6b064edae0814a", + "reference": "a2c590166b2133a4633738648b6b064edae0814a", "shasum": "" }, "require": { - "php": ">=5.3,<8.0-DEV" + "php": "^7.1" }, "require-dev": { - "athletic/athletic": "~0.1.8", + "doctrine/coding-standard": "^6.0", "ext-pdo": "*", "ext-phar": "*", - "phpunit/phpunit": "~4.0", - "squizlabs/php_codesniffer": "~2.0" + "phpbench/phpbench": "^0.13", + "phpstan/phpstan-phpunit": "^0.11", + "phpstan/phpstan-shim": "^0.11", + "phpunit/phpunit": "^7.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.0.x-dev" + "dev-master": "1.2.x-dev" } }, "autoload": { @@ -103,12 +105,12 @@ } ], "description": "A small, lightweight utility to instantiate objects in PHP without invoking their constructors", - "homepage": "https://github.com/doctrine/instantiator", + "homepage": "https://www.doctrine-project.org/projects/instantiator.html", "keywords": [ "constructor", "instantiate" ], - "time": "2015-06-14T21:17:01+00:00" + "time": "2019-03-17T17:37:11+00:00" }, { "name": "mikey179/vfsstream", @@ -158,25 +160,28 @@ }, { "name": "myclabs/deep-copy", - "version": "1.7.0", + "version": "1.9.1", "source": { "type": "git", "url": "https://github.com/myclabs/DeepCopy.git", - "reference": "3b8a3a99ba1f6a3952ac2747d989303cbd6b7a3e" + "reference": "e6828efaba2c9b79f4499dae1d66ef8bfa7b2b72" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/3b8a3a99ba1f6a3952ac2747d989303cbd6b7a3e", - "reference": "3b8a3a99ba1f6a3952ac2747d989303cbd6b7a3e", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/e6828efaba2c9b79f4499dae1d66ef8bfa7b2b72", + "reference": "e6828efaba2c9b79f4499dae1d66ef8bfa7b2b72", "shasum": "" }, "require": { - "php": "^5.6 || ^7.0" + "php": "^7.1" + }, + "replace": { + "myclabs/deep-copy": "self.version" }, "require-dev": { "doctrine/collections": "^1.0", "doctrine/common": "^2.6", - "phpunit/phpunit": "^4.1" + "phpunit/phpunit": "^7.1" }, "type": "library", "autoload": { @@ -199,7 +204,7 @@ "object", "object graph" ], - "time": "2017-10-19T19:58:43+00:00" + "time": "2019-04-07T13:18:21+00:00" }, { "name": "phake/phake", @@ -261,22 +266,22 @@ }, { "name": "phar-io/manifest", - "version": "1.0.1", + "version": "1.0.3", "source": { "type": "git", "url": "https://github.com/phar-io/manifest.git", - "reference": "2df402786ab5368a0169091f61a7c1e0eb6852d0" + "reference": "7761fcacf03b4d4f16e7ccb606d4879ca431fcf4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phar-io/manifest/zipball/2df402786ab5368a0169091f61a7c1e0eb6852d0", - "reference": "2df402786ab5368a0169091f61a7c1e0eb6852d0", + "url": "https://api.github.com/repos/phar-io/manifest/zipball/7761fcacf03b4d4f16e7ccb606d4879ca431fcf4", + "reference": "7761fcacf03b4d4f16e7ccb606d4879ca431fcf4", "shasum": "" }, "require": { "ext-dom": "*", "ext-phar": "*", - "phar-io/version": "^1.0.1", + "phar-io/version": "^2.0", "php": "^5.6 || ^7.0" }, "type": "library", @@ -312,20 +317,20 @@ } ], "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)", - "time": "2017-03-05T18:14:27+00:00" + "time": "2018-07-08T19:23:20+00:00" }, { "name": "phar-io/version", - "version": "1.0.1", + "version": "2.0.1", "source": { "type": "git", "url": "https://github.com/phar-io/version.git", - "reference": "a70c0ced4be299a63d32fa96d9281d03e94041df" + "reference": "45a2ec53a73c70ce41d55cedef9063630abaf1b6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phar-io/version/zipball/a70c0ced4be299a63d32fa96d9281d03e94041df", - "reference": "a70c0ced4be299a63d32fa96d9281d03e94041df", + "url": "https://api.github.com/repos/phar-io/version/zipball/45a2ec53a73c70ce41d55cedef9063630abaf1b6", + "reference": "45a2ec53a73c70ce41d55cedef9063630abaf1b6", "shasum": "" }, "require": { @@ -359,7 +364,7 @@ } ], "description": "Library for handling version information and constraints", - "time": "2017-03-05T17:38:23+00:00" + "time": "2018-07-08T19:19:57+00:00" }, { "name": "phpdocumentor/reflection-common", @@ -578,40 +583,40 @@ }, { "name": "phpunit/php-code-coverage", - "version": "5.3.2", + "version": "6.1.4", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-code-coverage.git", - "reference": "c89677919c5dd6d3b3852f230a663118762218ac" + "reference": "807e6013b00af69b6c5d9ceb4282d0393dbb9d8d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/c89677919c5dd6d3b3852f230a663118762218ac", - "reference": "c89677919c5dd6d3b3852f230a663118762218ac", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/807e6013b00af69b6c5d9ceb4282d0393dbb9d8d", + "reference": "807e6013b00af69b6c5d9ceb4282d0393dbb9d8d", "shasum": "" }, "require": { "ext-dom": "*", "ext-xmlwriter": "*", - "php": "^7.0", - "phpunit/php-file-iterator": "^1.4.2", + "php": "^7.1", + "phpunit/php-file-iterator": "^2.0", "phpunit/php-text-template": "^1.2.1", - "phpunit/php-token-stream": "^2.0.1", + "phpunit/php-token-stream": "^3.0", "sebastian/code-unit-reverse-lookup": "^1.0.1", - "sebastian/environment": "^3.0", + "sebastian/environment": "^3.1 || ^4.0", "sebastian/version": "^2.0.1", "theseer/tokenizer": "^1.1" }, "require-dev": { - "phpunit/phpunit": "^6.0" + "phpunit/phpunit": "^7.0" }, "suggest": { - "ext-xdebug": "^2.5.5" + "ext-xdebug": "^2.6.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "5.3.x-dev" + "dev-master": "6.1-dev" } }, "autoload": { @@ -637,29 +642,32 @@ "testing", "xunit" ], - "time": "2018-04-06T15:36:58+00:00" + "time": "2018-10-31T16:06:48+00:00" }, { "name": "phpunit/php-file-iterator", - "version": "1.4.5", + "version": "2.0.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-file-iterator.git", - "reference": "730b01bc3e867237eaac355e06a36b85dd93a8b4" + "reference": "050bedf145a257b1ff02746c31894800e5122946" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/730b01bc3e867237eaac355e06a36b85dd93a8b4", - "reference": "730b01bc3e867237eaac355e06a36b85dd93a8b4", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/050bedf145a257b1ff02746c31894800e5122946", + "reference": "050bedf145a257b1ff02746c31894800e5122946", "shasum": "" }, "require": { - "php": ">=5.3.3" + "php": "^7.1" + }, + "require-dev": { + "phpunit/phpunit": "^7.1" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.4.x-dev" + "dev-master": "2.0.x-dev" } }, "autoload": { @@ -674,7 +682,7 @@ "authors": [ { "name": "Sebastian Bergmann", - "email": "sb@sebastian-bergmann.de", + "email": "sebastian@phpunit.de", "role": "lead" } ], @@ -684,7 +692,7 @@ "filesystem", "iterator" ], - "time": "2017-11-27T13:52:08+00:00" + "time": "2018-09-13T20:33:42+00:00" }, { "name": "phpunit/php-text-template", @@ -729,28 +737,28 @@ }, { "name": "phpunit/php-timer", - "version": "1.0.9", + "version": "2.1.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-timer.git", - "reference": "3dcf38ca72b158baf0bc245e9184d3fdffa9c46f" + "reference": "1038454804406b0b5f5f520358e78c1c2f71501e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/3dcf38ca72b158baf0bc245e9184d3fdffa9c46f", - "reference": "3dcf38ca72b158baf0bc245e9184d3fdffa9c46f", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/1038454804406b0b5f5f520358e78c1c2f71501e", + "reference": "1038454804406b0b5f5f520358e78c1c2f71501e", "shasum": "" }, "require": { - "php": "^5.3.3 || ^7.0" + "php": "^7.1" }, "require-dev": { - "phpunit/phpunit": "^4.8.35 || ^5.7 || ^6.0" + "phpunit/phpunit": "^7.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.0-dev" + "dev-master": "2.1-dev" } }, "autoload": { @@ -765,7 +773,7 @@ "authors": [ { "name": "Sebastian Bergmann", - "email": "sb@sebastian-bergmann.de", + "email": "sebastian@phpunit.de", "role": "lead" } ], @@ -774,33 +782,33 @@ "keywords": [ "timer" ], - "time": "2017-02-26T11:10:40+00:00" + "time": "2019-06-07T04:22:29+00:00" }, { "name": "phpunit/php-token-stream", - "version": "2.0.2", + "version": "3.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-token-stream.git", - "reference": "791198a2c6254db10131eecfe8c06670700904db" + "reference": "c99e3be9d3e85f60646f152f9002d46ed7770d18" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-token-stream/zipball/791198a2c6254db10131eecfe8c06670700904db", - "reference": "791198a2c6254db10131eecfe8c06670700904db", + "url": "https://api.github.com/repos/sebastianbergmann/php-token-stream/zipball/c99e3be9d3e85f60646f152f9002d46ed7770d18", + "reference": "c99e3be9d3e85f60646f152f9002d46ed7770d18", "shasum": "" }, "require": { "ext-tokenizer": "*", - "php": "^7.0" + "php": "^7.1" }, "require-dev": { - "phpunit/phpunit": "^6.2.4" + "phpunit/phpunit": "^7.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "2.0-dev" + "dev-master": "3.0-dev" } }, "autoload": { @@ -823,57 +831,57 @@ "keywords": [ "tokenizer" ], - "time": "2017-11-27T05:48:46+00:00" + "time": "2018-10-30T05:52:18+00:00" }, { "name": "phpunit/phpunit", - "version": "6.5.14", + "version": "7.5.13", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "bac23fe7ff13dbdb461481f706f0e9fe746334b7" + "reference": "b9278591caa8630127f96c63b598712b699e671c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/bac23fe7ff13dbdb461481f706f0e9fe746334b7", - "reference": "bac23fe7ff13dbdb461481f706f0e9fe746334b7", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/b9278591caa8630127f96c63b598712b699e671c", + "reference": "b9278591caa8630127f96c63b598712b699e671c", "shasum": "" }, "require": { + "doctrine/instantiator": "^1.1", "ext-dom": "*", "ext-json": "*", "ext-libxml": "*", "ext-mbstring": "*", "ext-xml": "*", - "myclabs/deep-copy": "^1.6.1", - "phar-io/manifest": "^1.0.1", - "phar-io/version": "^1.0", - "php": "^7.0", + "myclabs/deep-copy": "^1.7", + "phar-io/manifest": "^1.0.2", + "phar-io/version": "^2.0", + "php": "^7.1", "phpspec/prophecy": "^1.7", - "phpunit/php-code-coverage": "^5.3", - "phpunit/php-file-iterator": "^1.4.3", + "phpunit/php-code-coverage": "^6.0.7", + "phpunit/php-file-iterator": "^2.0.1", "phpunit/php-text-template": "^1.2.1", - "phpunit/php-timer": "^1.0.9", - "phpunit/phpunit-mock-objects": "^5.0.9", - "sebastian/comparator": "^2.1", - "sebastian/diff": "^2.0", - "sebastian/environment": "^3.1", + "phpunit/php-timer": "^2.1", + "sebastian/comparator": "^3.0", + "sebastian/diff": "^3.0", + "sebastian/environment": "^4.0", "sebastian/exporter": "^3.1", "sebastian/global-state": "^2.0", "sebastian/object-enumerator": "^3.0.3", - "sebastian/resource-operations": "^1.0", + "sebastian/resource-operations": "^2.0", "sebastian/version": "^2.0.1" }, "conflict": { - "phpdocumentor/reflection-docblock": "3.0.2", - "phpunit/dbunit": "<3.0" + "phpunit/phpunit-mock-objects": "*" }, "require-dev": { "ext-pdo": "*" }, "suggest": { + "ext-soap": "*", "ext-xdebug": "*", - "phpunit/php-invoker": "^1.1" + "phpunit/php-invoker": "^2.0" }, "bin": [ "phpunit" @@ -881,7 +889,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "6.5.x-dev" + "dev-master": "7.5-dev" } }, "autoload": { @@ -907,67 +915,7 @@ "testing", "xunit" ], - "time": "2019-02-01T05:22:47+00:00" - }, - { - "name": "phpunit/phpunit-mock-objects", - "version": "5.0.10", - "source": { - "type": "git", - "url": "https://github.com/sebastianbergmann/phpunit-mock-objects.git", - "reference": "cd1cf05c553ecfec36b170070573e540b67d3f1f" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit-mock-objects/zipball/cd1cf05c553ecfec36b170070573e540b67d3f1f", - "reference": "cd1cf05c553ecfec36b170070573e540b67d3f1f", - "shasum": "" - }, - "require": { - "doctrine/instantiator": "^1.0.5", - "php": "^7.0", - "phpunit/php-text-template": "^1.2.1", - "sebastian/exporter": "^3.1" - }, - "conflict": { - "phpunit/phpunit": "<6.0" - }, - "require-dev": { - "phpunit/phpunit": "^6.5.11" - }, - "suggest": { - "ext-soap": "*" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "5.0.x-dev" - } - }, - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "lead" - } - ], - "description": "Mock Object library for PHPUnit", - "homepage": "https://github.com/sebastianbergmann/phpunit-mock-objects/", - "keywords": [ - "mock", - "xunit" - ], - "abandoned": true, - "time": "2018-08-09T05:50:03+00:00" + "time": "2019-06-19T12:01:51+00:00" }, { "name": "sebastian/code-unit-reverse-lookup", @@ -1016,30 +964,30 @@ }, { "name": "sebastian/comparator", - "version": "2.1.3", + "version": "3.0.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/comparator.git", - "reference": "34369daee48eafb2651bea869b4b15d75ccc35f9" + "reference": "5de4fc177adf9bce8df98d8d141a7559d7ccf6da" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/34369daee48eafb2651bea869b4b15d75ccc35f9", - "reference": "34369daee48eafb2651bea869b4b15d75ccc35f9", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/5de4fc177adf9bce8df98d8d141a7559d7ccf6da", + "reference": "5de4fc177adf9bce8df98d8d141a7559d7ccf6da", "shasum": "" }, "require": { - "php": "^7.0", - "sebastian/diff": "^2.0 || ^3.0", + "php": "^7.1", + "sebastian/diff": "^3.0", "sebastian/exporter": "^3.1" }, "require-dev": { - "phpunit/phpunit": "^6.4" + "phpunit/phpunit": "^7.1" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "2.1.x-dev" + "dev-master": "3.0-dev" } }, "autoload": { @@ -1076,32 +1024,33 @@ "compare", "equality" ], - "time": "2018-02-01T13:46:46+00:00" + "time": "2018-07-12T15:12:46+00:00" }, { "name": "sebastian/diff", - "version": "2.0.1", + "version": "3.0.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/diff.git", - "reference": "347c1d8b49c5c3ee30c7040ea6fc446790e6bddd" + "reference": "720fcc7e9b5cf384ea68d9d930d480907a0c1a29" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/347c1d8b49c5c3ee30c7040ea6fc446790e6bddd", - "reference": "347c1d8b49c5c3ee30c7040ea6fc446790e6bddd", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/720fcc7e9b5cf384ea68d9d930d480907a0c1a29", + "reference": "720fcc7e9b5cf384ea68d9d930d480907a0c1a29", "shasum": "" }, "require": { - "php": "^7.0" + "php": "^7.1" }, "require-dev": { - "phpunit/phpunit": "^6.2" + "phpunit/phpunit": "^7.5 || ^8.0", + "symfony/process": "^2 || ^3.3 || ^4" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "2.0-dev" + "dev-master": "3.0-dev" } }, "autoload": { @@ -1126,34 +1075,40 @@ "description": "Diff implementation", "homepage": "https://github.com/sebastianbergmann/diff", "keywords": [ - "diff" + "diff", + "udiff", + "unidiff", + "unified diff" ], - "time": "2017-08-03T08:09:46+00:00" + "time": "2019-02-04T06:01:07+00:00" }, { "name": "sebastian/environment", - "version": "3.1.0", + "version": "4.2.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/environment.git", - "reference": "cd0871b3975fb7fc44d11314fd1ee20925fce4f5" + "reference": "f2a2c8e1c97c11ace607a7a667d73d47c19fe404" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/cd0871b3975fb7fc44d11314fd1ee20925fce4f5", - "reference": "cd0871b3975fb7fc44d11314fd1ee20925fce4f5", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/f2a2c8e1c97c11ace607a7a667d73d47c19fe404", + "reference": "f2a2c8e1c97c11ace607a7a667d73d47c19fe404", "shasum": "" }, "require": { - "php": "^7.0" + "php": "^7.1" }, "require-dev": { - "phpunit/phpunit": "^6.1" + "phpunit/phpunit": "^7.5" + }, + "suggest": { + "ext-posix": "*" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "3.1.x-dev" + "dev-master": "4.2-dev" } }, "autoload": { @@ -1178,7 +1133,7 @@ "environment", "hhvm" ], - "time": "2017-07-01T08:51:00+00:00" + "time": "2019-05-05T09:05:15+00:00" }, { "name": "sebastian/exporter", @@ -1445,25 +1400,25 @@ }, { "name": "sebastian/resource-operations", - "version": "1.0.0", + "version": "2.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/resource-operations.git", - "reference": "ce990bb21759f94aeafd30209e8cfcdfa8bc3f52" + "reference": "4d7a795d35b889bf80a0cc04e08d77cedfa917a9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/resource-operations/zipball/ce990bb21759f94aeafd30209e8cfcdfa8bc3f52", - "reference": "ce990bb21759f94aeafd30209e8cfcdfa8bc3f52", + "url": "https://api.github.com/repos/sebastianbergmann/resource-operations/zipball/4d7a795d35b889bf80a0cc04e08d77cedfa917a9", + "reference": "4d7a795d35b889bf80a0cc04e08d77cedfa917a9", "shasum": "" }, "require": { - "php": ">=5.6.0" + "php": "^7.1" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.0.x-dev" + "dev-master": "2.0-dev" } }, "autoload": { @@ -1483,7 +1438,7 @@ ], "description": "Provides a list of PHP built-in functions that operate on resources", "homepage": "https://www.github.com/sebastianbergmann/resource-operations", - "time": "2015-07-28T20:34:47+00:00" + "time": "2018-10-04T04:07:39+00:00" }, { "name": "sebastian/version", diff --git a/vendor-bin/robo/composer.lock b/vendor-bin/robo/composer.lock index 2c7301c5..ad49752c 100644 --- a/vendor-bin/robo/composer.lock +++ b/vendor-bin/robo/composer.lock @@ -1092,25 +1092,27 @@ }, { "name": "symfony/console", - "version": "v3.4.28", + "version": "v4.3.1", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "8e1d1e406dd31727fa70cd5a99cda202e9d6a5c6" + "reference": "d50bbeeb0e17e6dd4124ea391eff235e932cbf64" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/8e1d1e406dd31727fa70cd5a99cda202e9d6a5c6", - "reference": "8e1d1e406dd31727fa70cd5a99cda202e9d6a5c6", + "url": "https://api.github.com/repos/symfony/console/zipball/d50bbeeb0e17e6dd4124ea391eff235e932cbf64", + "reference": "d50bbeeb0e17e6dd4124ea391eff235e932cbf64", "shasum": "" }, "require": { - "php": "^5.5.9|>=7.0.8", - "symfony/debug": "~2.8|~3.0|~4.0", - "symfony/polyfill-mbstring": "~1.0" + "php": "^7.1.3", + "symfony/polyfill-mbstring": "~1.0", + "symfony/polyfill-php73": "^1.8", + "symfony/service-contracts": "^1.1" }, "conflict": { "symfony/dependency-injection": "<3.4", + "symfony/event-dispatcher": "<4.3", "symfony/process": "<3.3" }, "provide": { @@ -1118,11 +1120,12 @@ }, "require-dev": { "psr/log": "~1.0", - "symfony/config": "~3.3|~4.0", + "symfony/config": "~3.4|~4.0", "symfony/dependency-injection": "~3.4|~4.0", - "symfony/event-dispatcher": "~2.8|~3.0|~4.0", + "symfony/event-dispatcher": "^4.3", "symfony/lock": "~3.4|~4.0", - "symfony/process": "~3.3|~4.0" + "symfony/process": "~3.4|~4.0", + "symfony/var-dumper": "^4.3" }, "suggest": { "psr/log": "For using the console logger", @@ -1133,7 +1136,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "3.4-dev" + "dev-master": "4.3-dev" } }, "autoload": { @@ -1160,90 +1163,41 @@ ], "description": "Symfony Console Component", "homepage": "https://symfony.com", - "time": "2019-05-09T08:42:51+00:00" - }, - { - "name": "symfony/debug", - "version": "v3.4.28", - "source": { - "type": "git", - "url": "https://github.com/symfony/debug.git", - "reference": "671fc55bd14800668b1d0a3708c3714940e30a8c" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/debug/zipball/671fc55bd14800668b1d0a3708c3714940e30a8c", - "reference": "671fc55bd14800668b1d0a3708c3714940e30a8c", - "shasum": "" - }, - "require": { - "php": "^5.5.9|>=7.0.8", - "psr/log": "~1.0" - }, - "conflict": { - "symfony/http-kernel": ">=2.3,<2.3.24|~2.4.0|>=2.5,<2.5.9|>=2.6,<2.6.2" - }, - "require-dev": { - "symfony/http-kernel": "~2.8|~3.0|~4.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "3.4-dev" - } - }, - "autoload": { - "psr-4": { - "Symfony\\Component\\Debug\\": "" - }, - "exclude-from-classmap": [ - "/Tests/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Symfony Debug Component", - "homepage": "https://symfony.com", - "time": "2019-05-18T13:32:47+00:00" + "time": "2019-06-05T13:25:51+00:00" }, { "name": "symfony/event-dispatcher", - "version": "v3.4.28", + "version": "v4.3.1", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher.git", - "reference": "a088aafcefb4eef2520a290ed82e4374092a6dff" + "reference": "4e6c670af81c4fb0b6c08b035530a9915d0b691f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/a088aafcefb4eef2520a290ed82e4374092a6dff", - "reference": "a088aafcefb4eef2520a290ed82e4374092a6dff", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/4e6c670af81c4fb0b6c08b035530a9915d0b691f", + "reference": "4e6c670af81c4fb0b6c08b035530a9915d0b691f", "shasum": "" }, "require": { - "php": "^5.5.9|>=7.0.8" + "php": "^7.1.3", + "symfony/event-dispatcher-contracts": "^1.1" }, "conflict": { - "symfony/dependency-injection": "<3.3" + "symfony/dependency-injection": "<3.4" + }, + "provide": { + "psr/event-dispatcher-implementation": "1.0", + "symfony/event-dispatcher-implementation": "1.1" }, "require-dev": { "psr/log": "~1.0", - "symfony/config": "~2.8|~3.0|~4.0", - "symfony/dependency-injection": "~3.3|~4.0", - "symfony/expression-language": "~2.8|~3.0|~4.0", - "symfony/stopwatch": "~2.8|~3.0|~4.0" + "symfony/config": "~3.4|~4.0", + "symfony/dependency-injection": "~3.4|~4.0", + "symfony/expression-language": "~3.4|~4.0", + "symfony/http-foundation": "^3.4|^4.0", + "symfony/service-contracts": "^1.1", + "symfony/stopwatch": "~3.4|~4.0" }, "suggest": { "symfony/dependency-injection": "", @@ -1252,7 +1206,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "3.4-dev" + "dev-master": "4.3-dev" } }, "autoload": { @@ -1279,30 +1233,88 @@ ], "description": "Symfony EventDispatcher Component", "homepage": "https://symfony.com", - "time": "2019-04-02T08:51:52+00:00" + "time": "2019-05-30T16:10:05+00:00" }, { - "name": "symfony/filesystem", - "version": "v3.4.28", + "name": "symfony/event-dispatcher-contracts", + "version": "v1.1.5", "source": { "type": "git", - "url": "https://github.com/symfony/filesystem.git", - "reference": "acf99758b1df8e9295e6b85aa69f294565c9fedb" + "url": "https://github.com/symfony/event-dispatcher-contracts.git", + "reference": "c61766f4440ca687de1084a5c00b08e167a2575c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/acf99758b1df8e9295e6b85aa69f294565c9fedb", - "reference": "acf99758b1df8e9295e6b85aa69f294565c9fedb", + "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/c61766f4440ca687de1084a5c00b08e167a2575c", + "reference": "c61766f4440ca687de1084a5c00b08e167a2575c", "shasum": "" }, "require": { - "php": "^5.5.9|>=7.0.8", + "php": "^7.1.3" + }, + "suggest": { + "psr/event-dispatcher": "", + "symfony/event-dispatcher-implementation": "" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.1-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\EventDispatcher\\": "" + } + }, + "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": "Generic abstractions related to dispatching event", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "time": "2019-06-20T06:46:26+00:00" + }, + { + "name": "symfony/filesystem", + "version": "v4.3.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/filesystem.git", + "reference": "bf2af40d738dec5e433faea7b00daa4431d0a4cf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/bf2af40d738dec5e433faea7b00daa4431d0a4cf", + "reference": "bf2af40d738dec5e433faea7b00daa4431d0a4cf", + "shasum": "" + }, + "require": { + "php": "^7.1.3", "symfony/polyfill-ctype": "~1.8" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "3.4-dev" + "dev-master": "4.3-dev" } }, "autoload": { @@ -1329,29 +1341,29 @@ ], "description": "Symfony Filesystem Component", "homepage": "https://symfony.com", - "time": "2019-02-04T21:34:32+00:00" + "time": "2019-06-03T20:27:40+00:00" }, { "name": "symfony/finder", - "version": "v3.4.28", + "version": "v4.3.1", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "fa5d962a71f2169dfe1cbae217fa5a2799859f6c" + "reference": "b3d4f4c0e4eadfdd8b296af9ca637cfbf51d8176" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/fa5d962a71f2169dfe1cbae217fa5a2799859f6c", - "reference": "fa5d962a71f2169dfe1cbae217fa5a2799859f6c", + "url": "https://api.github.com/repos/symfony/finder/zipball/b3d4f4c0e4eadfdd8b296af9ca637cfbf51d8176", + "reference": "b3d4f4c0e4eadfdd8b296af9ca637cfbf51d8176", "shasum": "" }, "require": { - "php": "^5.5.9|>=7.0.8" + "php": "^7.1.3" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "3.4-dev" + "dev-master": "4.3-dev" } }, "autoload": { @@ -1378,7 +1390,7 @@ ], "description": "Symfony Finder Component", "homepage": "https://symfony.com", - "time": "2019-05-24T12:25:55+00:00" + "time": "2019-05-26T20:47:49+00:00" }, { "name": "symfony/polyfill-ctype", @@ -1497,6 +1509,64 @@ ], "time": "2019-02-06T07:57:58+00:00" }, + { + "name": "symfony/polyfill-php73", + "version": "v1.11.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php73.git", + "reference": "d1fb4abcc0c47be136208ad9d68bf59f1ee17abd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php73/zipball/d1fb4abcc0c47be136208ad9d68bf59f1ee17abd", + "reference": "d1fb4abcc0c47be136208ad9d68bf59f1ee17abd", + "shasum": "" + }, + "require": { + "php": ">=5.3.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.11-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Polyfill\\Php73\\": "" + }, + "files": [ + "bootstrap.php" + ], + "classmap": [ + "Resources/stubs" + ] + }, + "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": "Symfony polyfill backporting some PHP 7.3+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "time": "2019-02-06T07:57:58+00:00" + }, { "name": "symfony/process", "version": "v3.4.28", @@ -1547,21 +1617,79 @@ "time": "2019-05-22T12:54:11+00:00" }, { - "name": "symfony/yaml", - "version": "v3.4.28", + "name": "symfony/service-contracts", + "version": "v1.1.5", "source": { "type": "git", - "url": "https://github.com/symfony/yaml.git", - "reference": "212a27b731e5bfb735679d1ffaac82bd6a1dc996" + "url": "https://github.com/symfony/service-contracts.git", + "reference": "f391a00de78ec7ec8cf5cdcdae59ec7b883edb8d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/212a27b731e5bfb735679d1ffaac82bd6a1dc996", - "reference": "212a27b731e5bfb735679d1ffaac82bd6a1dc996", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/f391a00de78ec7ec8cf5cdcdae59ec7b883edb8d", + "reference": "f391a00de78ec7ec8cf5cdcdae59ec7b883edb8d", "shasum": "" }, "require": { - "php": "^5.5.9|>=7.0.8", + "php": "^7.1.3", + "psr/container": "^1.0" + }, + "suggest": { + "symfony/service-implementation": "" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.1-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Service\\": "" + } + }, + "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": "Generic abstractions related to writing services", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "time": "2019-06-13T11:15:36+00:00" + }, + { + "name": "symfony/yaml", + "version": "v4.3.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/yaml.git", + "reference": "c60ecf5ba842324433b46f58dc7afc4487dbab99" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/yaml/zipball/c60ecf5ba842324433b46f58dc7afc4487dbab99", + "reference": "c60ecf5ba842324433b46f58dc7afc4487dbab99", + "shasum": "" + }, + "require": { + "php": "^7.1.3", "symfony/polyfill-ctype": "~1.8" }, "conflict": { @@ -1576,7 +1704,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "3.4-dev" + "dev-master": "4.3-dev" } }, "autoload": { @@ -1603,7 +1731,7 @@ ], "description": "Symfony Yaml Component", "homepage": "https://symfony.com", - "time": "2019-03-25T07:48:46+00:00" + "time": "2019-04-06T14:04:46+00:00" } ], "packages-dev": [], From 92b1626dbacbea77a40be243b7fe0619e058f285 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Fri, 21 Jun 2019 12:00:23 -0400 Subject: [PATCH 121/142] Remove most unused features of the query builder Experience has proved programmatically setting joins is not useful, and getting the types and values of query parts was not being maintained. The programmatic setting of GROUP BY may be useful in future, however. --- lib/Misc/Query.php | 71 ++++------------------------------------------ 1 file changed, 6 insertions(+), 65 deletions(-) diff --git a/lib/Misc/Query.php b/lib/Misc/Query.php index 5a1b0b89..55d2cac8 100644 --- a/lib/Misc/Query.php +++ b/lib/Misc/Query.php @@ -13,10 +13,6 @@ class Query { protected $qCTE = []; // Common table expression query components protected $tCTE = []; // Common table expression type bindings protected $vCTE = []; // Common table expression binding values - protected $jCTE = []; // Common Table Expression joins - protected $qJoin = []; // JOIN clause components - protected $tJoin = []; // JOIN clause type bindings - protected $vJoin = []; // JOIN clause binding values protected $qWhere = []; // WHERE clause components protected $tWhere = []; // WHERE clause type bindings protected $vWhere = []; // WHERE clause binding values @@ -42,24 +38,12 @@ class Query { return true; } - public function setCTE(string $tableSpec, string $body, $types = null, $values = null, string $join = ''): bool { + public function setCTE(string $tableSpec, string $body, $types = null, $values = null): bool { $this->qCTE[] = "$tableSpec as ($body)"; if (!is_null($types)) { $this->tCTE[] = $types; $this->vCTE[] = $values; } - if (strlen($join)) { // the CTE might only participate in subqueries rather than a join on the main query - $this->jCTE[] = $join; - } - return true; - } - - public function setJoin(string $join, $types = null, $values = null): bool { - $this->qJoin[] = $join; - if (!is_null($types)) { - $this->tJoin[] = $types; - $this->vJoin[] = $values; - } return true; } @@ -88,12 +72,8 @@ class Query { return true; } - public function setOrder(string $order, bool $prepend = false): bool { - if ($prepend) { - array_unshift($this->order, $order); - } else { - $this->order[] = $order; - } + public function setOrder(string $order): bool { + $this->order[] = $order; return true; } @@ -103,11 +83,10 @@ class Query { return true; } - public function pushCTE(string $tableSpec, string $join = ''): bool { + public function pushCTE(string $tableSpec): bool { // this function takes the query body and converts it to a common table expression, putting it at the bottom of the existing CTE stack // all WHERE, ORDER BY, and LIMIT parts belong to the new CTE and are removed from the main query $this->setCTE($tableSpec, $this->buildQueryBody(), [$this->tBody, $this->tWhere, $this->tWhereNot], [$this->vBody, $this->vWhere, $this->vWhereNot]); - $this->jCTE = []; $this->tBody = []; $this->vBody = []; $this->qWhere = []; @@ -116,15 +95,9 @@ class Query { $this->qWhereNot = []; $this->tWhereNot = []; $this->vWhereNot = []; - $this->qJoin = []; - $this->tJoin = []; - $this->vJoin = []; $this->order = []; $this->group = []; $this->setLimit(0, 0); - if (strlen($join)) { - $this->jCTE[] = $join; - } return true; } @@ -144,49 +117,17 @@ class Query { } public function getTypes(): array { - return [$this->tCTE, $this->tBody, $this->tJoin, $this->tWhere, $this->tWhereNot]; + return [$this->tCTE, $this->tBody, $this->tWhere, $this->tWhereNot]; } public function getValues(): array { - return [$this->vCTE, $this->vBody, $this->vJoin, $this->vWhere, $this->vWhereNot]; - } - - public function getJoinTypes(): array { - return $this->tJoin; - } - - public function getJoinValues(): array { - return $this->vJoin; - } - - public function getWhereTypes(): array { - return $this->tWhere; - } - - public function getWhereValues(): array { - return $this->vWhere; - } - - public function getCTETypes(): array { - return $this->tCTE; - } - - public function getCTEValues(): array { - return $this->vCTE; + return [$this->vCTE, $this->vBody, $this->vWhere, $this->vWhereNot]; } protected function buildQueryBody(): string { $out = ""; // add the body $out .= $this->qBody; - if (sizeof($this->qCTE)) { - // add any joins against CTEs - $out .= " ".implode(" ", $this->jCTE); - } - // add any JOINs - if (sizeof($this->qJoin)) { - $out .= " ".implode(" ", $this->qJoin); - } // add any WHERE terms if (sizeof($this->qWhere) || sizeof($this->qWhereNot)) { $where = implode(" AND ", $this->qWhere); From 7046ce163c1dc9e13db3568126821cbf8b3eaa03 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Fri, 21 Jun 2019 13:47:34 -0400 Subject: [PATCH 122/142] More format-neutral code out of OPML class --- lib/ImportExport/AbstractImportExport.php | 167 ++++++++++++++++++ lib/ImportExport/OPML.php | 150 +--------------- .../{TestOPMLFile.php => TestFile.php} | 30 ++-- tests/phpunit.xml | 5 +- 4 files changed, 186 insertions(+), 166 deletions(-) create mode 100644 lib/ImportExport/AbstractImportExport.php rename tests/cases/ImportExport/{TestOPMLFile.php => TestFile.php} (85%) diff --git a/lib/ImportExport/AbstractImportExport.php b/lib/ImportExport/AbstractImportExport.php new file mode 100644 index 00000000..54452301 --- /dev/null +++ b/lib/ImportExport/AbstractImportExport.php @@ -0,0 +1,167 @@ +exists($user)) { + throw new UserException("doesNotExist", ["action" => __FUNCTION__, "user" => $user]); + } + // first extract useful information from the input + list($feeds, $folders) = $this->parse($data, $flat); + $folderMap = []; + foreach ($folders as $f) { + // check to make sure folder names are all valid + if (!strlen(trim($f['name']))) { + throw new Exception("invalidFolderName"); + } + // check for duplicates + if (!isset($folderMap[$f['parent']])) { + $folderMap[$f['parent']] = []; + } + if (isset($folderMap[$f['parent']][$f['name']])) { + throw new Exception("invalidFolderCopy"); + } else { + $folderMap[$f['parent']][$f['name']] = true; + } + } + // get feed IDs for each URL, adding feeds where necessary + foreach ($feeds as $k => $f) { + $feeds[$k]['id'] = Arsse::$db->feedAdd(($f['url'])); + } + // start a transaction for atomic rollback + $tr = Arsse::$db->begin(); + // get current state of database + $foldersDb = iterator_to_array(Arsse::$db->folderList($user)); + $feedsDb = iterator_to_array(Arsse::$db->subscriptionList($user)); + $tagsDb = iterator_to_array(Arsse::$db->tagList($user)); + // reconcile folders + $folderMap = [0 => 0]; + foreach ($folders as $id => $f) { + $parent = $folderMap[$f['parent']]; + // find a match for the import folder in the existing folders + foreach ($foldersDb as $db) { + if ((int) $db['parent'] == $parent && $db['name'] === $f['name']) { + $folderMap[$id] = (int) $db['id']; + break; + } + } + if (!isset($folderMap[$id])) { + // if no existing folder exists, add one + $folderMap[$id] = Arsse::$db->folderAdd($user, ['name' => $f['name'], 'parent' -> $parent]); + } + } + // process newsfeed subscriptions + $feedMap = []; + $tagMap = []; + foreach ($feeds as $f) { + $folder = $folderMap[$f['folder']]; + $title = strlen(trim($f['title'])) ? $f['title'] : null; + $found = false; + // find a match for the import feed is existing subscriptions + foreach ($feedsDb as $db) { + if ((int) $db['feed'] == $f['id']) { + $found = true; + $feedMap[$f['id']] = (int) $db['id']; + break; + } + } + if (!$found) { + // if no subscription exists, add one + $feedMap[$f['id']] = Arsse::$db->subscriptionAdd($user, $f['url']); + } + if (!$found || $replace) { + // set the subscription's properties, if this is a new feed or we're doing a full replacement + Arsse::$db->subscriptionPropertiesSet($user, $feedMap[$f['id']], ['title' => $title, 'folder' => $folder]); + // compile the set of used tags, if this is a new feed or we're doing a full replacement + foreach ($f['tags'] as $t) { + if (!strlen(trim($t))) { + // ignore any blank tags + continue; + } + if (!isset($tagMap[$t])) { + // populate the tag map + $tagMap[$t] = []; + } + $tagMap[$t][] = $f['id']; + } + } + } + // set tags + $mode = $replace ? Database::ASSOC_REPLACE : Database::ASSOC_ADD; + foreach ($tagMap as $tag => $subs) { + // make sure the tag exists + $found = false; + foreach ($tagsDb as $db) { + if ($tag === $db['name']) { + $found = true; + break; + } + } + if (!$found) { + // add the tag if it wasn't found + Arsse::$db->tagAdd($user, ['name' => $tag]); + } + Arsse::$db->tagSubscriptionsSet($user, $tag, $subs, $mode, true); + } + // finally, if we're performing a replacement, delete any subscriptions, folders, or tags which were not present in the import + if ($replace) { + foreach (array_diff(array_column($feedsDb, "id"), $feedMap) as $id) { + try { + Arsse::$db->subscriptionRemove($user, $id); + } catch (InputException $e) { + // ignore errors + } + } + foreach (array_diff(array_column($foldersDb, "id"), $folderMap) as $id) { + try { + Arsse::$db->folderRemove($user, $id); + } catch (InputException $e) { + // ignore errors + } + } + foreach (array_diff(array_column($tagsDb, "name"), array_keys($tagMap)) as $id) { + try { + Arsse::$db->tagRemove($user, $id, true); + } catch (InputException $e) { + // ignore errors + } + } + } + $tr->commit(); + return true; + } + + abstract public function parse(string $data, bool $flat): array; + + abstract public function export(string $user, bool $flat = false): string; + + public function exportFile(string $file, string $user, bool $flat = false): bool { + $data = $this->export($user, $flat); + if (!@file_put_contents($file, $data)) { + // if it fails throw an exception + $err = file_exists($file) ? "fileUnwritable" : "fileUncreatable"; + throw new Exception($err, ['file' => $file, 'format' => str_replace(__NAMESPACE__."\\", "", get_class($this))]); + } + return true; + } + + public function importFile(string $file, string $user, bool $flat = false, bool $replace): bool { + $data = @file_get_contents($file); + if ($data === false) { + // if it fails throw an exception + $err = file_exists($file) ? "fileUnreadable" : "fileMissing"; + throw new Exception($err, ['file' => $file, 'format' => str_replace(__NAMESPACE__."\\", "", get_class($this))]); + } + return $this->import($user, $data, $flat, $replace); + } +} diff --git a/lib/ImportExport/OPML.php b/lib/ImportExport/OPML.php index 5c633b4b..92252696 100644 --- a/lib/ImportExport/OPML.php +++ b/lib/ImportExport/OPML.php @@ -7,137 +7,9 @@ declare(strict_types=1); 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; -class OPML { - public function import(string $user, string $opml, bool $flat = false, bool $replace = false): bool { - // first extract useful information from the input - list($feeds, $folders) = $this->parse($opml, $flat); - $folderMap = []; - foreach ($folders as $f) { - // check to make sure folder names are all valid - if (!strlen(trim($f['name']))) { - throw new Exception("invalidFolderName"); - } - // check for duplicates - if (!isset($folderMap[$f['parent']])) { - $folderMap[$f['parent']] = []; - } - if (isset($folderMap[$f['parent']][$f['name']])) { - throw new Exception("invalidFolderCopy"); - } else { - $folderMap[$f['parent']][$f['name']] = true; - } - } - // get feed IDs for each URL, adding feeds where necessary - foreach ($feeds as $k => $f) { - $feeds[$k]['id'] = Arsse::$db->feedAdd(($f['url'])); - } - // start a transaction for atomic rollback - $tr = Arsse::$db->begin(); - // get current state of database - $foldersDb = iterator_to_array(Arsse::$db->folderList($user)); - $feedsDb = iterator_to_array(Arsse::$db->subscriptionList($user)); - $tagsDb = iterator_to_array(Arsse::$db->tagList($user)); - // reconcile folders - $folderMap = [0 => 0]; - foreach ($folders as $id => $f) { - $parent = $folderMap[$f['parent']]; - // find a match for the import folder in the existing folders - foreach ($foldersDb as $db) { - if ((int) $db['parent'] == $parent && $db['name'] === $f['name']) { - $folderMap[$id] = (int) $db['id']; - break; - } - } - if (!isset($folderMap[$id])) { - // if no existing folder exists, add one - $folderMap[$id] = Arsse::$db->folderAdd($user, ['name' => $f['name'], 'parent' -> $parent]); - } - } - // process newsfeed subscriptions - $feedMap = []; - $tagMap = []; - foreach ($feeds as $f) { - $folder = $folderMap[$f['folder']]; - $title = strlen(trim($f['title'])) ? $f['title'] : null; - $found = false; - // find a match for the import feed is existing subscriptions - foreach ($feedsDb as $db) { - if ((int) $db['feed'] == $f['id']) { - $found = true; - $feedMap[$f['id']] = (int) $db['id']; - break; - } - } - if (!$found) { - // if no subscription exists, add one - $feedMap[$f['id']] = Arsse::$db->subscriptionAdd($user, $f['url']); - } - if (!$found || $replace) { - // set the subscription's properties, if this is a new feed or we're doing a full replacement - Arsse::$db->subscriptionPropertiesSet($user, $feedMap[$f['id']], ['title' => $title, 'folder' => $folder]); - // compile the set of used tags, if this is a new feed or we're doing a full replacement - foreach ($f['tags'] as $t) { - if (!strlen(trim($t))) { - // ignore any blank tags - continue; - } - if (!isset($tagMap[$t])) { - // populate the tag map - $tagMap[$t] = []; - } - $tagMap[$t][] = $f['id']; - } - } - } - // set tags - $mode = $replace ? Database::ASSOC_REPLACE : Database::ASSOC_ADD; - foreach ($tagMap as $tag => $subs) { - // make sure the tag exists - $found = false; - foreach ($tagsDb as $db) { - if ($tag === $db['name']) { - $found = true; - break; - } - } - if (!$found) { - // add the tag if it wasn't found - Arsse::$db->tagAdd($user, ['name' => $tag]); - } - Arsse::$db->tagSubscriptionsSet($user, $tag, $subs, $mode, true); - } - // finally, if we're performing a replacement, delete any subscriptions, folders, or tags which were not present in the import - if ($replace) { - foreach (array_diff(array_column($feedsDb, "id"), $feedMap) as $id) { - try { - Arsse::$db->subscriptionRemove($user, $id); - } catch (InputException $e) { - // ignore errors - } - } - foreach (array_diff(array_column($foldersDb, "id"), $folderMap) as $id) { - try { - Arsse::$db->folderRemove($user, $id); - } catch (InputException $e) { - // ignore errors - } - } - foreach (array_diff(array_column($tagsDb, "name"), array_keys($tagMap)) as $id) { - try { - Arsse::$db->tagRemove($user, $id, true); - } catch (InputException $e) { - // ignore errors - } - } - } - $tr->commit(); - return true; - } - +class OPML extends AbstractImportExport { public function parse(string $opml, bool $flat): array { $d = new \DOMDocument; if (!@$d->loadXML($opml)) { @@ -276,24 +148,4 @@ class OPML { // return the serialization return $document->saveXML(); } - - public function exportFile(string $file, string $user, bool $flat = false): bool { - $data = $this->export($user, $flat); - if (!@file_put_contents($file, $data)) { - // if it fails throw an exception - $err = file_exists($file) ? "fileUnwritable" : "fileUncreatable"; - throw new Exception($err, ['file' => $file, 'format' => str_replace(__NAMESPACE__."\\", "", __CLASS__)]); - } - return true; - } - - public function importFile(string $file, string $user, bool $flat = false, bool $replace): bool { - $data = @file_get_contents($file); - if ($data === false) { - // if it fails throw an exception - $err = file_exists($file) ? "fileUnreadable" : "fileMissing"; - throw new Exception($err, ['file' => $file, 'format' => str_replace(__NAMESPACE__."\\", "", __CLASS__)]); - } - return $this->import($user, $data, $flat, $replace); - } } diff --git a/tests/cases/ImportExport/TestOPMLFile.php b/tests/cases/ImportExport/TestFile.php similarity index 85% rename from tests/cases/ImportExport/TestOPMLFile.php rename to tests/cases/ImportExport/TestFile.php index 35147ef8..be2ebef6 100644 --- a/tests/cases/ImportExport/TestOPMLFile.php +++ b/tests/cases/ImportExport/TestFile.php @@ -10,24 +10,24 @@ use JKingWeb\Arsse\ImportExport\OPML; use JKingWeb\Arsse\ImportExport\Exception; use org\bovigo\vfs\vfsStream; -/** @covers \JKingWeb\Arsse\ImportExport\OPML */ -class TestOPMLFile extends \JKingWeb\Arsse\Test\AbstractTest { +/** @covers \JKingWeb\Arsse\ImportExport\AbstractImportExport */ +class TestFile extends \JKingWeb\Arsse\Test\AbstractTest { protected $vfs; protected $path; - protected $opml; + protected $proc; public function setUp() { self::clearData(); // create a mock OPML processor with stubbed underlying import/export routines - $this->opml = \Phake::partialMock(OPML::class); - \Phake::when($this->opml)->export->thenReturn("OPML_FILE"); - \Phake::when($this->opml)->import->thenReturn(true); + $this->proc = \Phake::partialMock(OPML::class); + \Phake::when($this->proc)->export->thenReturn("EXPORT_FILE"); + \Phake::when($this->proc)->import->thenReturn(true); $this->vfs = vfsStream::setup("root", null, [ 'exportGoodFile' => "", 'exportGoodDir' => [], 'exportBadFile' => "", 'exportBadDir' => [], - 'importGoodFile' => "", + 'importGoodFile' => "GOOD_FILE", 'importBadFile' => "", ]); $this->path = $this->vfs->url()."/"; @@ -40,7 +40,7 @@ class TestOPMLFile extends \JKingWeb\Arsse\Test\AbstractTest { public function tearDown() { $this->path = null; $this->vfs = null; - $this->opml = null; + $this->proc = null; self::clearData(); } @@ -50,13 +50,13 @@ class TestOPMLFile extends \JKingWeb\Arsse\Test\AbstractTest { try { if ($exp instanceof \JKingWeb\Arsse\AbstractException) { $this->assertException($exp); - $this->opml->exportFile($path, $user, $flat); + $this->proc->exportFile($path, $user, $flat); } else { - $this->assertSame($exp, $this->opml->exportFile($path, $user, $flat)); - $this->assertSame("OPML_FILE", $this->vfs->getChild($file)->getContent()); + $this->assertSame($exp, $this->proc->exportFile($path, $user, $flat)); + $this->assertSame("EXPORT_FILE", $this->vfs->getChild($file)->getContent()); } } finally { - \Phake::verify($this->opml)->export($user, $flat); + \Phake::verify($this->proc)->export($user, $flat); } } @@ -89,12 +89,12 @@ class TestOPMLFile extends \JKingWeb\Arsse\Test\AbstractTest { try { if ($exp instanceof \JKingWeb\Arsse\AbstractException) { $this->assertException($exp); - $this->opml->importFile($path, $user, $flat, $replace); + $this->proc->importFile($path, $user, $flat, $replace); } else { - $this->assertSame($exp, $this->opml->importFile($path, $user, $flat, $replace)); + $this->assertSame($exp, $this->proc->importFile($path, $user, $flat, $replace)); } } finally { - \Phake::verify($this->opml, \Phake::times((int) ($exp === true)))->import($user, "", $flat, $replace); + \Phake::verify($this->proc, \Phake::times((int) ($exp === true)))->import($user, "GOOD_FILE", $flat, $replace); } } diff --git a/tests/phpunit.xml b/tests/phpunit.xml index 6ad94f32..fb753f3e 100644 --- a/tests/phpunit.xml +++ b/tests/phpunit.xml @@ -7,7 +7,8 @@ convertWarningsToExceptions="false" beStrictAboutTestsThatDoNotTestAnything="true" beStrictAboutOutputDuringTests="true" - stopOnError="true"> + forceCoversAnnotation="true" +> @@ -114,8 +115,8 @@ cases/CLI/TestCLI.php + cases/ImportExport/TestFile.php cases/ImportExport/TestOPML.php - cases/ImportExport/TestOPMLFile.php From 12ef3e649fc19f01ca9ba80c4669b99ce3b7619c Mon Sep 17 00:00:00 2001 From: "J. King" Date: Fri, 21 Jun 2019 13:55:49 -0400 Subject: [PATCH 123/142] Mock AbstractImportExport directly --- tests/cases/ImportExport/TestFile.php | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/cases/ImportExport/TestFile.php b/tests/cases/ImportExport/TestFile.php index be2ebef6..5a85bb69 100644 --- a/tests/cases/ImportExport/TestFile.php +++ b/tests/cases/ImportExport/TestFile.php @@ -6,7 +6,7 @@ declare(strict_types=1); namespace JKingWeb\Arsse\TestCase\ImportExport; -use JKingWeb\Arsse\ImportExport\OPML; +use JKingWeb\Arsse\ImportExport\AbstractImportExport; use JKingWeb\Arsse\ImportExport\Exception; use org\bovigo\vfs\vfsStream; @@ -18,8 +18,8 @@ class TestFile extends \JKingWeb\Arsse\Test\AbstractTest { public function setUp() { self::clearData(); - // create a mock OPML processor with stubbed underlying import/export routines - $this->proc = \Phake::partialMock(OPML::class); + // create a mock Import/Export processor with stubbed underlying import/export routines + $this->proc = \Phake::partialMock(AbstractImportExport::class); \Phake::when($this->proc)->export->thenReturn("EXPORT_FILE"); \Phake::when($this->proc)->import->thenReturn(true); $this->vfs = vfsStream::setup("root", null, [ @@ -45,7 +45,7 @@ class TestFile extends \JKingWeb\Arsse\Test\AbstractTest { } /** @dataProvider provideFileExports */ - public function testExportOpmlToAFile(string $file, string $user, bool $flat, $exp) { + public function testExportToAFile(string $file, string $user, bool $flat, $exp) { $path = $this->path.$file; try { if ($exp instanceof \JKingWeb\Arsse\AbstractException) { @@ -84,7 +84,7 @@ class TestFile extends \JKingWeb\Arsse\Test\AbstractTest { } /** @dataProvider provideFileImports */ - public function testImportFromOpmlFile(string $file, string $user, bool $flat, bool $replace, $exp) { + public function testImportFromAFile(string $file, string $user, bool $flat, bool $replace, $exp) { $path = $this->path.$file; try { if ($exp instanceof \JKingWeb\Arsse\AbstractException) { From 2628ff7bf4589eb52de33e97228ff5ff1c891415 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Fri, 21 Jun 2019 18:52:27 -0400 Subject: [PATCH 124/142] Make database test helpers generic --- tests/cases/Database/Base.php | 123 +------------------- tests/cases/Database/SeriesArticle.php | 58 ++++----- tests/cases/Database/SeriesCleanup.php | 16 +-- tests/cases/Database/SeriesFeed.php | 8 +- tests/cases/Database/SeriesFolder.php | 12 +- tests/cases/Database/SeriesLabel.php | 26 ++--- tests/cases/Database/SeriesMeta.php | 10 +- tests/cases/Database/SeriesSession.php | 6 +- tests/cases/Database/SeriesSubscription.php | 16 +-- tests/cases/Database/SeriesTag.php | 26 ++--- tests/cases/Database/SeriesToken.php | 12 +- tests/cases/Database/SeriesUser.php | 6 +- tests/lib/AbstractTest.php | 121 ++++++++++++++++++- 13 files changed, 218 insertions(+), 222 deletions(-) diff --git a/tests/cases/Database/Base.php b/tests/cases/Database/Base.php index de6d39e2..537002f7 100644 --- a/tests/cases/Database/Base.php +++ b/tests/cases/Database/Base.php @@ -8,11 +8,7 @@ namespace JKingWeb\Arsse\TestCase\Database; use JKingWeb\Arsse\Test\Database; use JKingWeb\Arsse\Arsse; -use JKingWeb\Arsse\Conf; use JKingWeb\Arsse\User; -use JKingWeb\Arsse\Misc\ValueInfo; -use JKingWeb\Arsse\Db\Result; -use JKingWeb\Arsse\Test\DatabaseInformation; use Phake; abstract class Base extends \JKingWeb\Arsse\Test\AbstractTest { @@ -84,7 +80,7 @@ abstract class Base extends \JKingWeb\Arsse\Test\AbstractTest { $this->$setUp(); // prime the database with series data if it hasn't already been done if (!$this->primed && isset($this->data)) { - $this->primeDatabase($this->data); + $this->primeDatabase(static::$drv, $this->data); } } @@ -111,121 +107,4 @@ abstract class Base extends \JKingWeb\Arsse\Test\AbstractTest { static::$failureReason = ""; static::clearData(); } - - public function primeDatabase(array $data): bool { - $drv = static::$drv; - $tr = $drv->begin(); - foreach ($data as $table => $info) { - $cols = array_map(function($v) { - return '"'.str_replace('"', '""', $v).'"'; - }, array_keys($info['columns'])); - $cols = implode(",", $cols); - $bindings = array_values($info['columns']); - $params = implode(",", array_fill(0, sizeof($info['columns']), "?")); - $s = $drv->prepareArray("INSERT INTO $table($cols) values($params)", $bindings); - foreach ($info['rows'] as $row) { - $s->runArray($row); - } - } - $tr->commit(); - $this->primed = true; - return true; - } - - public function compareExpectations(array $expected): bool { - foreach ($expected as $table => $info) { - $cols = array_map(function($v) { - return '"'.str_replace('"', '""', $v).'"'; - }, array_keys($info['columns'])); - $cols = implode(",", $cols); - $types = $info['columns']; - $data = static::$drv->prepare("SELECT $cols from $table")->run()->getAll(); - $cols = array_keys($info['columns']); - foreach ($info['rows'] as $index => $row) { - $this->assertCount(sizeof($cols), $row, "The number of values for array index $index does not match the number of fields"); - $row = array_combine($cols, $row); - foreach ($data as $index => $test) { - foreach ($test as $col => $value) { - switch ($types[$col]) { - case "datetime": - $test[$col] = $this->approximateTime($row[$col], $value); - break; - case "int": - $test[$col] = ValueInfo::normalize($value, ValueInfo::T_INT | ValueInfo::M_DROP | valueInfo::M_NULL); - break; - case "float": - $test[$col] = ValueInfo::normalize($value, ValueInfo::T_FLOAT | ValueInfo::M_DROP | valueInfo::M_NULL); - break; - case "bool": - $test[$col] = (int) ValueInfo::normalize($value, ValueInfo::T_BOOL | ValueInfo::M_DROP | valueInfo::M_NULL); - break; - } - } - if ($row===$test) { - $data[$index] = $test; - break; - } - } - $this->assertContains($row, $data, "Table $table does not contain record at array index $index."); - $found = array_search($row, $data, true); - unset($data[$found]); - } - $this->assertSame([], $data); - } - return true; - } - - public function primeExpectations(array $source, array $tableSpecs = null): array { - $out = []; - foreach ($tableSpecs as $table => $columns) { - // make sure the source has the table we want - $this->assertArrayHasKey($table, $source, "Source for expectations does not contain requested table $table."); - $out[$table] = [ - 'columns' => [], - 'rows' => array_fill(0, sizeof($source[$table]['rows']), []), - ]; - // make sure the source has all the columns we want for the table - $cols = array_flip($columns); - $cols = array_intersect_key($cols, $source[$table]['columns']); - $this->assertSame(array_keys($cols), $columns, "Source for table $table does not contain all requested columns"); - // get a map of source value offsets and keys - $targets = array_flip(array_keys($source[$table]['columns'])); - foreach ($cols as $key => $order) { - // fill the column-spec - $out[$table]['columns'][$key] = $source[$table]['columns'][$key]; - foreach ($source[$table]['rows'] as $index => $row) { - // fill each row column-wise with re-ordered values - $out[$table]['rows'][$index][$order] = $row[$targets[$key]]; - } - } - } - return $out; - } - - public function assertResult(array $expected, Result $data) { - $data = $data->getAll(); - $this->assertCount(sizeof($expected), $data, "Number of result rows (".sizeof($data).") differs from number of expected rows (".sizeof($expected).")"); - if (sizeof($expected)) { - // make sure the expectations are consistent - foreach ($expected as $exp) { - if (!isset($keys)) { - $keys = $exp; - continue; - } - $this->assertSame(array_keys($keys), array_keys($exp), "Result set expectations are irregular"); - } - // filter the result set to contain just the desired keys (we don't care if the result has extra keys) - $rows = []; - foreach ($data as $row) { - $rows[] = array_intersect_key($row, $keys); - } - // compare the result set to the expectations - foreach ($rows as $row) { - $this->assertContains($row, $expected, "Result set contains unexpected record."); - $found = array_search($row, $expected); - unset($expected[$found]); - } - $this->assertArraySubset($expected, [], false, "Expectations not in result set."); - } - } } diff --git a/tests/cases/Database/SeriesArticle.php b/tests/cases/Database/SeriesArticle.php index 048cd184..2deaeb75 100644 --- a/tests/cases/Database/SeriesArticle.php +++ b/tests/cases/Database/SeriesArticle.php @@ -581,7 +581,7 @@ trait SeriesArticle { $state['arsse_marks']['rows'][9][4] = $now; $state['arsse_marks']['rows'][11][2] = 0; $state['arsse_marks']['rows'][11][4] = $now; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testMarkAllArticlesRead() { @@ -596,7 +596,7 @@ trait SeriesArticle { $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,'']; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testMarkAllArticlesUnstarred() { @@ -607,7 +607,7 @@ trait SeriesArticle { $state['arsse_marks']['rows'][10][4] = $now; $state['arsse_marks']['rows'][11][3] = 0; $state['arsse_marks']['rows'][11][4] = $now; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testMarkAllArticlesStarred() { @@ -622,7 +622,7 @@ trait SeriesArticle { $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,'']; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testMarkAllArticlesUnreadAndUnstarred() { @@ -636,7 +636,7 @@ trait SeriesArticle { $state['arsse_marks']['rows'][11][2] = 0; $state['arsse_marks']['rows'][11][3] = 0; $state['arsse_marks']['rows'][11][4] = $now; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testMarkAllArticlesReadAndStarred() { @@ -654,7 +654,7 @@ trait SeriesArticle { $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,'']; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testMarkAllArticlesUnreadAndStarred() { @@ -672,7 +672,7 @@ trait SeriesArticle { $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,'']; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testMarkAllArticlesReadAndUnstarred() { @@ -690,7 +690,7 @@ trait SeriesArticle { $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,'']; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testSetNoteForAllArticles() { @@ -709,7 +709,7 @@ trait SeriesArticle { $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']; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testMarkATreeFolder() { @@ -720,7 +720,7 @@ trait SeriesArticle { $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,'']; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testMarkALeafFolder() { @@ -729,7 +729,7 @@ trait SeriesArticle { $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,'']; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testMarkAMissingFolder() { @@ -743,7 +743,7 @@ trait SeriesArticle { $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,'']; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testMarkAMissingSubscription() { @@ -757,7 +757,7 @@ trait SeriesArticle { $state = $this->primeExpectations($this->data, $this->checkTables); $state['arsse_marks']['rows'][9][3] = 1; $state['arsse_marks']['rows'][9][4] = $now; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testMarkMultipleArticles() { @@ -767,7 +767,7 @@ trait SeriesArticle { $state['arsse_marks']['rows'][9][3] = 1; $state['arsse_marks']['rows'][9][4] = $now; $state['arsse_marks']['rows'][] = [14,7,0,1,$now,'']; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testMarkMultipleArticlessUnreadAndStarred() { @@ -780,7 +780,7 @@ trait SeriesArticle { $state['arsse_marks']['rows'][11][2] = 0; $state['arsse_marks']['rows'][11][4] = $now; $state['arsse_marks']['rows'][] = [14,7,0,1,$now,'']; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testMarkTooFewMultipleArticles() { @@ -803,7 +803,7 @@ trait SeriesArticle { $state = $this->primeExpectations($this->data, $this->checkTables); $state['arsse_marks']['rows'][9][3] = 1; $state['arsse_marks']['rows'][9][4] = $now; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testMarkMultipleEditions() { @@ -813,13 +813,13 @@ trait SeriesArticle { $state['arsse_marks']['rows'][9][3] = 1; $state['arsse_marks']['rows'][9][4] = $now; $state['arsse_marks']['rows'][] = [14,7,0,1,$now,'']; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testMarkMultipleMissingEditions() { $this->assertSame(0, Arsse::$db->articleMark($this->user, ['starred'=>true], (new Context)->editions([500,501]))); $state = $this->primeExpectations($this->data, $this->checkTables); - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testMarkMultipleEditionsUnread() { @@ -830,7 +830,7 @@ trait SeriesArticle { $state['arsse_marks']['rows'][9][4] = $now; $state['arsse_marks']['rows'][11][2] = 0; $state['arsse_marks']['rows'][11][4] = $now; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testMarkMultipleEditionsUnreadWithStale() { @@ -839,7 +839,7 @@ trait SeriesArticle { $state = $this->primeExpectations($this->data, $this->checkTables); $state['arsse_marks']['rows'][11][2] = 0; $state['arsse_marks']['rows'][11][4] = $now; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testMarkMultipleEditionsUnreadAndStarredWithStale() { @@ -851,7 +851,7 @@ trait SeriesArticle { $state['arsse_marks']['rows'][11][2] = 0; $state['arsse_marks']['rows'][11][4] = $now; $state['arsse_marks']['rows'][] = [14,7,0,1,$now,'']; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testMarkTooFewMultipleEditions() { @@ -866,7 +866,7 @@ trait SeriesArticle { public function testMarkAStaleEditionUnread() { Arsse::$db->articleMark($this->user, ['read'=>false], (new Context)->edition(20)); // no changes occur $state = $this->primeExpectations($this->data, $this->checkTables); - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testMarkAStaleEditionStarred() { @@ -875,7 +875,7 @@ trait SeriesArticle { $state = $this->primeExpectations($this->data, $this->checkTables); $state['arsse_marks']['rows'][9][3] = 1; $state['arsse_marks']['rows'][9][4] = $now; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testMarkAStaleEditionUnreadAndStarred() { @@ -884,13 +884,13 @@ trait SeriesArticle { $state = $this->primeExpectations($this->data, $this->checkTables); $state['arsse_marks']['rows'][9][3] = 1; $state['arsse_marks']['rows'][9][4] = $now; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testMarkAStaleEditionUnreadAndUnstarred() { Arsse::$db->articleMark($this->user, ['read'=>false,'starred'=>false], (new Context)->edition(20)); // no changes occur $state = $this->primeExpectations($this->data, $this->checkTables); - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testMarkAMissingEdition() { @@ -906,7 +906,7 @@ trait SeriesArticle { $state['arsse_marks']['rows'][8][4] = $now; $state['arsse_marks']['rows'][9][3] = 1; $state['arsse_marks']['rows'][9][4] = $now; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testMarkByLatestEdition() { @@ -919,7 +919,7 @@ trait SeriesArticle { $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,'']; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testMarkByLastMarked() { @@ -930,7 +930,7 @@ trait SeriesArticle { $state['arsse_marks']['rows'][8][4] = $now; $state['arsse_marks']['rows'][9][3] = 1; $state['arsse_marks']['rows'][9][4] = $now; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testMarkByNotLastMarked() { @@ -939,7 +939,7 @@ trait SeriesArticle { $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,'']; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testMarkArticlesWithoutAuthority() { diff --git a/tests/cases/Database/SeriesCleanup.php b/tests/cases/Database/SeriesCleanup.php index 6d80a7eb..9d2e0097 100644 --- a/tests/cases/Database/SeriesCleanup.php +++ b/tests/cases/Database/SeriesCleanup.php @@ -161,7 +161,7 @@ trait SeriesCleanup { $state['arsse_feeds']['rows'][0][1] = null; unset($state['arsse_feeds']['rows'][1]); $state['arsse_feeds']['rows'][2][1] = $now; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testCleanUpOrphanedFeedsWithUnlimitedRetention() { @@ -175,7 +175,7 @@ trait SeriesCleanup { ]); $state['arsse_feeds']['rows'][0][1] = null; $state['arsse_feeds']['rows'][2][1] = $now; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testCleanUpOldArticlesWithStandardRetention() { @@ -186,7 +186,7 @@ trait SeriesCleanup { foreach ([7,8,9] as $id) { unset($state['arsse_articles']['rows'][$id - 1]); } - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testCleanUpOldArticlesWithUnlimitedReadRetention() { @@ -200,7 +200,7 @@ trait SeriesCleanup { foreach ([7,8] as $id) { unset($state['arsse_articles']['rows'][$id - 1]); } - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testCleanUpOldArticlesWithUnlimitedUnreadRetention() { @@ -214,7 +214,7 @@ trait SeriesCleanup { foreach ([9] as $id) { unset($state['arsse_articles']['rows'][$id - 1]); } - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testCleanUpOldArticlesWithUnlimitedRetention() { @@ -226,7 +226,7 @@ trait SeriesCleanup { $state = $this->primeExpectations($this->data, [ 'arsse_articles' => ["id"] ]); - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testCleanUpExpiredSessions() { @@ -237,7 +237,7 @@ trait SeriesCleanup { foreach ([3,4,5] as $id) { unset($state['arsse_sessions']['rows'][$id - 1]); } - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testCleanUpExpiredTokens() { @@ -248,6 +248,6 @@ trait SeriesCleanup { foreach ([2] as $id) { unset($state['arsse_tokens']['rows'][$id - 1]); } - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } } diff --git a/tests/cases/Database/SeriesFeed.php b/tests/cases/Database/SeriesFeed.php index a01f0644..8576bdf2 100644 --- a/tests/cases/Database/SeriesFeed.php +++ b/tests/cases/Database/SeriesFeed.php @@ -204,7 +204,7 @@ trait SeriesFeed { $state['arsse_marks']['rows'][3] = [6,4,0,0,$now]; $state['arsse_marks']['rows'][6] = [1,3,0,0,$now]; $state['arsse_feeds']['rows'][0] = [1,6]; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); // update a valid feed which previously had an error Arsse::$db->feedUpdate(2); // update an erroneous feed which previously had no errors @@ -214,12 +214,12 @@ trait SeriesFeed { ]); $state['arsse_feeds']['rows'][1] = [2,0,""]; $state['arsse_feeds']['rows'][2] = [3,1,'Feed URL "http://localhost:8000/Feed/Fetching/Error?code=404" is invalid']; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); // update the bad feed again, twice Arsse::$db->feedUpdate(3); Arsse::$db->feedUpdate(3); $state['arsse_feeds']['rows'][2] = [3,3,'Feed URL "http://localhost:8000/Feed/Fetching/Error?code=404" is invalid']; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testUpdateAMissingFeed() { @@ -254,7 +254,7 @@ trait SeriesFeed { ["Bodybuilders"], ["Men"], ]; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testListStaleFeeds() { diff --git a/tests/cases/Database/SeriesFolder.php b/tests/cases/Database/SeriesFolder.php index 9643b64b..367c0244 100644 --- a/tests/cases/Database/SeriesFolder.php +++ b/tests/cases/Database/SeriesFolder.php @@ -105,7 +105,7 @@ trait SeriesFolder { 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($state); + $this->compareExpectations(static::$drv, $state); } public function testAddADuplicateRootFolder() { @@ -120,7 +120,7 @@ trait SeriesFolder { 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($state); + $this->compareExpectations(static::$drv, $state); } public function testAddANestedFolderToAMissingParent() { @@ -218,7 +218,7 @@ trait SeriesFolder { 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($state); + $this->compareExpectations(static::$drv, $state); } public function testRemoveAFolderTree() { @@ -228,7 +228,7 @@ trait SeriesFolder { foreach ([0,1,2,5] as $index) { unset($state['arsse_folders']['rows'][$index]); } - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testRemoveAMissingFolder() { @@ -292,7 +292,7 @@ trait SeriesFolder { 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($state); + $this->compareExpectations(static::$drv, $state); } public function testRenameTheRootFolder() { @@ -319,7 +319,7 @@ trait SeriesFolder { 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($state); + $this->compareExpectations(static::$drv, $state); } public function testMoveTheRootFolder() { diff --git a/tests/cases/Database/SeriesLabel.php b/tests/cases/Database/SeriesLabel.php index 1f11004d..ec767e63 100644 --- a/tests/cases/Database/SeriesLabel.php +++ b/tests/cases/Database/SeriesLabel.php @@ -257,7 +257,7 @@ trait SeriesLabel { Phake::verify(Arsse::$user)->authorize($user, "labelAdd"); $state = $this->primeExpectations($this->data, $this->checkLabels); $state['arsse_labels']['rows'][] = [$labelID, $user, "Entertaining"]; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testAddADuplicateLabel() { @@ -313,7 +313,7 @@ trait SeriesLabel { 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($state); + $this->compareExpectations(static::$drv, $state); } public function testRemoveALabelByName() { @@ -321,7 +321,7 @@ trait SeriesLabel { 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($state); + $this->compareExpectations(static::$drv, $state); } public function testRemoveAMissingLabel() { @@ -397,7 +397,7 @@ trait SeriesLabel { 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($state); + $this->compareExpectations(static::$drv, $state); } public function testRenameALabelByName() { @@ -405,7 +405,7 @@ trait SeriesLabel { 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($state); + $this->compareExpectations(static::$drv, $state); } public function testRenameALabelToTheEmptyString() { @@ -487,14 +487,14 @@ trait SeriesLabel { $state = $this->primeExpectations($this->data, $this->checkMembers); $state['arsse_label_members']['rows'][4][3] = 1; $state['arsse_label_members']['rows'][] = [1,2,1,1]; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testClearALabelFromArticles() { Arsse::$db->labelArticlesSet("john.doe@example.com", 1, (new Context)->articles([1,5]), Database::ASSOC_REMOVE); $state = $this->primeExpectations($this->data, $this->checkMembers); $state['arsse_label_members']['rows'][0][3] = 0; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testApplyALabelToArticlesByName() { @@ -502,26 +502,26 @@ trait SeriesLabel { $state = $this->primeExpectations($this->data, $this->checkMembers); $state['arsse_label_members']['rows'][4][3] = 1; $state['arsse_label_members']['rows'][] = [1,2,1,1]; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testClearALabelFromArticlesByName() { Arsse::$db->labelArticlesSet("john.doe@example.com", "Interesting", (new Context)->articles([1,5]), Database::ASSOC_REMOVE, true); $state = $this->primeExpectations($this->data, $this->checkMembers); $state['arsse_label_members']['rows'][0][3] = 0; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testApplyALabelToNoArticles() { Arsse::$db->labelArticlesSet("john.doe@example.com", 1, (new Context)->articles([10000])); $state = $this->primeExpectations($this->data, $this->checkMembers); - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testClearALabelFromNoArticles() { Arsse::$db->labelArticlesSet("john.doe@example.com", 1, (new Context)->articles([10000]), Database::ASSOC_REMOVE); $state = $this->primeExpectations($this->data, $this->checkMembers); - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testReplaceArticlesOfALabel() { @@ -531,7 +531,7 @@ trait SeriesLabel { $state['arsse_label_members']['rows'][2][3] = 0; $state['arsse_label_members']['rows'][4][3] = 1; $state['arsse_label_members']['rows'][] = [1,2,1,1]; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testPurgeArticlesOfALabel() { @@ -539,7 +539,7 @@ trait SeriesLabel { $state = $this->primeExpectations($this->data, $this->checkMembers); $state['arsse_label_members']['rows'][0][3] = 0; $state['arsse_label_members']['rows'][2][3] = 0; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testApplyALabelToArticlesWithoutAuthority() { diff --git a/tests/cases/Database/SeriesMeta.php b/tests/cases/Database/SeriesMeta.php index 538700a3..485c7155 100644 --- a/tests/cases/Database/SeriesMeta.php +++ b/tests/cases/Database/SeriesMeta.php @@ -28,7 +28,7 @@ trait SeriesMeta { // as far as tests are concerned the schema version is part of the expectations primed into the database array_unshift($this->data['arsse_meta']['rows'], ['schema_version', "".Database::SCHEMA_VERSION]); // but it's already been inserted by the driver, so we prime without it - $this->primeDatabase($dataBare); + $this->primeDatabase(static::$drv, $dataBare); } protected function tearDownSeriesMeta() { @@ -39,7 +39,7 @@ trait SeriesMeta { $this->assertTrue(Arsse::$db->metaSet("favourite", "Cygnus X-1")); $state = $this->primeExpectations($this->data, ['arsse_meta' => ['key','value']]); $state['arsse_meta']['rows'][] = ["favourite","Cygnus X-1"]; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testAddANewTypedValue() { @@ -52,14 +52,14 @@ trait SeriesMeta { $state['arsse_meta']['rows'][] = ["true","1"]; $state['arsse_meta']['rows'][] = ["false","0"]; $state['arsse_meta']['rows'][] = ["millennium","2000-01-01 00:00:00"]; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testChangeAnExistingValue() { $this->assertTrue(Arsse::$db->metaSet("album", "Hemispheres")); $state = $this->primeExpectations($this->data, ['arsse_meta' => ['key','value']]); $state['arsse_meta']['rows'][1][1] = "Hemispheres"; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testRemoveAValue() { @@ -67,7 +67,7 @@ trait SeriesMeta { $this->assertFalse(Arsse::$db->metaRemove("album")); $state = $this->primeExpectations($this->data, ['arsse_meta' => ['key','value']]); unset($state['arsse_meta']['rows'][1]); - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testRetrieveAValue() { diff --git a/tests/cases/Database/SeriesSession.php b/tests/cases/Database/SeriesSession.php index 74a809c1..ad9a45b2 100644 --- a/tests/cases/Database/SeriesSession.php +++ b/tests/cases/Database/SeriesSession.php @@ -69,7 +69,7 @@ trait SeriesSession { // sessions near timeout should be refreshed automatically $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($state); + $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")); @@ -96,7 +96,7 @@ trait SeriesSession { $now = time(); $state = $this->primeExpectations($this->data, ['arsse_sessions' => ["id", "created", "expires", "user"]]); $state['arsse_sessions']['rows'][] = [$id, Date::transform($now, "sql"), Date::transform(Date::add(Arsse::$conf->userSessionTimeout, $now), "sql"), $user]; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testCreateASessionWithoutAuthority() { @@ -111,7 +111,7 @@ trait SeriesSession { $this->assertTrue(Arsse::$db->sessionDestroy($user, $id)); $state = $this->primeExpectations($this->data, ['arsse_sessions' => ["id", "created", "expires", "user"]]); unset($state['arsse_sessions']['rows'][0]); - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); // destroying a session which does not exist is not an error $this->assertFalse(Arsse::$db->sessionDestroy($user, $id)); } diff --git a/tests/cases/Database/SeriesSubscription.php b/tests/cases/Database/SeriesSubscription.php index d65fb3eb..be06be86 100644 --- a/tests/cases/Database/SeriesSubscription.php +++ b/tests/cases/Database/SeriesSubscription.php @@ -160,7 +160,7 @@ trait SeriesSubscription { 'arsse_subscriptions' => ['id','owner','feed'], ]); $state['arsse_subscriptions']['rows'][] = [$subID,$this->user,1]; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testAddASubscriptionToANewFeed() { @@ -177,7 +177,7 @@ trait SeriesSubscription { ]); $state['arsse_feeds']['rows'][] = [$feedID,$url,"",""]; $state['arsse_subscriptions']['rows'][] = [$subID,$this->user,$feedID]; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testAddASubscriptionToANewFeedViaDiscovery() { @@ -195,7 +195,7 @@ trait SeriesSubscription { ]); $state['arsse_feeds']['rows'][] = [$feedID,$discovered,"",""]; $state['arsse_subscriptions']['rows'][] = [$subID,$this->user,$feedID]; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testAddASubscriptionToAnInvalidFeed() { @@ -211,7 +211,7 @@ trait SeriesSubscription { 'arsse_feeds' => ['id','url','username','password'], 'arsse_subscriptions' => ['id','owner','feed'], ]); - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); $this->assertException("invalidUrl", "Feed"); throw $e; } @@ -238,7 +238,7 @@ trait SeriesSubscription { 'arsse_subscriptions' => ['id','owner','feed'], ]); array_shift($state['arsse_subscriptions']['rows']); - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testRemoveAMissingSubscription() { @@ -377,15 +377,15 @@ trait SeriesSubscription { 'arsse_subscriptions' => ['id','owner','feed','title','folder','pinned','order_type'], ]); $state['arsse_subscriptions']['rows'][0] = [1,"john.doe@example.com",2,"Ook Ook",3,0,0]; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); Arsse::$db->subscriptionPropertiesSet($this->user, 1, [ 'title' => null, ]); $state['arsse_subscriptions']['rows'][0] = [1,"john.doe@example.com",2,null,3,0,0]; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); // making no changes is a valid result Arsse::$db->subscriptionPropertiesSet($this->user, 1, ['unhinged' => true]); - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testMoveASubscriptionToAMissingFolder() { diff --git a/tests/cases/Database/SeriesTag.php b/tests/cases/Database/SeriesTag.php index b3ff4e48..ddd52cdd 100644 --- a/tests/cases/Database/SeriesTag.php +++ b/tests/cases/Database/SeriesTag.php @@ -117,7 +117,7 @@ trait SeriesTag { Phake::verify(Arsse::$user)->authorize($user, "tagAdd"); $state = $this->primeExpectations($this->data, $this->checkTags); $state['arsse_tags']['rows'][] = [$tagID, $user, "Entertaining"]; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testAddADuplicateTag() { @@ -173,7 +173,7 @@ trait SeriesTag { 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($state); + $this->compareExpectations(static::$drv, $state); } public function testRemoveATagByName() { @@ -181,7 +181,7 @@ trait SeriesTag { 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($state); + $this->compareExpectations(static::$drv, $state); } public function testRemoveAMissingTag() { @@ -255,7 +255,7 @@ trait SeriesTag { 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($state); + $this->compareExpectations(static::$drv, $state); } public function testRenameATagByName() { @@ -263,7 +263,7 @@ trait SeriesTag { 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($state); + $this->compareExpectations(static::$drv, $state); } public function testRenameATagToTheEmptyString() { @@ -345,14 +345,14 @@ trait SeriesTag { $state = $this->primeExpectations($this->data, $this->checkMembers); $state['arsse_tag_members']['rows'][1][2] = 1; $state['arsse_tag_members']['rows'][] = [1,4,1]; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testClearATagFromSubscriptions() { Arsse::$db->tagSubscriptionsSet("john.doe@example.com", 1, [1,3], Database::ASSOC_REMOVE); $state = $this->primeExpectations($this->data, $this->checkMembers); $state['arsse_tag_members']['rows'][0][2] = 0; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testApplyATagToSubscriptionsByName() { @@ -360,26 +360,26 @@ trait SeriesTag { $state = $this->primeExpectations($this->data, $this->checkMembers); $state['arsse_tag_members']['rows'][1][2] = 1; $state['arsse_tag_members']['rows'][] = [1,4,1]; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testClearATagFromSubscriptionsByName() { Arsse::$db->tagSubscriptionsSet("john.doe@example.com", "Interesting", [1,3], Database::ASSOC_REMOVE, true); $state = $this->primeExpectations($this->data, $this->checkMembers); $state['arsse_tag_members']['rows'][0][2] = 0; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testApplyATagToNoSubscriptionsByName() { Arsse::$db->tagSubscriptionsSet("john.doe@example.com", "Interesting", [], Database::ASSOC_ADD, true); $state = $this->primeExpectations($this->data, $this->checkMembers); - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testClearATagFromNoSubscriptionsByName() { Arsse::$db->tagSubscriptionsSet("john.doe@example.com", "Interesting", [], Database::ASSOC_REMOVE, true); $state = $this->primeExpectations($this->data, $this->checkMembers); - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testReplaceSubscriptionsOfATag() { @@ -389,7 +389,7 @@ trait SeriesTag { $state['arsse_tag_members']['rows'][1][2] = 1; $state['arsse_tag_members']['rows'][2][2] = 0; $state['arsse_tag_members']['rows'][] = [1,4,1]; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testPurgeSubscriptionsOfATag() { @@ -397,7 +397,7 @@ trait SeriesTag { $state = $this->primeExpectations($this->data, $this->checkMembers); $state['arsse_tag_members']['rows'][0][2] = 0; $state['arsse_tag_members']['rows'][2][2] = 0; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testApplyATagToSubscriptionsWithoutAuthority() { diff --git a/tests/cases/Database/SeriesToken.php b/tests/cases/Database/SeriesToken.php index ff85407b..ef223dff 100644 --- a/tests/cases/Database/SeriesToken.php +++ b/tests/cases/Database/SeriesToken.php @@ -87,13 +87,13 @@ trait SeriesToken { $state = $this->primeExpectations($this->data, ['arsse_tokens' => ["id", "class", "expires", "user"]]); $id = Arsse::$db->tokenCreate($user, "fever.login"); $state['arsse_tokens']['rows'][] = [$id, "fever.login", null, $user]; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); $id = Arsse::$db->tokenCreate($user, "fever.login", null, new \DateTime("2020-01-01T00:00:00Z")); $state['arsse_tokens']['rows'][] = [$id, "fever.login", "2020-01-01 00:00:00", $user]; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); Arsse::$db->tokenCreate($user, "fever.login", "token!", new \DateTime("2021-01-01T00:00:00Z")); $state['arsse_tokens']['rows'][] = ["token!", "fever.login", "2021-01-01 00:00:00", $user]; - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); } public function testCreateATokenForAMissingUser() { @@ -113,7 +113,7 @@ trait SeriesToken { $this->assertTrue(Arsse::$db->tokenRevoke($user, "fever.login", $id)); $state = $this->primeExpectations($this->data, ['arsse_tokens' => ["id", "expires", "user"]]); unset($state['arsse_tokens']['rows'][0]); - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); // revoking a token which does not exist is not an error $this->assertFalse(Arsse::$db->tokenRevoke($user, "fever.login", $id)); } @@ -124,10 +124,10 @@ trait SeriesToken { $this->assertTrue(Arsse::$db->tokenRevoke($user, "fever.login")); unset($state['arsse_tokens']['rows'][0]); unset($state['arsse_tokens']['rows'][1]); - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); $this->assertTrue(Arsse::$db->tokenRevoke($user, "class.class")); unset($state['arsse_tokens']['rows'][2]); - $this->compareExpectations($state); + $this->compareExpectations(static::$drv, $state); // revoking tokens which do not exist is not an error $this->assertFalse(Arsse::$db->tokenRevoke($user, "unknown.class")); } diff --git a/tests/cases/Database/SeriesUser.php b/tests/cases/Database/SeriesUser.php index 8036beee..8395edcc 100644 --- a/tests/cases/Database/SeriesUser.php +++ b/tests/cases/Database/SeriesUser.php @@ -36,7 +36,7 @@ trait SeriesUser { $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($this->data); + $this->compareExpectations(static::$drv, $this->data); } public function testCheckThatAUserExistsWithoutAuthority() { @@ -68,7 +68,7 @@ trait SeriesUser { 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($state); + $this->compareExpectations(static::$drv, $state); } public function testAddAnExistingUser() { @@ -87,7 +87,7 @@ trait SeriesUser { 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($state); + $this->compareExpectations(static::$drv, $state); } public function testRemoveAMissingUser() { diff --git a/tests/lib/AbstractTest.php b/tests/lib/AbstractTest.php index f55ca9b5..6b4de82c 100644 --- a/tests/lib/AbstractTest.php +++ b/tests/lib/AbstractTest.php @@ -9,14 +9,15 @@ namespace JKingWeb\Arsse\Test; use JKingWeb\Arsse\Exception; use JKingWeb\Arsse\Arsse; use JKingWeb\Arsse\Conf; -use JKingWeb\Arsse\CLI; +use JKingWeb\Arsse\Db\Driver; +use JKingWeb\Arsse\Db\Result; use JKingWeb\Arsse\Misc\Date; +use JKingWeb\Arsse\Misc\ValueInfo; use Psr\Http\Message\MessageInterface; use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\ResponseInterface; use Zend\Diactoros\Response\JsonResponse; -use Zend\Diactoros\Response\EmptyResponse; /** @coversNothing */ abstract class AbstractTest extends \PHPUnit\Framework\TestCase { @@ -135,4 +136,120 @@ abstract class AbstractTest extends \PHPUnit\Framework\TestCase { } return $value; } + + public function primeDatabase(Driver $drv, array $data): bool { + $tr = $drv->begin(); + foreach ($data as $table => $info) { + $cols = array_map(function($v) { + return '"'.str_replace('"', '""', $v).'"'; + }, array_keys($info['columns'])); + $cols = implode(",", $cols); + $bindings = array_values($info['columns']); + $params = implode(",", array_fill(0, sizeof($info['columns']), "?")); + $s = $drv->prepareArray("INSERT INTO $table($cols) values($params)", $bindings); + foreach ($info['rows'] as $row) { + $s->runArray($row); + } + } + $tr->commit(); + $this->primed = true; + return true; + } + + public function compareExpectations(Driver $drv, array $expected): bool { + foreach ($expected as $table => $info) { + $cols = array_map(function($v) { + return '"'.str_replace('"', '""', $v).'"'; + }, array_keys($info['columns'])); + $cols = implode(",", $cols); + $types = $info['columns']; + $data = $drv->prepare("SELECT $cols from $table")->run()->getAll(); + $cols = array_keys($info['columns']); + foreach ($info['rows'] as $index => $row) { + $this->assertCount(sizeof($cols), $row, "The number of values for array index $index does not match the number of fields"); + $row = array_combine($cols, $row); + foreach ($data as $index => $test) { + foreach ($test as $col => $value) { + switch ($types[$col]) { + case "datetime": + $test[$col] = $this->approximateTime($row[$col], $value); + break; + case "int": + $test[$col] = ValueInfo::normalize($value, ValueInfo::T_INT | ValueInfo::M_DROP | valueInfo::M_NULL); + break; + case "float": + $test[$col] = ValueInfo::normalize($value, ValueInfo::T_FLOAT | ValueInfo::M_DROP | valueInfo::M_NULL); + break; + case "bool": + $test[$col] = (int) ValueInfo::normalize($value, ValueInfo::T_BOOL | ValueInfo::M_DROP | valueInfo::M_NULL); + break; + } + } + if ($row===$test) { + $data[$index] = $test; + break; + } + } + $this->assertContains($row, $data, "Table $table does not contain record at array index $index."); + $found = array_search($row, $data, true); + unset($data[$found]); + } + $this->assertSame([], $data); + } + return true; + } + + public function primeExpectations(array $source, array $tableSpecs = null): array { + $out = []; + foreach ($tableSpecs as $table => $columns) { + // make sure the source has the table we want + $this->assertArrayHasKey($table, $source, "Source for expectations does not contain requested table $table."); + $out[$table] = [ + 'columns' => [], + 'rows' => array_fill(0, sizeof($source[$table]['rows']), []), + ]; + // make sure the source has all the columns we want for the table + $cols = array_flip($columns); + $cols = array_intersect_key($cols, $source[$table]['columns']); + $this->assertSame(array_keys($cols), $columns, "Source for table $table does not contain all requested columns"); + // get a map of source value offsets and keys + $targets = array_flip(array_keys($source[$table]['columns'])); + foreach ($cols as $key => $order) { + // fill the column-spec + $out[$table]['columns'][$key] = $source[$table]['columns'][$key]; + foreach ($source[$table]['rows'] as $index => $row) { + // fill each row column-wise with re-ordered values + $out[$table]['rows'][$index][$order] = $row[$targets[$key]]; + } + } + } + return $out; + } + + public function assertResult(array $expected, Result $data) { + $data = $data->getAll(); + $this->assertCount(sizeof($expected), $data, "Number of result rows (".sizeof($data).") differs from number of expected rows (".sizeof($expected).")"); + if (sizeof($expected)) { + // make sure the expectations are consistent + foreach ($expected as $exp) { + if (!isset($keys)) { + $keys = $exp; + continue; + } + $this->assertSame(array_keys($keys), array_keys($exp), "Result set expectations are irregular"); + } + // filter the result set to contain just the desired keys (we don't care if the result has extra keys) + $rows = []; + foreach ($data as $row) { + $rows[] = array_intersect_key($row, $keys); + } + // compare the result set to the expectations + foreach ($rows as $row) { + $this->assertContains($row, $expected, "Result set contains unexpected record."); + $found = array_search($row, $expected); + unset($expected[$found]); + } + $this->assertArraySubset($expected, [], false, "Expectations not in result set."); + } + } } From cb71a9efd7a6fc1fbf57c821895879a65d5f02c7 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Sat, 22 Jun 2019 10:29:26 -0400 Subject: [PATCH 125/142] Make database connections for testing configurable --- tests/cases/Db/BaseStatement.php | 2 +- tests/lib/AbstractTest.php | 22 +++++++++++++--------- tests/phpunit.xml | 15 +++++++++++++++ 3 files changed, 29 insertions(+), 10 deletions(-) diff --git a/tests/cases/Db/BaseStatement.php b/tests/cases/Db/BaseStatement.php index cdc74a72..f62c3e88 100644 --- a/tests/cases/Db/BaseStatement.php +++ b/tests/cases/Db/BaseStatement.php @@ -68,7 +68,7 @@ abstract class BaseStatement extends \JKingWeb\Arsse\Test\AbstractTest { /** @dataProvider provideBinaryBindings */ public function testHandleBinaryData($value, string $type, string $exp) { if (in_array(static::$implementation, ["PostgreSQL", "PDO PostgreSQL"])) { - $this->markTestSkipped("Correct handling of binary data with PostgreSQL and native MySQL is currently unknown"); + $this->markTestIncomplete("Correct handling of binary data with PostgreSQL is not currently implemented"); } if ($exp === "null") { $query = "SELECT (? is null) as pass"; diff --git a/tests/lib/AbstractTest.php b/tests/lib/AbstractTest.php index 6b4de82c..b1b79f16 100644 --- a/tests/lib/AbstractTest.php +++ b/tests/lib/AbstractTest.php @@ -43,15 +43,19 @@ abstract class AbstractTest extends \PHPUnit\Framework\TestCase { public static function setConf(array $conf = [], bool $force = true) { $defaults = [ - 'dbSQLite3File' => ":memory:", - 'dbSQLite3Timeout' => 0, - 'dbPostgreSQLUser' => "arsse_test", - 'dbPostgreSQLPass' => "arsse_test", - 'dbPostgreSQLDb' => "arsse_test", - 'dbPostgreSQLSchema' => "arsse_test", - 'dbMySQLUser' => "arsse_test", - 'dbMySQLPass' => "arsse_test", - 'dbMySQLDb' => "arsse_test", + 'dbSQLite3File' => ":memory:", + 'dbSQLite3Timeout' => 0, + 'dbPostgreSQLHost' => $_ENV['ARSSE_TEST_PGSQL_HOST'] ?: "", + 'dbPostgreSQLPort' => $_ENV['ARSSE_TEST_PGSQL_PORT'] ?: 5432, + 'dbPostgreSQLUser' => $_ENV['ARSSE_TEST_PGSQL_USER'] ?: "arsse_test", + 'dbPostgreSQLPass' => $_ENV['ARSSE_TEST_PGSQL_PASS'] ?: "arsse_test", + 'dbPostgreSQLDb' => $_ENV['ARSSE_TEST_PGSQL_DB'] ?: "arsse_test", + 'dbPostgreSQLSchema' => $_ENV['ARSSE_TEST_PGSQL_SCHEMA'] ?: "arsse_test", + 'dbMySQLHost' => $_ENV['ARSSE_TEST_MYSQL_HOST'] ?: "localhost", + 'dbMySQLPort' => $_ENV['ARSSE_TEST_MYSQL_PORT'] ?: 3306, + 'dbMySQLUser' => $_ENV['ARSSE_TEST_MYSQL_USER'] ?: "arsse_test", + 'dbMySQLPass' => $_ENV['ARSSE_TEST_MYSQL_PASS'] ?: "arsse_test", + 'dbMySQLDb' => $_ENV['ARSSE_TEST_MYSQL_DB'] ?: "arsse_test", ]; Arsse::$conf = (($force ? null : Arsse::$conf) ?? (new Conf))->import($defaults)->import($conf); } diff --git a/tests/phpunit.xml b/tests/phpunit.xml index fb753f3e..5617ddb6 100644 --- a/tests/phpunit.xml +++ b/tests/phpunit.xml @@ -10,6 +10,20 @@ forceCoversAnnotation="true" > + + + + + + + + + + + + + + ../lib @@ -116,6 +130,7 @@ cases/ImportExport/TestFile.php + cases/ImportExport/TestImportExport.php cases/ImportExport/TestOPML.php From 61fe673e206a8f2be1f5b1b713048f2e6ab346b5 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Sun, 23 Jun 2019 18:45:24 -0400 Subject: [PATCH 126/142] Skeleton for import tests --- tests/cases/ImportExport/TestImportExport.php | 97 +++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 tests/cases/ImportExport/TestImportExport.php diff --git a/tests/cases/ImportExport/TestImportExport.php b/tests/cases/ImportExport/TestImportExport.php new file mode 100644 index 00000000..9001b119 --- /dev/null +++ b/tests/cases/ImportExport/TestImportExport.php @@ -0,0 +1,97 @@ +proc = \Phake::partialMock(AbstractImportExport::class); + // initialize an SQLite memeory database + static::setConf(); + try { + $this->drv = Driver::create(); + } catch (\JKingWeb\Arsse\Db\Exception $e) { + $this->markTestSkipped("An SQLite database is required for this test"); + } + // create the database interface with the suitable driver and apply the latest schema + Arsse::$db = new Database($this->drv); + Arsse::$db->driverSchemaUpdate(); + $this->data = [ + 'arsse_users' => [ + 'columns' => [ + 'id' => 'str', + 'password' => 'str', + ], + 'rows' => [ + ], + ], + 'arsse_folders' => [ + 'columns' => [ + 'id' => "int", + 'owner' => "str", + 'parent' => "int", + 'name' => "str", + ], + 'rows' => [ + ], + ], + 'arsse_feeds' => [ + 'columns' => [ + 'id' => "int", + 'url' => "str", + 'title' => "str", + ], + 'rows' => [ + ], + ], + 'arsse_subscriptions' => [ + 'columns' => [ + 'id' => "int", + 'owner' => "str", + 'feed' => "int", + 'title' => "str", + ], + 'rows' => [ + ], + ], + 'arsse_tags' => [ + 'columns' => [ + 'id' => "int", + 'owner' => "str", + 'name' => "str", + ], + 'rows' => [ + ], + ], + 'arsse_tag_members' => [ + 'columns' => [ + 'tag' => "int", + 'subscription' => "int", + 'assigned' => "bool", + ], + 'rows' => [ + ], + ], + ]; + } + + public function tearDown() { + $this->drv = null; + $this->proc = null; + self::clearData(); + } +} From 30cede9ea4ec2cf891037d5e748394dab08b7717 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Fri, 5 Jul 2019 14:58:05 -0400 Subject: [PATCH 127/142] Make OPML parser protected --- lib/ImportExport/AbstractImportExport.php | 2 +- lib/ImportExport/OPML.php | 2 +- tests/cases/ImportExport/TestOPML.php | 3 ++- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/lib/ImportExport/AbstractImportExport.php b/lib/ImportExport/AbstractImportExport.php index 54452301..3a234438 100644 --- a/lib/ImportExport/AbstractImportExport.php +++ b/lib/ImportExport/AbstractImportExport.php @@ -141,7 +141,7 @@ abstract class AbstractImportExport { return true; } - abstract public function parse(string $data, bool $flat): array; + abstract protected function parse(string $data, bool $flat): array; abstract public function export(string $user, bool $flat = false): string; diff --git a/lib/ImportExport/OPML.php b/lib/ImportExport/OPML.php index 92252696..aa311f8a 100644 --- a/lib/ImportExport/OPML.php +++ b/lib/ImportExport/OPML.php @@ -10,7 +10,7 @@ use JKingWeb\Arsse\Arsse; use JKingWeb\Arsse\User\Exception as UserException; class OPML extends AbstractImportExport { - public function parse(string $opml, bool $flat): array { + protected function parse(string $opml, bool $flat): array { $d = new \DOMDocument; if (!@$d->loadXML($opml)) { // not a valid XML document diff --git a/tests/cases/ImportExport/TestOPML.php b/tests/cases/ImportExport/TestOPML.php index 0002f2c8..e4ef7b42 100644 --- a/tests/cases/ImportExport/TestOPML.php +++ b/tests/cases/ImportExport/TestOPML.php @@ -109,7 +109,8 @@ OPML_EXPORT_SERIALIZATION; /** @dataProvider provideParserData */ public function testParseOpmlForImport(string $file, bool $flat, $exp) { $data = file_get_contents(\JKingWeb\Arsse\DOCROOT."Import/OPML/$file"); - $parser = new OPML; + // set up a partial mock to make the ImportExport::parse() method visible + $parser = \Phake::makeVisible(\Phake::partialMock(OPML::class)); if ($exp instanceof \JKingWeb\Arsse\AbstractException) { $this->assertException($exp); $parser->parse($data, $flat); From 103755cfb400e864b6312f252ac47249e4b6aa3c Mon Sep 17 00:00:00 2001 From: "J. King" Date: Fri, 5 Jul 2019 19:01:34 -0400 Subject: [PATCH 128/142] Test fixture for import tests --- tests/cases/ImportExport/TestImportExport.php | 76 +++++++++++++++++++ tests/lib/AbstractTest.php | 2 +- 2 files changed, 77 insertions(+), 1 deletion(-) diff --git a/tests/cases/ImportExport/TestImportExport.php b/tests/cases/ImportExport/TestImportExport.php index 9001b119..c1b5e147 100644 --- a/tests/cases/ImportExport/TestImportExport.php +++ b/tests/cases/ImportExport/TestImportExport.php @@ -6,6 +6,7 @@ declare(strict_types=1); namespace JKingWeb\Arsse\TestCase\ImportExport; +use JKingWeb\Arsse\Arsse; use JKingWeb\Arsse\Db\SQLite3\Driver; use JKingWeb\Arsse\ImportExport\AbstractImportExport; use JKingWeb\Arsse\ImportExport\Exception; @@ -15,9 +16,20 @@ use JKingWeb\Arsse\Test\Database; class TestImportExport extends \JKingWeb\Arsse\Test\AbstractTest { protected $drv; protected $proc; + protected $checkTables = [ + 'arsse_folders' => ["id", "owner", "parent", "name"], + 'arsse_feeds' => ['id', 'url'], + 'arsse_subscriptions' => ["id", "owner", "folder", "feed", "title"], + 'arsse_tags' => ["id", "owner", "name"], + 'arsse_tag_members' => ["tag", "subscription", "assigned"], + ]; public function setUp() { self::clearData(); + // 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 @@ -37,6 +49,8 @@ class TestImportExport extends \JKingWeb\Arsse\Test\AbstractTest { 'password' => 'str', ], 'rows' => [ + ["john.doe@example.com", ""], + ["jane.doe@example.com", ""], ], ], 'arsse_folders' => [ @@ -47,6 +61,12 @@ class TestImportExport extends \JKingWeb\Arsse\Test\AbstractTest { 'name' => "str", ], 'rows' => [ + [1, "john.doe@example.com", null, "Science"], + [2, "john.doe@example.com", 1, "Rocketry"], + [3, "john.doe@example.com", null, "Politics"], + [4, "john.doe@example.com", null, "Photography"], + [5, "john.doe@example.com", 3, "Local"], + [6, "john.doe@example.com", 3, "National"], ], ], 'arsse_feeds' => [ @@ -56,16 +76,29 @@ class TestImportExport extends \JKingWeb\Arsse\Test\AbstractTest { 'title' => "str", ], 'rows' => [ + [1, "http://localhost:8000/Import/nasa-jpl", "NASA JPL"], + [2, "http://localhost:8000/Import/torstar", "Toronto Star"], + [3, "http://localhost:8000/Import/ars", "Ars Technica"], + [4, "http://localhost:8000/Import/cbc", "CBC News"], + [5, "http://localhost:8000/Import/citizen", "Ottawa Citizen"], + [6, "http://localhost:8000/Import/eurogamer", "Eurogamer"], ], ], 'arsse_subscriptions' => [ 'columns' => [ 'id' => "int", 'owner' => "str", + 'folder' => "int", 'feed' => "int", 'title' => "str", ], 'rows' => [ + [1, "john.doe@example.com", 2, 1, "NASA JPL"], + [2, "john.doe@example.com", 5, 2, "Toronto Star"], + [3, "john.doe@example.com", 1, 3, "Ars Technica"], + [4, "john.doe@example.com", 6, 4, "CBC News"], + [5, "john.doe@example.com", 6, 5, "Ottawa Citizen"], + [6, "john.doe@example.com", null, 6, "Eurogamer"], ], ], 'arsse_tags' => [ @@ -75,6 +108,12 @@ class TestImportExport extends \JKingWeb\Arsse\Test\AbstractTest { 'name' => "str", ], 'rows' => [ + [1, "john.doe@example.com", "canada"], + [2, "john.doe@example.com", "frequent"], + [3, "john.doe@example.com", "gaming"], + [4, "john.doe@example.com", "news"], + [5, "john.doe@example.com", "tech"], + [6, "john.doe@example.com", "toronto"], ], ], 'arsse_tag_members' => [ @@ -84,9 +123,22 @@ class TestImportExport extends \JKingWeb\Arsse\Test\AbstractTest { 'assigned' => "bool", ], 'rows' => [ + [1, 2, 1], + [1, 4, 1], + [1, 5, 1], + [2, 3, 1], + [2, 6, 1], + [3, 6, 1], + [4, 2, 1], + [4, 4, 1], + [4, 5, 1], + [5, 1, 1], + [5, 3, 1], + [6, 2, 1], ], ], ]; + $this->primeDatabase($this->drv, $this->data); } public function tearDown() { @@ -94,4 +146,28 @@ class TestImportExport extends \JKingWeb\Arsse\Test\AbstractTest { $this->proc = null; self::clearData(); } + + public function testMakeNoEffectiveChnages() { + $in = [[ + ['url' => "http://localhost:8000/Import/nasa-jpl", 'title' => "NASA JPL", 'folder' => 3, 'tags' => ["tech"]], + ['url' => "http://localhost:8000/Import/ars", 'title' => "Ars Technica", 'folder' => 2, 'tags' => ["frequent", "tech"]], + ['url' => "http://localhost:8000/Import/torstar", 'title' => "Toronto Star", 'folder' => 5, 'tags' => ["news", "canada", "toronto"]], + ['url' => "http://localhost:8000/Import/citizen", 'title' => "Ottawa Citizen", 'folder' => 6, 'tags' => ["news", "canada"]], + ['url' => "http://localhost:8000/Import/eurogamer", 'title' => "Eurogamer", 'folder' => 0, 'tags' => ["gaming", "frequent"]], + ['url' => "http://localhost:8000/Import/cbc", 'title' => "CBC News", 'folder' => 6, 'tags' => ["news", "canada"]], + ], [1 => + ['id' => 1, 'name' => "Photography", 'parent' => 0], + ['id' => 2, 'name' => "Science", 'parent' => 0], + ['id' => 3, 'name' => "Rocketry", 'parent' => 2], + ['id' => 4, 'name' => "Politics", 'parent' => 0], + ['id' => 5, 'name' => "Local", 'parent' => 4], + ['id' => 6, 'name' => "National", 'parent' => 4], + ]]; + \Phake::when($this->proc)->parse->thenReturn($in); + $exp = $this->primeExpectations($this->data, $this->checkTables); + $this->proc->import("john.doe@example.com", "", false, false); + $this->compareExpectations($this->drv, $exp); + $this->proc->import("john.doe@example.com", "", false, true); + $this->compareExpectations($this->drv, $exp); + } } diff --git a/tests/lib/AbstractTest.php b/tests/lib/AbstractTest.php index b1b79f16..30b69b04 100644 --- a/tests/lib/AbstractTest.php +++ b/tests/lib/AbstractTest.php @@ -203,7 +203,7 @@ abstract class AbstractTest extends \PHPUnit\Framework\TestCase { return true; } - public function primeExpectations(array $source, array $tableSpecs = null): array { + public function primeExpectations(array $source, array $tableSpecs): array { $out = []; foreach ($tableSpecs as $table => $columns) { // make sure the source has the table we want From 8f9678b8a40c220a30c68e285632f646c97c69d6 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Fri, 5 Jul 2019 21:18:30 -0400 Subject: [PATCH 129/142] Tests for baasic import errors --- tests/cases/ImportExport/TestImportExport.php | 29 ++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/tests/cases/ImportExport/TestImportExport.php b/tests/cases/ImportExport/TestImportExport.php index c1b5e147..40c1cd33 100644 --- a/tests/cases/ImportExport/TestImportExport.php +++ b/tests/cases/ImportExport/TestImportExport.php @@ -147,7 +147,34 @@ class TestImportExport extends \JKingWeb\Arsse\Test\AbstractTest { self::clearData(); } - public function testMakeNoEffectiveChnages() { + public function testImportForAMissingUser() { + \Phake::when(Arsse::$user)->exists->thenReturn(false); + $this->assertException("doesNotExist", "User"); + $this->proc->import("john.doe@example.com", "", false, false); + } + + public function testImportWithInvalidFolder() { + $in = [[ + ], [1 => + ['id' => 1, 'name' => "", 'parent' => 0], + ]]; + \Phake::when($this->proc)->parse->thenReturn($in); + $this->assertException("invalidFolderName", "ImportExport"); + $this->proc->import("john.doe@example.com", "", false, false); + } + + public function testImportWithDuplicateFolder() { + $in = [[ + ], [1 => + ['id' => 1, 'name' => "New", 'parent' => 0], + ['id' => 2, 'name' => "New", 'parent' => 0], + ]]; + \Phake::when($this->proc)->parse->thenReturn($in); + $this->assertException("invalidFolderCopy", "ImportExport"); + $this->proc->import("john.doe@example.com", "", false, false); + } + + public function testMakeNoEffectiveChanges() { $in = [[ ['url' => "http://localhost:8000/Import/nasa-jpl", 'title' => "NASA JPL", 'folder' => 3, 'tags' => ["tech"]], ['url' => "http://localhost:8000/Import/ars", 'title' => "Ars Technica", 'folder' => 2, 'tags' => ["frequent", "tech"]], From 0480465e7eeb18e9c20f2b8db21b129ad96ab37a Mon Sep 17 00:00:00 2001 From: "J. King" Date: Wed, 24 Jul 2019 09:10:13 -0400 Subject: [PATCH 130/142] Test Fever XML responses Fixes #158 --- lib/REST/Fever/API.php | 8 +++++--- tests/cases/REST/Fever/TestAPI.php | 15 ++++++++++++++- tests/lib/AbstractTest.php | 5 +++-- 3 files changed, 22 insertions(+), 6 deletions(-) diff --git a/lib/REST/Fever/API.php b/lib/REST/Fever/API.php index 643d1e87..59f7a9f1 100644 --- a/lib/REST/Fever/API.php +++ b/lib/REST/Fever/API.php @@ -188,7 +188,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { $d = $p->ownerDocument; foreach ($data as $k => $v) { if (!is_array($v)) { - $p->appendChild($d->createElement($k, $v)); + $p->appendChild($d->createElement($k, (string) $v)); } elseif (isset($v[0])) { // this is a very simplistic check for an indexed array // it would not pass muster in the face of generic data, @@ -206,9 +206,11 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { $d = $p->ownerDocument; foreach ($data as $v) { if (!is_array($v)) { - $p->appendChild($d->createElement($k, $v)); + // this case is never encountered with Fever's output + $p->appendChild($d->createElement($k, (string) $v)); // @codeCoverageIgnore } elseif (isset($v[0])) { - $p->appendChild($this->makeXMLIndexed($v, $d->createElement($k), substr($k, 0, strlen($k) - 1))); + // this case is never encountered with Fever's output + $p->appendChild($this->makeXMLIndexed($v, $d->createElement($k), substr($k, 0, strlen($k) - 1))); // @codeCoverageIgnore } else { $p->appendChild($this->makeXMLAssoc($v, $d->createElement($k))); } diff --git a/tests/cases/REST/Fever/TestAPI.php b/tests/cases/REST/Fever/TestAPI.php index 40231222..def389a7 100644 --- a/tests/cases/REST/Fever/TestAPI.php +++ b/tests/cases/REST/Fever/TestAPI.php @@ -17,11 +17,14 @@ use JKingWeb\Arsse\REST\Fever\API; use Psr\Http\Message\ResponseInterface; use Zend\Diactoros\ServerRequest; use Zend\Diactoros\Response\JsonResponse; +use Zend\Diactoros\Response\XmlResponse; use Zend\Diactoros\Response\EmptyResponse; -use PHPUnit\Util\Json; /** @covers \JKingWeb\Arsse\REST\Fever\API */ class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest { + /** @var \JKingWeb\Arsse\REST\Fever\API */ + protected $h; + protected $articles = [ 'db' => [ [ @@ -483,4 +486,14 @@ class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest { $this->assertMessage($exp, $act); \Phake::verify(Arsse::$db)->articleMark; // only called one time, above } + + public function testOutputToXml() { + \Phake::when($this->h)->processRequest->thenReturn([ + 'items' => $this->articles['rest'], + 'total_items' => 1024, + ]); + $exp = new XmlResponse("1018Article title 1<p>Article content 1</p>http://example.com/1009466848001028Article title 2<p>Article content 2</p>http://example.com/2019467712001039Article title 3<p>Article content 3</p>http://example.com/3109468576001049Article title 4<p>Article content 4</p>http://example.com/41194694400010510Article title 5<p>Article content 5</p>http://example.com/5009470304001024"); + $act = $this->h->dispatch($this->req("api=xml")); + $this->assertMessage($exp, $act); + } } diff --git a/tests/lib/AbstractTest.php b/tests/lib/AbstractTest.php index f55ca9b5..cef0a8a8 100644 --- a/tests/lib/AbstractTest.php +++ b/tests/lib/AbstractTest.php @@ -9,14 +9,13 @@ namespace JKingWeb\Arsse\Test; use JKingWeb\Arsse\Exception; use JKingWeb\Arsse\Arsse; use JKingWeb\Arsse\Conf; -use JKingWeb\Arsse\CLI; use JKingWeb\Arsse\Misc\Date; use Psr\Http\Message\MessageInterface; use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\ResponseInterface; use Zend\Diactoros\Response\JsonResponse; -use Zend\Diactoros\Response\EmptyResponse; +use Zend\Diactoros\Response\XmlResponse; /** @coversNothing */ abstract class AbstractTest extends \PHPUnit\Framework\TestCase { @@ -93,6 +92,8 @@ abstract class AbstractTest extends \PHPUnit\Framework\TestCase { if ($exp instanceof JsonResponse) { $this->assertEquals($exp->getPayload(), $act->getPayload(), $text); $this->assertSame($exp->getPayload(), $act->getPayload(), $text); + } elseif ($exp instanceof XmlResponse) { + $this->assertXmlStringEqualsXmlString((string) $exp->getBody(), (string) $act->getBody(), $text); } else { $this->assertEquals((string) $exp->getBody(), (string) $act->getBody(), $text); } From 61b942df70bba21827c9adda1a7abb3be5fa49f4 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Wed, 24 Jul 2019 12:27:50 -0400 Subject: [PATCH 131/142] Defer Fever favicons to a future release --- lib/REST/Fever/API.php | 13 +++++++++++-- tests/cases/REST/Fever/TestAPI.php | 10 ++++++++-- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/lib/REST/Fever/API.php b/lib/REST/Fever/API.php index 59f7a9f1..83c3be82 100644 --- a/lib/REST/Fever/API.php +++ b/lib/REST/Fever/API.php @@ -26,6 +26,8 @@ use Zend\Diactoros\Response\EmptyResponse; class API extends \JKingWeb\Arsse\REST\AbstractHandler { const LEVEL = 3; + const GENERIC_ICON_TYPE = "image/png;base64"; + const GENERIC_ICON_DATA = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAAZdEVYdFNvZnR3YXJlAHBhaW50Lm5ldCA0LjAuMjHxIGmVAAAADUlEQVQYV2NgYGBgAAAABQABijPjAAAAAABJRU5ErkJggg=="; // GET parameters for which we only check presence: these will be converted to booleans const PARAM_BOOL = ["groups", "feeds", "items", "favicons", "links", "unread_item_ids", "saved_item_ids"]; @@ -143,7 +145,14 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { $out['feeds_groups'] = $this->getRelationships(); } if ($G['favicons']) { - # deal with 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, + ], + ]; } if ($G['items']) { $out['items'] = $this->getItems($G); @@ -318,7 +327,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { foreach (arsse::$db->subscriptionList(Arsse::$user->id) as $sub) { $out[] = [ 'id' => (int) $sub['id'], - 'favicon_id' => (int) ($sub['favicon'] ? $sub['feed'] : 0), + 'favicon_id' => 0, // TODO: implement favicons 'title' => (string) $sub['title'], 'url' => $sub['url'], 'site_url' => $sub['source'], diff --git a/tests/cases/REST/Fever/TestAPI.php b/tests/cases/REST/Fever/TestAPI.php index def389a7..b81bdc32 100644 --- a/tests/cases/REST/Fever/TestAPI.php +++ b/tests/cases/REST/Fever/TestAPI.php @@ -307,9 +307,9 @@ class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest { ])); $exp = new JsonResponse([ 'feeds' => [ - ['id' => 1, 'favicon_id' => 5, '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' => 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' => 1, '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' => 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")], ], 'feeds_groups' => [ ['group_id' => 1, 'feed_ids' => "1,2"], @@ -496,4 +496,10 @@ class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest { $act = $this->h->dispatch($this->req("api=xml")); $this->assertMessage($exp, $act); } + + public function testListFeedIcons() { + $act = $this->h->dispatch($this->req("api&favicons")); + $exp = new JsonResponse(['favicons' => [['id' => 0, 'data' => API::GENERIC_ICON_TYPE.",".API::GENERIC_ICON_DATA]]]); + $this->assertMessage($exp, $act); + } } From 56bb4608205d9b903116ac10d8822f7256272e71 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Wed, 24 Jul 2019 12:32:00 -0400 Subject: [PATCH 132/142] Test answering OPTIONS requests in Fever --- tests/cases/REST/Fever/TestAPI.php | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/cases/REST/Fever/TestAPI.php b/tests/cases/REST/Fever/TestAPI.php index b81bdc32..3393ddeb 100644 --- a/tests/cases/REST/Fever/TestAPI.php +++ b/tests/cases/REST/Fever/TestAPI.php @@ -502,4 +502,13 @@ class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest { $exp = new JsonResponse(['favicons' => [['id' => 0, 'data' => API::GENERIC_ICON_TYPE.",".API::GENERIC_ICON_DATA]]]); $this->assertMessage($exp, $act); } + + public function testAnswerOptionsRequest() { + $act = $this->h->dispatch($this->req("api", "", "OPTIONS")); + $exp = new EmptyResponse(204, [ + 'Allow' => "POST", + 'Accept' => "application/x-www-form-urlencoded", + ]); + $this->assertMessage($exp, $act); + } } From 0e95892aea234d70187d676fc7df79d6b8cc7612 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Wed, 24 Jul 2019 14:20:17 -0400 Subject: [PATCH 133/142] Do not necessarily ignore blank tags in import We still make them practically impossible in OPML imports, however --- lib/AbstractException.php | 1 + lib/ImportExport/AbstractImportExport.php | 4 ++-- lib/ImportExport/OPML.php | 4 ++++ locale/en.php | 1 + tests/cases/ImportExport/TestOPML.php | 2 +- 5 files changed, 9 insertions(+), 3 deletions(-) diff --git a/lib/AbstractException.php b/lib/AbstractException.php index 1e3a2dd3..0165d464 100644 --- a/lib/AbstractException.php +++ b/lib/AbstractException.php @@ -94,6 +94,7 @@ abstract class AbstractException extends \Exception { "ImportExport/Exception.invalidSemantics" => 10612, "ImportExport/Exception.invalidFolderName" => 10613, "ImportExport/Exception.invalidFolderCopy" => 10614, + "ImportExport/Exception.invalidTagName" => 10615, ]; public function __construct(string $msgID = "", $vars = null, \Throwable $e = null) { diff --git a/lib/ImportExport/AbstractImportExport.php b/lib/ImportExport/AbstractImportExport.php index 3a234438..66cc9b23 100644 --- a/lib/ImportExport/AbstractImportExport.php +++ b/lib/ImportExport/AbstractImportExport.php @@ -85,8 +85,8 @@ abstract class AbstractImportExport { // compile the set of used tags, if this is a new feed or we're doing a full replacement foreach ($f['tags'] as $t) { if (!strlen(trim($t))) { - // ignore any blank tags - continue; + // fail if we have any blank tags + throw new Exception("invalidTagName"); } if (!isset($tagMap[$t])) { // populate the tag map diff --git a/lib/ImportExport/OPML.php b/lib/ImportExport/OPML.php index aa311f8a..30a3cc51 100644 --- a/lib/ImportExport/OPML.php +++ b/lib/ImportExport/OPML.php @@ -61,6 +61,10 @@ class OPML extends AbstractImportExport { $categories = array_map(function($v) { return trim(preg_replace("/\s+/", " ", $v)); }, explode(",", $categories)); + // filter out any blank categories + $categories = array_filter($categories, function($v) { + return strlen($v); + }); } else { $categories = []; } diff --git a/locale/en.php b/locale/en.php index 19fc7241..e095db8a 100644 --- a/locale/en.php +++ b/locale/en.php @@ -163,4 +163,5 @@ return [ 'Exception.JKingWeb/Arsse/ImportExport/Exception.invalidSemantics' => 'Input data is not valid {type} data', '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', ]; diff --git a/tests/cases/ImportExport/TestOPML.php b/tests/cases/ImportExport/TestOPML.php index e4ef7b42..503211ca 100644 --- a/tests/cases/ImportExport/TestOPML.php +++ b/tests/cases/ImportExport/TestOPML.php @@ -135,7 +135,7 @@ OPML_EXPORT_SERIALIZATION; ['url' => "http://example.com/3", 'title' => "", 'folder' => 0, 'tags' => []], ['url' => "http://example.com/4", 'title' => "", 'folder' => 0, 'tags' => []], ['url' => "", 'title' => "", 'folder' => 0, 'tags' => ["whee"]], - ['url' => "", 'title' => "", 'folder' => 0, 'tags' => ["whee", "whoo", ""]], + ['url' => "", 'title' => "", 'folder' => 0, 'tags' => ["whee", "whoo"]], ], []]], ["FoldersOnly.opml", true, [[], []]], ["FoldersOnly.opml", false, [[], [1 => From 13b76dea0cda274872e57e85168499c7a375036c Mon Sep 17 00:00:00 2001 From: "J. King" Date: Thu, 25 Jul 2019 13:14:29 -0400 Subject: [PATCH 134/142] Tests for generic importing --- tests/cases/ImportExport/TestImportExport.php | 66 ++++++++++++++++++- tests/docroot/Import/some-feed.php | 18 +++++ tests/lib/AbstractTest.php | 6 +- 3 files changed, 86 insertions(+), 4 deletions(-) create mode 100644 tests/docroot/Import/some-feed.php diff --git a/tests/cases/ImportExport/TestImportExport.php b/tests/cases/ImportExport/TestImportExport.php index 40c1cd33..f5452a55 100644 --- a/tests/cases/ImportExport/TestImportExport.php +++ b/tests/cases/ImportExport/TestImportExport.php @@ -18,7 +18,7 @@ class TestImportExport extends \JKingWeb\Arsse\Test\AbstractTest { protected $proc; protected $checkTables = [ 'arsse_folders' => ["id", "owner", "parent", "name"], - 'arsse_feeds' => ['id', 'url'], + 'arsse_feeds' => ["id", "url", "title"], 'arsse_subscriptions' => ["id", "owner", "folder", "feed", "title"], 'arsse_tags' => ["id", "owner", "name"], 'arsse_tag_members' => ["tag", "subscription", "assigned"], @@ -197,4 +197,68 @@ class TestImportExport extends \JKingWeb\Arsse\Test\AbstractTest { $this->proc->import("john.doe@example.com", "", false, true); $this->compareExpectations($this->drv, $exp); } + + public function testModifyASubscription() { + $in = [[ + ['url' => "http://localhost:8000/Import/nasa-jpl", 'title' => "NASA JPL", 'folder' => 3, 'tags' => ["tech"]], + ['url' => "http://localhost:8000/Import/ars", 'title' => "Ars Technica", 'folder' => 2, 'tags' => ["frequent", "tech"]], + ['url' => "http://localhost:8000/Import/torstar", 'title' => "Toronto Star", 'folder' => 5, 'tags' => ["news", "canada", "toronto"]], + ['url' => "http://localhost:8000/Import/citizen", 'title' => "Ottawa Citizen", 'folder' => 6, 'tags' => ["news", "canada"]], + ['url' => "http://localhost:8000/Import/eurogamer", 'title' => "Eurogamer", 'folder' => 0, 'tags' => ["gaming", "frequent"]], + ['url' => "http://localhost:8000/Import/cbc", 'title' => "CBC", 'folder' => 0, 'tags' => ["news", "canada"]], // moved to root and renamed + ], [1 => + ['id' => 1, 'name' => "Photography", 'parent' => 0], + ['id' => 2, 'name' => "Science", 'parent' => 0], + ['id' => 3, 'name' => "Rocketry", 'parent' => 2], + ['id' => 4, 'name' => "Politics", 'parent' => 0], + ['id' => 5, 'name' => "Local", 'parent' => 4], + ['id' => 6, 'name' => "National", 'parent' => 4], + ]]; + \Phake::when($this->proc)->parse->thenReturn($in); + $this->proc->import("john.doe@example.com", "", false, true); + $exp = $this->primeExpectations($this->data, $this->checkTables); + $exp['arsse_subscriptions']['rows'][3] = [4, "john.doe@example.com", null, 4, "CBC"]; + $this->compareExpectations($this->drv, $exp); + } + + public function testImportAFeed() { + $in = [[ + ['url' => "http://localhost:8000/Import/some-feed", 'title' => "Some Feed", 'folder' => 0, 'tags' => ["frequent", "cryptic"]], //one existing tag and one new one + ], []]; + \Phake::when($this->proc)->parse->thenReturn($in); + $this->proc->import("john.doe@example.com", "", false, false); + $exp = $this->primeExpectations($this->data, $this->checkTables); + $exp['arsse_feeds']['rows'][] = [7, "http://localhost:8000/Import/some-feed", "Some feed"]; // author-supplied and user-supplied titles differ + $exp['arsse_subscriptions']['rows'][] = [7, "john.doe@example.com", null, 7, "Some Feed"]; + $exp['arsse_tags']['rows'][] = [7, "john.doe@example.com", "cryptic"]; + $exp['arsse_tag_members']['rows'][] = [2, 7, 1]; + $exp['arsse_tag_members']['rows'][] = [7, 7, 1]; + $this->compareExpectations($this->drv, $exp); + } + + public function testImportAFeedWithAnInvalidTag() { + $in = [[ + ['url' => "http://localhost:8000/Import/some-feed", 'title' => "Some Feed", 'folder' => 0, 'tags' => [""]], + ], []]; + \Phake::when($this->proc)->parse->thenReturn($in); + $this->assertException("invalidTagName", "ImportExport"); + $this->proc->import("john.doe@example.com", "", false, false); + } + + public function testReplaceData() { + $in = [[ + ['url' => "http://localhost:8000/Import/some-feed", 'title' => "Some Feed", 'folder' => 1, 'tags' => ["frequent", "cryptic"]], + ], [1 => + ['id' => 1, 'name' => "Photography", 'parent' => 0], + ]]; + \Phake::when($this->proc)->parse->thenReturn($in); + $this->proc->import("john.doe@example.com", "", false, true); + $exp = $this->primeExpectations($this->data, $this->checkTables); + $exp['arsse_feeds']['rows'][] = [7, "http://localhost:8000/Import/some-feed", "Some feed"]; // author-supplied and user-supplied titles differ + $exp['arsse_subscriptions']['rows'] = [[7, "john.doe@example.com", 4, 7, "Some Feed"]]; + $exp['arsse_tags']['rows'] = [[2, "john.doe@example.com", "frequent"], [7, "john.doe@example.com", "cryptic"]]; + $exp['arsse_tag_members']['rows'] = [[2, 7, 1], [7, 7, 1]]; + $exp['arsse_folders']['rows'] = [[4, "john.doe@example.com", null, "Photography"]]; + $this->compareExpectations($this->drv, $exp); + } } diff --git a/tests/docroot/Import/some-feed.php b/tests/docroot/Import/some-feed.php new file mode 100644 index 00000000..eec58567 --- /dev/null +++ b/tests/docroot/Import/some-feed.php @@ -0,0 +1,18 @@ + "application/rss+xml", + 'content' => << + + Some feed + http://example.com/ + Just a generic feed + + + http://localhost:8000/Import/some-feed/some-article + Some article + This feed is used only to demonstrate failure modes external to the feed itself + + + +MESSAGE_BODY +]; diff --git a/tests/lib/AbstractTest.php b/tests/lib/AbstractTest.php index 1644b594..6334e5c5 100644 --- a/tests/lib/AbstractTest.php +++ b/tests/lib/AbstractTest.php @@ -173,7 +173,7 @@ abstract class AbstractTest extends \PHPUnit\Framework\TestCase { $data = $drv->prepare("SELECT $cols from $table")->run()->getAll(); $cols = array_keys($info['columns']); foreach ($info['rows'] as $index => $row) { - $this->assertCount(sizeof($cols), $row, "The number of values for array index $index does not match the number of fields"); + $this->assertCount(sizeof($cols), $row, "The number of columns in array index $index of expectations for table $table does not match its definition"); $row = array_combine($cols, $row); foreach ($data as $index => $test) { foreach ($test as $col => $value) { @@ -197,11 +197,11 @@ abstract class AbstractTest extends \PHPUnit\Framework\TestCase { break; } } - $this->assertContains($row, $data, "Table $table does not contain record at array index $index."); + $this->assertContains($row, $data, "Actual Table $table does not contain record at expected array index $index"); $found = array_search($row, $data, true); unset($data[$found]); } - $this->assertSame([], $data); + $this->assertSame([], $data, "Actual table $table contains extra rows not in expectations"); } return true; } From faf524c54fc76db9273dd0d280a34e96078a6736 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Thu, 25 Jul 2019 15:45:18 -0400 Subject: [PATCH 135/142] CLI test for import Fixes #35 --- lib/CLI.php | 7 +-- lib/ImportExport/AbstractImportExport.php | 2 +- tests/cases/CLI/TestCLI.php | 57 +++++++++++++++++++++++ 3 files changed, 62 insertions(+), 4 deletions(-) diff --git a/lib/CLI.php b/lib/CLI.php index 0b1f3b97..7c0d30b5 100644 --- a/lib/CLI.php +++ b/lib/CLI.php @@ -196,11 +196,11 @@ USAGE_TEXT; case "export": $u = $args['']; $file = $this->resolveFile($args[''], "w"); - return (int) !$this->getInstance(OPML::class)->exportFile($file, $u, $args['--flat']); + return (int) !$this->getInstance(OPML::class)->exportFile($file, $u, ($args['--flat'] || $args['-f'])); case "import": $u = $args['']; - $file = $this->resolveFile($args[''], "w"); - return (int) !$this->getInstance(OPML::class)->importFile($file, $u, $args['--flat'], $args['--replace']); + $file = $this->resolveFile($args[''], "r"); + return (int) !$this->getInstance(OPML::class)->importFile($file, $u, ($args['--flat'] || $args['-f']), ($args['--replace'] || $args['-r'])); } } catch (AbstractException $e) { $this->logError($e->getMessage()); @@ -213,6 +213,7 @@ USAGE_TEXT; fwrite(STDERR, $msg.\PHP_EOL); } + /** @codeCoverageIgnore */ protected function getInstance(string $class) { return new $class; } diff --git a/lib/ImportExport/AbstractImportExport.php b/lib/ImportExport/AbstractImportExport.php index 66cc9b23..f882ea16 100644 --- a/lib/ImportExport/AbstractImportExport.php +++ b/lib/ImportExport/AbstractImportExport.php @@ -155,7 +155,7 @@ abstract class AbstractImportExport { return true; } - public function importFile(string $file, string $user, bool $flat = false, bool $replace): bool { + public function importFile(string $file, string $user, bool $flat = false, bool $replace = false): bool { $data = @file_get_contents($file); if ($data === false) { // if it fails throw an exception diff --git a/tests/cases/CLI/TestCLI.php b/tests/cases/CLI/TestCLI.php index 46290fc6..825bc381 100644 --- a/tests/cases/CLI/TestCLI.php +++ b/tests/cases/CLI/TestCLI.php @@ -311,6 +311,63 @@ class TestCLI extends \JKingWeb\Arsse\Test\AbstractTest { ["arsse.php export jane.doe@example.com - --flat", 0, "php://output", "jane.doe@example.com", true], ["arsse.php export --flat jane.doe@example.com good.opml", 0, "good.opml", "jane.doe@example.com", true], ["arsse.php export jane.doe@example.com bad.opml --flat", 10604, "bad.opml", "jane.doe@example.com", true], + ["arsse.php export john.doe@example.com -f", 0, "php://output", "john.doe@example.com", true], + ["arsse.php export john.doe@example.com - -f", 0, "php://output", "john.doe@example.com", true], + ["arsse.php export -f john.doe@example.com good.opml", 0, "good.opml", "john.doe@example.com", true], + ["arsse.php export john.doe@example.com bad.opml -f", 10604, "bad.opml", "john.doe@example.com", true], + ["arsse.php export jane.doe@example.com -f", 0, "php://output", "jane.doe@example.com", true], + ["arsse.php export jane.doe@example.com - -f", 0, "php://output", "jane.doe@example.com", true], + ["arsse.php export -f jane.doe@example.com good.opml", 0, "good.opml", "jane.doe@example.com", true], + ["arsse.php export jane.doe@example.com bad.opml -f", 10604, "bad.opml", "jane.doe@example.com", true], + ]; + } + + /** @dataProvider provideOpmlImports */ + public function testImportFromOpml(string $cmd, int $exitStatus, string $file, string $user, bool $flat, bool $replace) { + $opml = Phake::mock(OPML::class); + 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); + $this->assertLoaded(true); + Phake::verify($opml)->importFile($file, $user, $flat, $replace); + } + + public function provideOpmlImports() { + return [ + ["arsse.php import john.doe@example.com", 0, "php://input", "john.doe@example.com", false, false], + ["arsse.php import john.doe@example.com -", 0, "php://input", "john.doe@example.com", false, false], + ["arsse.php import john.doe@example.com good.opml", 0, "good.opml", "john.doe@example.com", false, false], + ["arsse.php import john.doe@example.com bad.opml", 10603, "bad.opml", "john.doe@example.com", false, false], + ["arsse.php import john.doe@example.com --flat", 0, "php://input", "john.doe@example.com", true, false], + ["arsse.php import john.doe@example.com - --flat", 0, "php://input", "john.doe@example.com", true, false], + ["arsse.php import --flat john.doe@example.com good.opml", 0, "good.opml", "john.doe@example.com", true, false], + ["arsse.php import john.doe@example.com bad.opml --flat", 10603, "bad.opml", "john.doe@example.com", true, false], + ["arsse.php import jane.doe@example.com", 0, "php://input", "jane.doe@example.com", false, false], + ["arsse.php import jane.doe@example.com -", 0, "php://input", "jane.doe@example.com", false, false], + ["arsse.php import jane.doe@example.com good.opml", 0, "good.opml", "jane.doe@example.com", false, false], + ["arsse.php import jane.doe@example.com bad.opml", 10603, "bad.opml", "jane.doe@example.com", false, false], + ["arsse.php import jane.doe@example.com --flat", 0, "php://input", "jane.doe@example.com", true, false], + ["arsse.php import jane.doe@example.com - --flat", 0, "php://input", "jane.doe@example.com", true, false], + ["arsse.php import --flat jane.doe@example.com good.opml", 0, "good.opml", "jane.doe@example.com", true, false], + ["arsse.php import jane.doe@example.com bad.opml --flat", 10603, "bad.opml", "jane.doe@example.com", true, false], + ["arsse.php import john.doe@example.com --replace", 0, "php://input", "john.doe@example.com", false, true], + ["arsse.php import john.doe@example.com - -r", 0, "php://input", "john.doe@example.com", false, true], + ["arsse.php import --replace john.doe@example.com good.opml", 0, "good.opml", "john.doe@example.com", false, true], + ["arsse.php import -r john.doe@example.com bad.opml", 10603, "bad.opml", "john.doe@example.com", false, true], + ["arsse.php import --replace john.doe@example.com --flat", 0, "php://input", "john.doe@example.com", true, true], + ["arsse.php import -r john.doe@example.com - --flat", 0, "php://input", "john.doe@example.com", true, true], + ["arsse.php import --flat john.doe@example.com good.opml -r", 0, "good.opml", "john.doe@example.com", true, true], + ["arsse.php import --replace john.doe@example.com bad.opml --flat", 10603, "bad.opml", "john.doe@example.com", true, true], + ["arsse.php import jane.doe@example.com -r ", 0, "php://input", "jane.doe@example.com", false, true], + ["arsse.php import jane.doe@example.com - --replace", 0, "php://input", "jane.doe@example.com", false, true], + ["arsse.php import -r jane.doe@example.com good.opml", 0, "good.opml", "jane.doe@example.com", false, true], + ["arsse.php import --replace jane.doe@example.com bad.opml", 10603, "bad.opml", "jane.doe@example.com", false, true], + ["arsse.php import jane.doe@example.com --flat -r", 0, "php://input", "jane.doe@example.com", true, true], + ["arsse.php import jane.doe@example.com - --flat --replace", 0, "php://input", "jane.doe@example.com", true, true], + ["arsse.php import --flat jane.doe@example.com good.opml -r", 0, "good.opml", "jane.doe@example.com", true, true], + ["arsse.php import jane.doe@example.com bad.opml --replace --flat", 10603, "bad.opml", "jane.doe@example.com", true, true], ]; } } From be92d2f05278a9c0448934b4e58c6271ab88cf70 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Thu, 25 Jul 2019 19:23:35 -0400 Subject: [PATCH 136/142] Documentation update; fixes #168 --- CHANGELOG | 1 + README.md | 34 +++++++++++++++++++++++++++++++--- UPGRADING | 7 ++++++- dist/nginx.conf | 5 +++++ 4 files changed, 43 insertions(+), 4 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index b0b42f4b..961082f9 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -6,6 +6,7 @@ New features: - Command line functionality for clearing a password, disabling the account - Command line options for dealing with Fever passwords - Command line functionality for exporting subscriptions to OPML +- Command line functionality for cron-based feed updating - Command line documentation of all commands and options Bug fixes: diff --git a/README.md b/README.md index f568a4ac..fcabd697 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,14 @@ # The Advanced RSS Environment -The Arsse is a news aggregator server which implements multiple synchronization protocols, including [version 1.2][NCNv1] of [NextCloud News][NCN]' protocol and the [Tiny Tiny RSS][TTRSS] protocol (details below). 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 to maximize compatibility with existing clients. +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 to maximize compatibility with existing clients. Supported protocols are: -At present the software should be considered in an "alpha" state: though its core subsystems are covered by unit tests and should be free of major bugs, not everything has been rigorously tested. Additionally, many features one would expect from other similar software have yet to be implemented. Areas of future work include: +- [NextCloud News][NCNv1] +- [Tiny Tiny RSS][TTRSS] +- [Fever][Fever] -- Providing more sync protocols (Google Reader, Fever, others) +At present the software should be considered in an "alpha" state: many features one would expect from other similar software have yet to be implemented. Areas of future work include: + +- Providing more sync protocols (Google Reader, others) - Better packaging and configuration samples - A user manual @@ -48,6 +52,8 @@ The Arsse includes a `user add []` console command to add u Alternatively, if the Web server is configured to handle authentication, you may set the configuration option `userPreAuth` to `true` and The Arsse will defer to the Web server and automatically add any missing users as it encounters them. +Console commands are also available to import from and export to OPML files. Consult `php arsse.php --help` for full details. + ## Installation from source If installing from the Git repository rather than a download package, you will need to follow extra steps before the instructions in the section above. @@ -194,6 +200,27 @@ Tiny Tiny RSS itself is unaware of HTTP authentication: if HTTP authentication i In all cases, supplying invalid HTTP credentials will result in a 401 response. +### Fever + +Unlike other protocols thus far supported by The Arsse, a reference implementation of [the Fever protocol][Fever] is no longer available: Fever was witdrawn from sale in 2016. Consequently the Arsse's implementation may not replicate all of Fever's functionality correctly. Moreover, some features have been deliberately omitted. + +#### Special considerations + +- Because of Fever's insecure authentication protocol, a Fever-specific password must be created before a user can communicate via the Fever protocol. Consult The Arsse's online help (`php arsse.php --help`) for instructions on how to set the necessary password +- The Fever protocol does not allow for adding or modifying feeds. Another protocol or OPML importing must be used to manage feeds +- Unlike other protocols supported by The Arsse, Fever uses "groups" (more commonly known as tags or labels) instead of folders to organize feeds. Currently OPML importing is the only means of managing groups + +#### Missing features + +- 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 + +#### Other notes + +- The undocumented `group_ids`, `feed_ids`, and `as=unread` parameters are all supported +- XML output is supported, but may not behave as Fever did. JSON output is highly recommended + [newIssue]: https://code.mensbeam.com/MensBeam/arsse/issues/new [Composer]: https://getcomposer.org/ [picoFeed]: https://github.com/miniflux/picoFeed/ @@ -205,3 +232,4 @@ In all cases, supplying invalid HTTP credentials will result in a 401 response. [News+]: https://github.com/noinnion/newsplus/ [ext-feedreader]: https://github.com/jangernert/FeedReader/tree/master/data/tt-rss-feedreader-plugin [ext-newsplus]: https://github.com/hrk/tt-rss-newsplus-plugin +[Fever]: https://web.archive.org/web/20161217042229/https://feedafever.com/api diff --git a/UPGRADING b/UPGRADING index ea3c84ad..c96f41c6 100644 --- a/UPGRADING +++ b/UPGRADING @@ -15,7 +15,12 @@ Upgrading from 0.7.1 to 0.8.0 - The database schema has changed from rev4 to rev5; if upgrading the database manually, apply the 4.sql file - +- Web server configuration has changed to accommodate Fever; the following URL + paths are affected: + - /fever/ +- The following Composer dependencies have been added: + - zendframework/zend-diactoros (version 2.x) + - zendframework/zend-httphandlerrunner Upgrading from 0.5.1 to 0.6.0 ============================= diff --git a/dist/nginx.conf b/dist/nginx.conf index c7dce50f..98e130c8 100644 --- a/dist/nginx.conf +++ b/dist/nginx.conf @@ -48,4 +48,9 @@ server { root /usr/share/arsse/www; try_files $uri =404; } + + # Fever protocol + location /fever/ { + try_files $uri @arsse_no_auth; + } } From 422eaf9605505276391db4d8e3da268d7b57aa2b Mon Sep 17 00:00:00 2001 From: "J. King" Date: Thu, 25 Jul 2019 22:34:58 -0400 Subject: [PATCH 137/142] Invalidate sessions on password change; closes #170 --- CHANGELOG | 3 ++- lib/Database.php | 14 ++++++++++---- lib/User.php | 4 ++++ tests/cases/Database/SeriesSession.php | 10 ++++++++++ 4 files changed, 26 insertions(+), 5 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index 961082f9..caf1e616 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -5,13 +5,14 @@ New features: - Support for the Fever protocol (see README.md for details) - Command line functionality for clearing a password, disabling the account - Command line options for dealing with Fever passwords -- Command line functionality for exporting subscriptions to OPML +- Command line functionality for importing and exporting OPML - Command line functionality for cron-based feed updating - Command line documentation of all commands and options Bug fixes: - Treat command line option -h the same as --help - Sort Tiny Tiny RSS special feeds according to special ordering +- Invalidate sessions when passwords are changed Version 0.7.1 (2019-03-25) ========================== diff --git a/lib/Database.php b/lib/Database.php index daca2344..10e66eb6 100644 --- a/lib/Database.php +++ b/lib/Database.php @@ -340,15 +340,21 @@ class Database { * This function can be used to explicitly invalidate a session after a user logs out * * @param string $user The user who owns the session to be destroyed - * @param string $id The identifier of the session to destroy + * @param string|null $id The identifier of the session to destroy */ - public function sessionDestroy(string $user, string $id): bool { + 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]); } - // delete the session and report success. - return (bool) $this->db->prepare("DELETE FROM arsse_sessions where id = ? and \"user\" = ?", "str", "str")->run($id, $user)->changes(); + 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); + return true; + } else { + // otherwise delete only the specified session and report success. + return (bool) $this->db->prepare("DELETE FROM arsse_sessions where id = ? and \"user\" = ?", "str", "str")->run($id, $user)->changes(); + } } /** Resumes a session, returning available session data diff --git a/lib/User.php b/lib/User.php index 4f529803..691d6faf 100644 --- a/lib/User.php +++ b/lib/User.php @@ -110,6 +110,8 @@ class User { if (Arsse::$db->userExists($user)) { // if the password change was successful and the user exists, set the internal password to the same value Arsse::$db->userPasswordSet($user, $out); + // also invalidate any current sessions for the user + Arsse::$db->sessionDestroy($user); } return $out; } @@ -123,6 +125,8 @@ class User { if (Arsse::$db->userExists($user)) { // if the password change was successful and the user exists, set the internal password to the same value Arsse::$db->userPasswordSet($user, null); + // also invalidate any current sessions for the user + Arsse::$db->sessionDestroy($user); } return $out; } diff --git a/tests/cases/Database/SeriesSession.php b/tests/cases/Database/SeriesSession.php index ad9a45b2..9e8a3884 100644 --- a/tests/cases/Database/SeriesSession.php +++ b/tests/cases/Database/SeriesSession.php @@ -116,6 +116,16 @@ trait SeriesSession { $this->assertFalse(Arsse::$db->sessionDestroy($user, $id)); } + public function testDestroyAllSessions() { + $user = "jane.doe@example.com"; + $this->assertTrue(Arsse::$db->sessionDestroy($user)); + $state = $this->primeExpectations($this->data, ['arsse_sessions' => ["id", "created", "expires", "user"]]); + unset($state['arsse_sessions']['rows'][0]); + unset($state['arsse_sessions']['rows'][1]); + unset($state['arsse_sessions']['rows'][2]); + $this->compareExpectations(static::$drv, $state); + } + public function testDestroyASessionForTheWrongUser() { $user = "john.doe@example.com"; $id = "80fa94c1a11f11e78667001e673b2560"; From cef31907d3d0de1f6dd1dec2bea300c0286bd2c1 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Thu, 25 Jul 2019 22:39:54 -0400 Subject: [PATCH 138/142] Cron functionality is not new --- CHANGELOG | 1 - 1 file changed, 1 deletion(-) diff --git a/CHANGELOG b/CHANGELOG index caf1e616..8e4988d5 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -6,7 +6,6 @@ New features: - Command line functionality for clearing a password, disabling the account - Command line options for dealing with Fever passwords - Command line functionality for importing and exporting OPML -- Command line functionality for cron-based feed updating - Command line documentation of all commands and options Bug fixes: From f7240301e4ff687a15e815ec6ba39d1e944faeea Mon Sep 17 00:00:00 2001 From: "J. King" Date: Fri, 26 Jul 2019 09:37:51 -0400 Subject: [PATCH 139/142] Basic database maintenance Closes #169 --- CHANGELOG | 3 +++ lib/Database.php | 10 ++++++++-- lib/Db/Driver.php | 6 ++++++ lib/Db/MySQL/Driver.php | 13 +++++++++++++ lib/Db/PostgreSQL/Driver.php | 6 ++++++ lib/Db/SQLite3/Driver.php | 6 ++++++ lib/Service.php | 9 +++++++-- tests/cases/Database/SeriesMiscellany.php | 4 ++++ tests/cases/Db/BaseDriver.php | 5 +++++ tests/cases/Db/BaseUpdate.php | 5 +++++ 10 files changed, 63 insertions(+), 4 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index 8e4988d5..4cdc00b4 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -13,6 +13,9 @@ Bug fixes: - Sort Tiny Tiny RSS special feeds according to special ordering - Invalidate sessions when passwords are changed +Changes: +- Perform regular database maintenance to improve long-term performance + Version 0.7.1 (2019-03-25) ========================== diff --git a/lib/Database.php b/lib/Database.php index 10e66eb6..366d84d0 100644 --- a/lib/Database.php +++ b/lib/Database.php @@ -110,6 +110,11 @@ class Database { return $this->db->charsetAcceptable(); } + /** Performs maintenance on the database to ensure good performance */ + public function driverMaintenance(): bool { + return $this->db->maintenance(); + } + /** Computes the column and value text of an SQL "SET" clause, validating arbitrary input against a whitelist * * Returns an indexed array containing the clause text, an array of types, and another array of values @@ -1788,10 +1793,11 @@ class Database { $limitUnread = Date::sub(Arsse::$conf->purgeArticlesUnread); } $feeds = $this->db->query("SELECT id, size from arsse_feeds")->getAll(); + $deleted = 0; foreach ($feeds as $feed) { - $query->run($feed['id'], $feed['size'], $feed['id'], $limitUnread, $limitRead); + $deleted += $query->run($feed['id'], $feed['size'], $feed['id'], $limitUnread, $limitRead)->changes(); } - return true; + return (bool) $deleted; } /** Ensures the specified article exists and raises an exception otherwise diff --git a/lib/Db/Driver.php b/lib/Db/Driver.php index 7f04dc6c..a456fba3 100644 --- a/lib/Db/Driver.php +++ b/lib/Db/Driver.php @@ -82,4 +82,10 @@ interface Driver { * This functionality should be avoided in favour of using statement parameters whenever possible */ public function literalString(string $str): string; + + /** Performs implementation-specific database maintenance to ensure good performance + * + * This should be restricted to quick maintenance; in SQLite terms it might include ANALYZE, but not VACUUM + */ + public function maintenance(): bool; } diff --git a/lib/Db/MySQL/Driver.php b/lib/Db/MySQL/Driver.php index cec575b1..bb9cac82 100644 --- a/lib/Db/MySQL/Driver.php +++ b/lib/Db/MySQL/Driver.php @@ -216,4 +216,17 @@ class Driver extends \JKingWeb\Arsse\Db\AbstractDriver { public function literalString(string $str): string { return "'".$this->db->real_escape_string($str)."'"; } + + public function maintenance(): bool { + // with MySQL each table must be analyzed separately, so we first have to get a list of tables + foreach ($this->query("SHOW TABLES like 'arsse\\_%'") as $table) { + $table = array_pop($table); + if (!preg_match("/^arsse_[a-z_]+$/", $table)) { + // table is not one of ours + continue; // @codeCoverageIgnore + } + $this->query("ANALYZE TABLE $table"); + } + return true; + } } diff --git a/lib/Db/PostgreSQL/Driver.php b/lib/Db/PostgreSQL/Driver.php index 12ad8fcd..7550393b 100644 --- a/lib/Db/PostgreSQL/Driver.php +++ b/lib/Db/PostgreSQL/Driver.php @@ -225,4 +225,10 @@ class Driver extends \JKingWeb\Arsse\Db\AbstractDriver { public function literalString(string $str): string { return pg_escape_literal($this->db, $str); } + + public function maintenance(): bool { + // analyze the database + $this->exec("ANALYZE"); + return true; + } } diff --git a/lib/Db/SQLite3/Driver.php b/lib/Db/SQLite3/Driver.php index 96e345fa..6cf290f5 100644 --- a/lib/Db/SQLite3/Driver.php +++ b/lib/Db/SQLite3/Driver.php @@ -188,4 +188,10 @@ class Driver extends \JKingWeb\Arsse\Db\AbstractDriver { public function literalString(string $str): string { return "'".\SQLite3::escapeString($str)."'"; } + + public function maintenance(): bool { + // analyze the database then checkpoint and truncate the write-ahead log + $this->exec("ANALYZE; PRAGMA wal_checkpoint(truncate)"); + return true; + } } diff --git a/lib/Service.php b/lib/Service.php index bc752aef..93d4e9ba 100644 --- a/lib/Service.php +++ b/lib/Service.php @@ -92,7 +92,12 @@ class Service { } public static function cleanupPost(): bool { - // delete old articles, according to configured threasholds - return Arsse::$db->articleCleanup(); + // delete old articles, according to configured thresholds + $deleted = Arsse::$db->articleCleanup(); + // if any articles were deleted, perform database maintenance + if ($deleted) { + Arsse::$db->driverMaintenance(); + } + return true; } } diff --git a/tests/cases/Database/SeriesMiscellany.php b/tests/cases/Database/SeriesMiscellany.php index 00803567..a7591bbe 100644 --- a/tests/cases/Database/SeriesMiscellany.php +++ b/tests/cases/Database/SeriesMiscellany.php @@ -44,4 +44,8 @@ trait SeriesMiscellany { public function testCheckCharacterSetAcceptability() { $this->assertInternalType("bool", Arsse::$db->driverCharsetAcceptable()); } + + public function testPerformMaintenance() { + $this->assertTrue(Arsse::$db->driverMaintenance()); + } } diff --git a/tests/cases/Db/BaseDriver.php b/tests/cases/Db/BaseDriver.php index 74ef7c97..677339b5 100644 --- a/tests/cases/Db/BaseDriver.php +++ b/tests/cases/Db/BaseDriver.php @@ -382,4 +382,9 @@ abstract class BaseDriver extends \JKingWeb\Arsse\Test\AbstractTest { public function testProduceAStringLiteral() { $this->assertSame("'It''s a string!'", $this->drv->literalString("It's a string!")); } + + public function testPerformMaintenance() { + // this performs maintenance in the absence of tables; see BaseUpdate.php for another test with tables + $this->assertTrue($this->drv->maintenance()); + } } diff --git a/tests/cases/Db/BaseUpdate.php b/tests/cases/Db/BaseUpdate.php index 27806846..e9bc10d0 100644 --- a/tests/cases/Db/BaseUpdate.php +++ b/tests/cases/Db/BaseUpdate.php @@ -130,4 +130,9 @@ class BaseUpdate extends \JKingWeb\Arsse\Test\AbstractTest { $this->assertException("updateTooNew", "Db"); $this->drv->schemaUpdate(-1, $this->base); } + + public function testPerformMaintenance() { + $this->drv->schemaUpdate(Database::SCHEMA_VERSION); + $this->assertTrue($this->drv->maintenance()); + } } From 4282ba1c264ddbe108c416e0016991e3b6733193 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Fri, 26 Jul 2019 09:39:46 -0400 Subject: [PATCH 140/142] Version bump --- lib/Arsse.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/Arsse.php b/lib/Arsse.php index 6de24256..82c43327 100644 --- a/lib/Arsse.php +++ b/lib/Arsse.php @@ -7,7 +7,7 @@ declare(strict_types=1); namespace JKingWeb\Arsse; class Arsse { - const VERSION = "0.7.1"; + const VERSION = "0.8.0"; /** @var Lang */ public static $lang; From 9f7e1c915cb92537cfd1803e2b62043dd9c4cf9a Mon Sep 17 00:00:00 2001 From: "J. King" Date: Fri, 26 Jul 2019 09:42:36 -0400 Subject: [PATCH 141/142] Start after PostgreSQL and MySQL when relevant --- CHANGELOG | 1 + dist/arsse.service | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index 4cdc00b4..de0cc2d6 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -12,6 +12,7 @@ Bug fixes: - Treat command line option -h the same as --help - Sort Tiny Tiny RSS special feeds according to special ordering - Invalidate sessions when passwords are changed +- Correct example systemd unit to start after PostgreSQL and MySQL Changes: - Perform regular database maintenance to improve long-term performance diff --git a/dist/arsse.service b/dist/arsse.service index 3e19ee8c..0adcdae4 100644 --- a/dist/arsse.service +++ b/dist/arsse.service @@ -1,6 +1,6 @@ [Unit] Description=The Arsse feed fetching service -After=network.target +After=network.target mysql.service postgresql.service [Service] User=www-data @@ -12,4 +12,4 @@ StandardError=syslog ExecStart=/usr/bin/env php /usr/share/arsse/arsse.php daemon [Install] -WantedBy=multi-user.target \ No newline at end of file +WantedBy=multi-user.target From 77b719660b9b510b13822ae4cba0c44f094f037b Mon Sep 17 00:00:00 2001 From: "J. King" Date: Fri, 26 Jul 2019 09:43:45 -0400 Subject: [PATCH 142/142] Date 0.8.0 release --- CHANGELOG | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG b/CHANGELOG index de0cc2d6..8e2451e6 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,4 +1,4 @@ -Version 0.8.0 (2019-??-??) +Version 0.8.0 (2019-07-26) ========================== New features: