Browse Source

Validate configuration parameters on import, and other changes

- Each parameter is checked for type and normalized
- Interval strings are converted to DateInterval objects
- Timeouts can be specified as interval strings
- Most intervals can be null to signify infinity
- Driver classes are checked that they implement the correct interface
- Short driver names may be used, and are used by default
- Helpful errors messages are printed in case of erroneous configuration

Exporting is currently broken; this will be fixed in an upcoming commit
microsub
J. King 5 years ago
parent
commit
5cd84c4ab4
  1. 3
      lib/AbstractException.php
  2. 161
      lib/Conf.php
  3. 16
      lib/Database.php
  4. 12
      lib/Db/MySQL/Driver.php
  5. 16
      lib/Db/PostgreSQL/Driver.php
  6. 6
      lib/Db/SQLite3/Driver.php
  7. 12
      lib/Lang.php
  8. 9
      lib/Misc/Date.php
  9. 1
      lib/Misc/ValueInfo.php
  10. 6
      lib/REST.php
  11. 4
      lib/REST/TinyTinyRSS/API.php
  12. 19
      lib/Service.php
  13. 4
      lib/Service/Serial/Driver.php
  14. 4
      lib/Service/Subprocess/Driver.php
  15. 4
      lib/User.php
  16. 100
      locale/en.php
  17. 23
      tests/cases/Conf/TestConf.php
  18. 28
      tests/cases/Database/SeriesCleanup.php
  19. 2
      tests/cases/REST/NextCloudNews/TestV1_2.php
  20. 2
      tests/cases/REST/TinyTinyRSS/TestAPI.php
  21. 22
      tests/cases/Service/TestService.php
  22. 4
      tests/lib/AbstractTest.php

3
lib/AbstractException.php

@ -19,6 +19,7 @@ abstract class AbstractException extends \Exception {
"Lang/Exception.fileCorrupt" => 10104,
"Lang/Exception.stringMissing" => 10105,
"Lang/Exception.stringInvalid" => 10106,
"Lang/Exception.dataInvalid" => 10107,
"Db/Exception.extMissing" => 10201,
"Db/Exception.fileMissing" => 10202,
"Db/Exception.fileUnusable" => 10203,
@ -62,6 +63,8 @@ abstract class AbstractException extends \Exception {
"Conf/Exception.fileUnwritable" => 10304,
"Conf/Exception.fileUncreatable" => 10305,
"Conf/Exception.fileCorrupt" => 10306,
"Conf/Exception.typeMismatch" => 10311,
"Conf/Exception.semanticMismatch" => 10312,
"User/Exception.functionNotImplemented" => 10401,
"User/Exception.doesNotExist" => 10402,
"User/Exception.alreadyExists" => 10403,

161
lib/Conf.php

@ -7,6 +7,8 @@
declare(strict_types=1);
namespace JKingWeb\Arsse;
use JKingWeb\Arsse\Misc\ValueInfo as Value;
/** Class for loading, saving, and querying configuration
*
* The Conf class serves both as a means of importing and querying configuration information, as well as a source for default parameters when a configuration file does not specify a value.
@ -15,19 +17,19 @@ class Conf {
/** @var string Default language to use for logging and errors */
public $lang = "en";
/** @var string Class of the database driver in use (SQLite3 by default) */
public $dbDriver = Db\SQLite3\Driver::class;
/** @var boolean Whether to attempt to automatically update the database when updated to a new version with schema changes */
/** @var string The database driver to use, one of "sqlite3", "postgresql", or "mysql". A fully-qualified class name may also be used for custom drivers */
public $dbDriver = "sqlite3";
/** @var boolean Whether to attempt to automatically update the database when upgrading to a new version with schema changes */
public $dbAutoUpdate = true;
/** @var float Number of seconds to wait before returning a timeout error when connecting to a database (zero waits forever; not applicable to SQLite) */
/** @var \DateInterval Number of seconds to wait before returning a timeout error when connecting to a database (zero waits forever; not applicable to SQLite) */
public $dbTimeoutConnect = 5.0;
/** @var float Number of seconds to wait before returning a timeout error when executing a database operation (zero waits forever; not applicable to SQLite) */
/** @var \DateInterval Number of seconds to wait before returning a timeout error when executing a database operation (zero waits forever; not applicable to SQLite) */
public $dbTimeoutExec = 0.0;
/** @var string|null Full path and file name of SQLite database (if using SQLite) */
public $dbSQLite3File = null;
/** @var string Encryption key to use for SQLite database (if using a version of SQLite with SEE) */
public $dbSQLite3Key = "";
/** @var float Number of seconds for SQLite to wait before returning a timeout error when trying to acquire a write lock on the database (zero does not wait) */
/** @var \DateInterval Number of seconds for SQLite to wait before returning a timeout error when trying to acquire a write lock on the database (zero does not wait) */
public $dbSQLite3Timeout = 60.0;
/** @var string Host name, address, or socket path of PostgreSQL database server (if using PostgreSQL) */
public $dbPostgreSQLHost = "";
@ -43,21 +45,21 @@ class Conf {
public $dbPostgreSQLSchema = "";
/** @var string Service file entry to use (if using PostgreSQL); if using a service entry all above parameters except schema are ignored */
public $dbPostgreSQLService = "";
/** @var string Host name, address, or socket path of MySQL/MariaDB database server (if using MySQL/MariaDB) */
/** @var string Host name or address of MySQL database server (if using MySQL) */
public $dbMySQLHost = "localhost";
/** @var string Log-in user name for MySQL/MariaDB database server (if using MySQL/MariaDB) */
/** @var string Log-in user name for MySQL database server (if using MySQL) */
public $dbMySQLUser = "arsse";
/** @var string Log-in password for MySQL/MariaDB database server (if using MySQL/MariaDB) */
/** @var string Log-in password for MySQL database server (if using MySQL) */
public $dbMySQLPass = "";
/** @var integer Listening port for MySQL/MariaDB database server (if using MySQL/MariaDB over TCP) */
/** @var integer Listening port for MySQL database server (if using MySQL over TCP) */
public $dbMySQLPort = 3306;
/** @var string Database name on MySQL/MariaDB database server (if using MySQL/MariaDB) */
/** @var string Database name on MySQL database server (if using MySQL) */
public $dbMySQLDb = "arsse";
/** @var string Unix domain socket or named pipe to use for MySQL when not connecting over TCP */
public $dbMySQLSocket = "";
/** @var string Class of the user management driver in use (Internal by default) */
public $userDriver = User\Internal\Driver::class;
/** @var string The user management driver to use, currently only "internal". A fully-qualified class name may also be used for custom drivers */
public $userDriver = "internal";
/** @var boolean Whether users are already authenticated by the Web server before the application is executed */
public $userPreAuth = false;
/** @var boolean Whether to require successful HTTP authentication before processing API-level authentication for protocols which have any. Normally the Tiny Tiny RSS relies on its own session-token authentication scheme, for example */
@ -66,43 +68,43 @@ class Conf {
public $userTempPasswordLength = 20;
/** @var boolean Whether invalid or expired API session tokens should prevent logging in when HTTP authentication is used, for protocol which implement their own authentication */
public $userSessionEnforced = true;
/** @var string Period of inactivity after which log-in sessions should be considered invalid, as an ISO 8601 duration (default: 24 hours)
/** @var \DateInterval Period of inactivity after which log-in sessions should be considered invalid, as an ISO 8601 duration (default: 24 hours)
* @see https://en.wikipedia.org/wiki/ISO_8601#Durations */
public $userSessionTimeout = "PT24H";
/** @var string Maximum lifetime of log-in sessions regardless of activity, as an ISO 8601 duration (default: 7 days);
/** @var \DateInterval Maximum lifetime of log-in sessions regardless of activity, as an ISO 8601 duration (default: 7 days);
* @see https://en.wikipedia.org/wiki/ISO_8601#Durations */
public $userSessionLifetime = "P7D";
/** @var string Class of the background feed update service driver in use (Forking by default) */
public $serviceDriver = Service\Forking\Driver::class;
/** @var string The interval between checks for new articles, as an ISO 8601 duration
/** @var string Feed update service driver to use, one of "serial", "subprocess", or "curl". A fully-qualified class name may also be used for custom drivers */
public $serviceDriver = "subprocess";
/** @var \DateInterval The interval between checks for new articles, as an ISO 8601 duration
* @see https://en.wikipedia.org/wiki/ISO_8601#Durations */
public $serviceFrequency = "PT2M";
/** @var integer Number of concurrent feed updates to perform */
public $serviceQueueWidth = 5;
/** @var string The base server address (with scheme, host, port if necessary, and terminal slash) to connect to the server when performing feed updates using cURL */
public $serviceCurlBase = "http://localhost/";
/** @var string The user name to use when performing feed updates using cURL; if none is provided, a temporary name and password will be stored in the database (this is not compatible with pre-authentication) */
public $serviceCurlUser = null;
/** @var string The user name to use when performing feed updates using cURL */
public $serviceCurlUser = "";
/** @var string The password to use when performing feed updates using cURL */
public $serviceCurlPassword = null;
public $serviceCurlPassword = "";
/** @var integer Number of seconds to wait for data when fetching feeds from foreign servers */
/** @var \DateInterval Number of seconds to wait for data when fetching feeds from foreign servers */
public $fetchTimeout = 10;
/** @var integer Maximum size, in bytes, of data when fetching feeds from foreign servers */
public $fetchSizeLimit = 2 * 1024 * 1024;
/** @var boolean Whether to allow the possibility of fetching full article contents using an item's URL. Whether fetching will actually happen is also governed by a per-feed setting */
public $fetchEnableScraping = true;
/** @var string|null User-Agent string to use when fetching feeds from foreign servers */
public $fetchUserAgentString;
public $fetchUserAgentString = null;
/** @var string When to delete a feed from the database after all its subscriptions have been deleted, as an ISO 8601 duration (default: 24 hours; empty string for never)
/** @var \DateInterval|null When to delete a feed from the database after all its subscriptions have been deleted, as an ISO 8601 duration (default: 24 hours; null for never)
* @see https://en.wikipedia.org/wiki/ISO_8601#Durations */
public $purgeFeeds = "PT24H";
/** @var string When to delete an unstarred article in the database after it has been marked read by all users, as an ISO 8601 duration (default: 7 days; empty string for never)
/** @var \DateInterval|null When to delete an unstarred article in the database after it has been marked read by all users, as an ISO 8601 duration (default: 7 days; null for never)
* @see https://en.wikipedia.org/wiki/ISO_8601#Durations */
public $purgeArticlesRead = "P7D";
/** @var string When to delete an unstarred article in the database regardless of its read state, as an ISO 8601 duration (default: 21 days; empty string for never)
/** @var \DateInterval|null When to delete an unstarred article in the database regardless of its read state, as an ISO 8601 duration (default: 21 days; null for never)
* @see https://en.wikipedia.org/wiki/ISO_8601#Durations */
public $purgeArticlesUnread = "P21D";
@ -113,10 +115,26 @@ class Conf {
/** @var string Space-separated list of origins from which to deny cross-origin resource sharing */
public $httpOriginsDenied = "";
const TYPE_NAMES = [
Value::T_BOOL => "boolean",
Value::T_STRING => "string",
Value::T_FLOAT => "float",
VALUE::T_INT => "integer",
Value::T_INTERVAL => "interval",
];
protected static $types = [];
/** Creates a new configuration object
* @param string $import_file Optional file to read configuration data from
* @see self::importFile() */
public function __construct(string $import_file = "") {
if (!static::$types) {
static::$types = $this->propertyDiscover();
}
foreach (array_keys(static::$types) as $prop) {
$this->$prop = $this->propertyImport($prop, $this->$prop);
}
if ($import_file !== "") {
$this->importFile($import_file);
}
@ -124,7 +142,7 @@ class Conf {
/** Layers configuration data from a file into an existing object
*
* The file must be a PHP script which return an array with keys that match the properties of the Conf class. Malformed files will throw an exception; unknown keys are silently ignored. Files may be imported is succession, though this is not currently used.
* The file must be a PHP script which returns an array with keys that match the properties of the Conf class. Malformed files will throw an exception; unknown keys are silently accepted. Files may be imported in succession, though this is not currently used.
* @param string $file Full path and file name for the file to import */
public function importFile(string $file): self {
if (!file_exists($file)) {
@ -143,16 +161,22 @@ class Conf {
if (!is_array($arr)) {
throw new Conf\Exception("fileCorrupt", $file);
}
return $this->import($arr);
return $this->importData($arr, $file);
}
/** Layers configuration data from an associative array into an existing object
*
* The input array must have keys that match the properties of the Conf class; unknown keys are silently ignored. Arrays may be imported is succession, though this is not currently used.
* The input array must have keys that match the properties of the Conf class; unknown keys are silently accepted. Arrays may be imported in succession, though this is not currently used.
* @param mixed[] $arr Array of configuration parameters to export */
public function import(array $arr): self {
$file = debug_backtrace(\DEBUG_BACKTRACE_IGNORE_ARGS, 1)[0]['file'] ?? "";
return $this->importData($arr, $file);
}
/** Layers configuration data from an associative array into an existing object */
protected function importData(array $arr, string $file): self {
foreach ($arr as $key => $value) {
$this->$key = $value;
$this->$key = $this->propertyImport($key, $value, $file);
}
return $this;
}
@ -165,7 +189,7 @@ class Conf {
$conf = new \ReflectionObject($this);
foreach ($conf->getProperties(\ReflectionProperty::IS_PUBLIC) as $prop) {
$name = $prop->name;
// add the property to the output if the value is scalar (or null) and either:
// add the property to the output if the value is of a supported type and either:
// 1. full output has been requested
// 2. the property is not defined in the class
// 3. it differs from the default
@ -211,4 +235,79 @@ class Conf {
}
return true;
}
/** Caches information about configuration properties for later access */
protected function propertyDiscover(): array {
$out = [];
$rc = new \ReflectionClass($this);
foreach ($rc->getProperties(\ReflectionProperty::IS_PUBLIC) as $p) {
if (preg_match("/@var\s+((?:int(eger)?|float|bool(ean)?|string|\\\\DateInterval)(?:\|null)?)[^\[]/", $p->getDocComment(), $match)) {
$match = explode("|", $match[1]);
$nullable = (sizeof($match) > 1);
$type = [
'string' => Value::T_STRING | Value::M_STRICT,
'integer' => Value::T_INT | Value::M_STRICT,
'boolean' => Value::T_BOOL | Value::M_STRICT,
'float' => Value::T_FLOAT | Value::M_STRICT,
'\\DateInterval' => Value::T_INTERVAL | Value::M_LOOSE,
][$match[0]];
if ($nullable) {
$type |= Value::M_NULL;
}
} else {
$type = Value::T_MIXED; // @codeCoverageIgnore
}
$out[$p->name] = ['name' => $match[0], 'const' => $type];
}
return $out;
}
protected function propertyImport(string $key, $value, string $file = "") {
try {
$typeName = static::$types[$key]['name'] ?? "mixed";
$typeConst = static::$types[$key]['const'] ?? Value::T_MIXED;
if ($typeName === "\\DateInterval") {
// date intervals have special handling: if the existing value (ultimately, the default value)
// is an integer or float, the new value should be imported as numeric. If the new value is a string
// it is first converted to an interval and then converted to the numeric type if necessary
if (is_string($value)) {
$value = Value::normalize($value, Value::T_INTERVAL | Value::M_STRICT);
}
switch (gettype($this->$key)) {
case "integer":
return Value::normalize($value, Value::T_INT | Value::M_STRICT);
case "double":
return Value::normalize($value, Value::T_FLOAT | Value::M_STRICT);
case "string":
case "object":
return $value;
default:
throw new ExceptionType("strictFailure"); // @codeCoverageIgnore
}
}
$value = Value::normalize($value, $typeConst);
switch ($key) {
case "dbDriver":
$driver = $driver ?? Database::DRIVER_NAMES[strtolower($value)] ?? $value;
$interface = $interface ?? Db\Driver::class;
// no break
case "userDriver":
$driver = $driver ?? User::DRIVER_NAMES[strtolower($value)] ?? $value;
$interface = $interface ?? User\Driver::class;
// no break
case "serviceDriver":
$driver = $driver ?? Service::DRIVER_NAMES[strtolower($value)] ?? $value;
$interface = $interface ?? Service\Driver::class;
if (!is_subclass_of($driver, $interface)) {
throw new Conf\Exception("semanticMismatch", ['param' => $key, 'file' => $file]);
}
return $driver;
}
return $value;
} catch (ExceptionType $e) {
$nullable = (int) (bool) (static::$types[$key] & Value::M_NULL);
$type = static::$types[$key]['const'] & ~(Value::M_STRICT | Value::M_DROP | Value::M_NULL | Value::M_ARRAY);
throw new Conf\Exception("typeMismatch", ['param' => $key, 'type' => self::TYPE_NAMES[$type], 'file' => $file, 'nullable' => $nullable]);
}
}
}

16
lib/Database.php

@ -16,6 +16,11 @@ use JKingWeb\Arsse\Misc\ValueInfo;
class Database {
const SCHEMA_VERSION = 4;
const LIMIT_ARTICLES = 50;
const DRIVER_NAMES = [
'sqlite3' => \JKingWeb\Arsse\Db\SQLite3\Driver::class,
'postgresql' => \JKingWeb\Arsse\Db\PostgreSQL\Driver::class,
'mysql' => \JKingWeb\Arsse\Db\MySQL\Driver::class,
];
/** @var Db\Driver */
public $db;
@ -760,14 +765,13 @@ class Database {
$this->db->query("UPDATE arsse_feeds set orphaned = null where exists(SELECT id from arsse_subscriptions where feed = arsse_feeds.id)");
// 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)");
// finally delete feeds that have been orphaned longer than the retention period
$limit = Date::normalize("now");
// finally delete feeds that have been orphaned longer than the retention period, if a a purge threshold has been specified
if (Arsse::$conf->purgeFeeds) {
// if there is a retention period specified, compute it; otherwise feed are deleted immediatelty
$limit = Date::sub(Arsse::$conf->purgeFeeds, $limit);
$limit = Date::sub(Arsse::$conf->purgeFeeds);
$out = (bool) $this->db->prepare("DELETE from arsse_feeds where orphaned <= ?", "datetime")->run($limit);
} else {
$out = false;
}
$out = (bool) $this->db->prepare("DELETE from arsse_feeds where orphaned <= ?", "datetime")->run($limit);
// commit changes and return
$tr->commit();
return $out;
}

12
lib/Db/MySQL/Driver.php

@ -28,12 +28,12 @@ class Driver extends \JKingWeb\Arsse\Db\AbstractDriver {
if (!static::requirementsMet()) {
throw new Exception("extMissing", static::driverName()); // @codeCoverageIgnore
}
$host = strtolower(!strlen((string) Arsse::$conf->dbMySQLHost) ? "localhost" : Arsse::$conf->dbMySQLHost);
$socket = strlen((string) Arsse::$conf->dbMySQLSocket) ? Arsse::$conf->dbMySQLSocket : ini_get("mysqli.default_socket");
$user = Arsse::$conf->dbMySQLUser ?? "";
$pass = Arsse::$conf->dbMySQLPass ?? "";
$port = Arsse::$conf->dbMySQLPost ?? 3306;
$db = Arsse::$conf->dbMySQLDb ?? "arsse";
$host = strtolower(!strlen(Arsse::$conf->dbMySQLHost) ? "localhost" : Arsse::$conf->dbMySQLHost);
$socket = strlen(Arsse::$conf->dbMySQLSocket) ? Arsse::$conf->dbMySQLSocket : ini_get("mysqli.default_socket");
$user = Arsse::$conf->dbMySQLUser;
$pass = Arsse::$conf->dbMySQLPass;
$port = Arsse::$conf->dbMySQLPort;
$db = Arsse::$conf->dbMySQLDb;
// make the connection
$this->makeConnection($db, $user, $pass, $host, $port, $socket);
// set session variables

16
lib/Db/PostgreSQL/Driver.php

@ -25,13 +25,13 @@ class Driver extends \JKingWeb\Arsse\Db\AbstractDriver {
if (!static::requirementsMet()) {
throw new Exception("extMissing", static::driverName()); // @codeCoverageIgnore
}
$user = $user ?? Arsse::$conf->dbPostgreSQLUser;
$pass = $pass ?? Arsse::$conf->dbPostgreSQLPass;
$db = $db ?? Arsse::$conf->dbPostgreSQLDb;
$host = $host ?? Arsse::$conf->dbPostgreSQLHost;
$port = $port ?? Arsse::$conf->dbPostgreSQLPort;
$schema = $schema ?? Arsse::$conf->dbPostgreSQLSchema;
$service = $service ?? Arsse::$conf->dbPostgreSQLService;
$user = Arsse::$conf->dbPostgreSQLUser;
$pass = Arsse::$conf->dbPostgreSQLPass;
$db = Arsse::$conf->dbPostgreSQLDb;
$host = Arsse::$conf->dbPostgreSQLHost;
$port = Arsse::$conf->dbPostgreSQLPort;
$schema = Arsse::$conf->dbPostgreSQLSchema;
$service = Arsse::$conf->dbPostgreSQLService;
$this->makeConnection($user, $pass, $db, $host, $port, $service);
foreach (static::makeSetupQueries($schema) as $q) {
$this->exec($q);
@ -42,7 +42,7 @@ class Driver extends \JKingWeb\Arsse\Db\AbstractDriver {
$base = [
'client_encoding' => "UTF8",
'application_name' => "arsse",
'connect_timeout' => (string) ceil(Arsse::$conf->dbTimeoutConnect ?? 0),
'connect_timeout' => (string) ceil(Arsse::$conf->dbTimeoutConnect),
];
$out = [];
if ($service != "") {

6
lib/Db/SQLite3/Driver.php

@ -28,8 +28,8 @@ class Driver extends \JKingWeb\Arsse\Db\AbstractDriver {
throw new Exception("extMissing", static::driverName()); // @codeCoverageIgnore
}
// if no database file is specified in the configuration, use a suitable default
$dbFile = $dbFile ?? Arsse::$conf->dbSQLite3File ?? \JKingWeb\Arsse\BASE."arsse.db";
$dbKey = $dbKey ?? Arsse::$conf->dbSQLite3Key;
$dbFile = Arsse::$conf->dbSQLite3File ?? \JKingWeb\Arsse\BASE."arsse.db";
$dbKey = Arsse::$conf->dbSQLite3Key;
$timeout = Arsse::$conf->dbSQLite3Timeout * 1000;
try {
$this->makeConnection($dbFile, $dbKey);
@ -55,7 +55,7 @@ class Driver extends \JKingWeb\Arsse\Db\AbstractDriver {
throw new Exception("fileCorrupt", $dbFile);
}
// set the timeout
$timeout = (int) ceil((Arsse::$conf->dbSQLite3Timeout ?? 0) * 1000);
$timeout = (int) ceil(Arsse::$conf->dbSQLite3Timeout * 1000);
$this->setTimeout($timeout);
// set other initial options
$this->exec("PRAGMA foreign_keys = yes");

12
lib/Lang.php

@ -26,6 +26,8 @@ class Lang {
protected $locale = ""; // the currently loaded locale
protected $loaded = []; // the cascade of loaded locale file names
protected $strings = self::REQUIRED; // the loaded locale strings, merged
/** @var \MessageFormatter */
protected $formatter;
public function __construct(string $path = BASE."locale".DIRECTORY_SEPARATOR) {
$this->path = $path;
@ -101,9 +103,13 @@ class Lang {
} elseif (!is_array($vars)) {
$vars = [$vars];
}
$msg = \MessageFormatter::formatMessage($this->locale, $msg, $vars);
$this->formatter = $this->formatter ?? new \MessageFormatter($this->locale, "Initial message");
if (!$this->formatter->setPattern($msg)) {
throw new Lang\Exception("stringInvalid", ['error' => $this->formatter->getErrorMessage(), 'msgID' => $msgID, 'fileList' => implode(", ", $this->loaded)]);
}
$msg = $this->formatter->format($vars);
if ($msg===false) {
throw new Lang\Exception("stringInvalid", ['msgID' => $msgID, 'fileList' => implode(", ", $this->loaded)]);
throw new Lang\Exception("dataInvalid", ['error' => $this->formatter->getErrorMessage(), 'msgID' => $msgID, 'fileList' => implode(", ", $this->loaded)]); // @codeCoverageIgnore
}
return $msg;
}
@ -159,6 +165,7 @@ class Lang {
$this->strings = self::REQUIRED;
$this->locale = $this->wanted;
$this->synched = true;
$this->formatter = null;
return true;
}
// decompose the requested locale from specific to general, building a list of files to load
@ -217,6 +224,7 @@ class Lang {
$this->loaded = $loaded;
$this->locale = $this->wanted;
$this->synched = true;
$this->formatter = null;
return true;
}
}

9
lib/Misc/Date.php

@ -25,16 +25,17 @@ class Date {
return ValueInfo::normalize($date, ValueInfo::T_DATE, $inFormat);
}
public static function add(string $interval, $date = "now") {
public static function add($interval, $date = "now") {
return self::modify("add", $interval, $date);
}
public static function sub(string $interval, $date = "now") {
public static function sub($interval, $date = "now") {
return self::modify("sub", $interval, $date);
}
protected static function modify(string $func, string $interval, $date) {
protected static function modify(string $func, $interval, $date) {
$date = self::normalize($date);
return $date ? $date->$func(new \DateInterval($interval)) : null;
$interval = (!$interval instanceof \DateInterval) ? ValueInfo::normalize($interval, ValueInfo::T_INTERVAL) : $interval;
return $date ? $date->$func($interval) : null;
}
}

1
lib/Misc/ValueInfo.php

@ -30,6 +30,7 @@ class ValueInfo {
const T_ARRAY = 7; // convert to array
const T_INTERVAL = 8; // convert to time interval
// normalization modes
const M_LOOSE = 0;
const M_NULL = 1 << 28; // pass nulls through regardless of target type
const M_DROP = 1 << 29; // drop the value (return null) if the type doesn't match
const M_STRICT = 1 << 30; // throw an exception if the type doesn't match

6
lib/REST.php

@ -146,7 +146,7 @@ class REST {
}
public function challenge(ResponseInterface $res, string $realm = null): ResponseInterface {
$realm = $realm ?? Arsse::$conf->httpRealm ?? "Default";
$realm = $realm ?? Arsse::$conf->httpRealm;
return $res->withAddedHeader("WWW-Authenticate", 'Basic realm="'.$realm.'"');
}
@ -205,8 +205,8 @@ class REST {
}
public function corsNegotiate(RequestInterface $req, string $allowed = null, string $denied = null): bool {
$allowed = trim($allowed ?? Arsse::$conf->httpOriginsAllowed ?? "");
$denied = trim($denied ?? Arsse::$conf->httpOriginsDenied ?? "");
$allowed = trim($allowed ?? Arsse::$conf->httpOriginsAllowed);
$denied = trim($denied ?? Arsse::$conf->httpOriginsDenied);
// continue if at least one origin is allowed
if ($allowed) {
// continue if the request has exactly one Origin header

4
lib/REST/TinyTinyRSS/API.php

@ -461,7 +461,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler {
}
$children = $c['children'] ? $this->enumerateCategories($cats, $subs, $c['id'], $all) : ['list' => [], 'feeds' => 0];
$feeds = $c['feeds'] ? $this->enumerateFeeds($subs, $c['id']) : [];
$count = sizeof($feeds) + $children['feeds'];
$count = sizeof($feeds) + (int) $children['feeds'];
$out[] = [
'name' => $c['name'],
'id' => "CAT:".$c['id'],
@ -472,7 +472,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler {
'unread' => 0,
'child_unread' => 0,
'checkbox' => false,
'param' => Arsse::$lang->msg("API.TTRSS.FeedCount", $count),
'param' => Arsse::$lang->msg("API.TTRSS.FeedCount", (string) $count),
'items' => array_merge($children['list'], $feeds),
];
$feedTotal += $count;

19
lib/Service.php

@ -9,6 +9,11 @@ namespace JKingWeb\Arsse;
use JKingWeb\Arsse\Misc\Date;
class Service {
const DRIVER_NAMES = [
'serial' => \JKingWeb\Arsse\Service\Serial\Driver::class,
'subprocess' => \JKingWeb\Arsse\Service\Subprocess\Driver::class,
'curl' => \JKingWeb\Arsse\Service\Curl\Driver::class,
];
/** @var Service\Driver */
protected $drv;
@ -27,18 +32,10 @@ class Service {
return $classes;
}
public static function interval(): \DateInterval {
try {
return new \DateInterval(Arsse::$conf->serviceFrequency);
} catch (\Exception $e) {
return new \DateInterval("PT2M");
}
}
public function __construct() {
$driver = Arsse::$conf->serviceDriver;
$this->drv = new $driver();
$this->interval = static::interval();
$this->interval = Arsse::$conf->serviceFrequency;
}
public function watch(bool $loop = true): \DateTimeInterface {
@ -77,8 +74,8 @@ class Service {
// convert the check-in timestamp to a DateTime instance
$checkin = Date::normalize($checkin, "sql");
// get the checking interval
$int = static::interval();
// subtract twice the checking interval from the current time to the earliest acceptable check-in time
$int = Arsse::$conf->serviceFrequency;
// subtract twice the checking interval from the current time to yield the earliest acceptable check-in time
$limit = new \DateTime();
$limit->sub($int);
$limit->sub($int);

4
lib/Service/Internal/Driver.php → lib/Service/Serial/Driver.php

@ -4,7 +4,7 @@
* See LICENSE and AUTHORS files for details */
declare(strict_types=1);
namespace JKingWeb\Arsse\Service\Internal;
namespace JKingWeb\Arsse\Service\Serial;
use JKingWeb\Arsse\Arsse;
@ -12,7 +12,7 @@ class Driver implements \JKingWeb\Arsse\Service\Driver {
protected $queue = [];
public static function driverName(): string {
return Arsse::$lang->msg("Driver.Service.Internal.Name");
return Arsse::$lang->msg("Driver.Service.Serial.Name");
}
public static function requirementsMet(): bool {

4
lib/Service/Forking/Driver.php → lib/Service/Subprocess/Driver.php

@ -4,7 +4,7 @@
* See LICENSE and AUTHORS files for details */
declare(strict_types=1);
namespace JKingWeb\Arsse\Service\Forking;
namespace JKingWeb\Arsse\Service\Subprocess;
use JKingWeb\Arsse\Arsse;
@ -12,7 +12,7 @@ class Driver implements \JKingWeb\Arsse\Service\Driver {
protected $queue = [];
public static function driverName(): string {
return Arsse::$lang->msg("Driver.Service.Forking.Name");
return Arsse::$lang->msg("Driver.Service.Subprocess.Name");
}
public static function requirementsMet(): bool {

4
lib/User.php

@ -9,6 +9,10 @@ namespace JKingWeb\Arsse;
use PasswordGenerator\Generator as PassGen;
class User {
const DRIVER_NAMES = [
'internal' => \JKingWeb\Arsse\User\Internal\Driver::class,
];
public $id = null;
/**

100
locale/en.php

@ -16,7 +16,7 @@ return [
'API.TTRSS.Feed.Published' => 'Published articles',
'API.TTRSS.Feed.Archived' => 'Archived articles',
'API.TTRSS.Feed.Read' => 'Recently read',
'API.TTRSS.FeedCount' => '{0, select, 1 {(1 feed)} other {({0} feeds)}}',
'API.TTRSS.FeedCount' => '({0, number} {0, plural, one {feed} other {feeds}})',
'Driver.Db.SQLite3.Name' => 'SQLite 3',
'Driver.Db.SQLite3PDO.Name' => 'SQLite 3 (PDO)',
@ -24,74 +24,16 @@ return [
'Driver.Db.PostgreSQLPDO.Name' => 'PostgreSQL (PDO)',
'Driver.Db.MySQL.Name' => 'MySQL',
'Driver.Db.MySQLPDO.Name' => 'MySQL (PDO)',
'Driver.Service.Curl.Name' => 'HTTP (curl)',
'Driver.Service.Internal.Name' => 'Internal',
'Driver.User.Internal.Name' => 'Internal',
'HTTP.Status.100' => 'Continue',
'HTTP.Status.101' => 'Switching Protocols',
'HTTP.Status.102' => 'Processing',
'HTTP.Status.200' => 'OK',
'HTTP.Status.201' => 'Created',
'HTTP.Status.202' => 'Accepted',
'HTTP.Status.203' => 'Non-Authoritative Information',
'HTTP.Status.204' => 'No Content',
'HTTP.Status.205' => 'Reset Content',
'HTTP.Status.206' => 'Partial Content',
'HTTP.Status.207' => 'Multi-Status',
'HTTP.Status.208' => 'Already Reported',
'HTTP.Status.226' => 'IM Used',
'HTTP.Status.300' => 'Multiple Choice',
'HTTP.Status.301' => 'Moved Permanently',
'HTTP.Status.302' => 'Found',
'HTTP.Status.303' => 'See Other',
'HTTP.Status.304' => 'Not Modified',
'HTTP.Status.305' => 'Use Proxy',
'HTTP.Status.306' => 'Switch Proxy',
'HTTP.Status.307' => 'Temporary Redirect',
'HTTP.Status.308' => 'Permanent Redirect',
'HTTP.Status.400' => 'Bad Request',
'HTTP.Status.401' => 'Unauthorized',
'HTTP.Status.402' => 'Payment Required',
'HTTP.Status.403' => 'Forbidden',
'HTTP.Status.404' => 'Not Found',
'HTTP.Status.405' => 'Method Not Allowed',
'HTTP.Status.406' => 'Not Acceptable',
'HTTP.Status.407' => 'Proxy Authentication Required',
'HTTP.Status.408' => 'Request Timeout',
'HTTP.Status.409' => 'Conflict',
'HTTP.Status.410' => 'Gone',
'HTTP.Status.411' => 'Length Required',
'HTTP.Status.412' => 'Precondition Failed',
'HTTP.Status.413' => 'Payload Too Large',
'HTTP.Status.414' => 'URL Too Long',
'HTTP.Status.415' => 'Unsupported Media Type',
'HTTP.Status.416' => 'Range Not Satisfiable',
'HTTP.Status.417' => 'Expectation Failed',
'HTTP.Status.421' => 'Misdirected Request',
'HTTP.Status.422' => 'Unprocessable Entity',
'HTTP.Status.423' => 'Locked',
'HTTP.Status.424' => 'Failed Depedency',
'HTTP.Status.426' => 'Upgrade Required',
'HTTP.Status.428' => 'Precondition Failed',
'HTTP.Status.429' => 'Too Many Requests',
'HTTP.Status.431' => 'Request Header Fields Too Large',
'HTTP.Status.451' => 'Unavailable For Legal Reasons',
'HTTP.Status.500' => 'Internal Server Error',
'HTTP.Status.501' => 'Not Implemented',
'HTTP.Status.502' => 'Bad Gateway',
'HTTP.Status.503' => 'Service Unavailable',
'HTTP.Status.504' => 'Gateway Timeout',
'HTTP.Status.505' => 'HTTP Version Not Supported',
'HTTP.Status.506' => 'Variant Also Negotiates',
'HTTP.Status.507' => 'Insufficient Storage',
'HTTP.Status.508' => 'Loop Detected',
'HTTP.Status.510' => 'Not Extended',
'HTTP.Status.511' => 'Network Authentication Required',
'Driver.Service.Serial.Name' => 'Serialized',
'Driver.Service.Subprocess.Name' => 'Concurrent subprocess',
'Driver.Service.Curl.Name' => 'Concurrent HTTP (curl)',
'Driver.User.Internal.Name' => 'Internal',
// this should only be encountered in testing (because tests should cover all exceptions!)
// indicates programming error
'Exception.JKingWeb/Arsse/Exception.uncoded' => 'The specified exception symbol {0} has no code specified in AbstractException.php',
// this should not usually be encountered
// indicates programming error
'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',
@ -103,20 +45,36 @@ return [
5 {datetime}
6 {string}
7 {array}
other {requested type}
8 {DateInterval}
other {requested type {0}}
}',
// indicates programming error
'Exception.JKingWeb/Arsse/ExceptionType.typeUnknown' => 'Normalization type {0} is not implemented',
'Exception.JKingWeb/Arsse/Lang/Exception.defaultFileMissing' => 'Default language file "{0}" missing',
'Exception.JKingWeb/Arsse/Lang/Exception.fileMissing' => 'Language file "{0}" is not available',
'Exception.JKingWeb/Arsse/Lang/Exception.fileUnreadable' => 'Insufficient permissions to read language file "{0}"',
'Exception.JKingWeb/Arsse/Lang/Exception.fileCorrupt' => 'Language file "{0}" is corrupt or does not conform to expected format',
'Exception.JKingWeb/Arsse/Lang/Exception.stringMissing' => 'Message string "{msgID}" missing from all loaded language files ({fileList})',
'Exception.JKingWeb/Arsse/Lang/Exception.stringInvalid' => 'Message string "{msgID}" is not a valid ICU message string (language files loaded: {fileList})',
'Exception.JKingWeb/Arsse/Lang/Exception.stringInvalid' => 'Message string "{msgID}" is not a valid ICU message string (language files loaded: {fileList}): {error}',
'Exception.JKingWeb/Arsse/Lang/Exception.dataInvalid' => 'Failed to format message message string "{msgID}" (language files loaded: {fileList}): {error}',
'Exception.JKingWeb/Arsse/Conf/Exception.fileMissing' => 'Configuration file "{0}" does not exist',
'Exception.JKingWeb/Arsse/Conf/Exception.fileUnreadable' => 'Insufficient permissions to read configuration file "{0}"',
'Exception.JKingWeb/Arsse/Conf/Exception.fileUncreatable' => 'Insufficient permissions to write new configuration file "{0}"',
'Exception.JKingWeb/Arsse/Conf/Exception.fileUnwritable' => 'Insufficient permissions to overwrite configuration file "{0}"',
'Exception.JKingWeb/Arsse/Conf/Exception.fileCorrupt' => 'Configuration file "{0}" is corrupt or does not conform to expected format',
'Exception.JKingWeb/Arsse/Conf/Exception.typeMismatch' =>
'Configuration parameter "{param}" in file "{file}" must be {type, select,
integer {an integral number}
string {a character string}
boolean {either true or false}
float {a decimal number}
interval {an ISO 8601 time interval}
other {consistent with type "{type}"}
}{nullable, select,
0 {}
other {, or null}
}',
'Exception.JKingWeb/Arsse/Conf/Exception.semanticMismatch' => 'Configuration parameter "{param}" in file "{file}" is not a valid value. Consult the documentation for possible values',
'Exception.JKingWeb/Arsse/Db/Exception.extMissing' => 'Required PHP extension for driver "{0}" not installed',
'Exception.JKingWeb/Arsse/Db/Exception.fileMissing' => 'Database file "{0}" does not exist',
'Exception.JKingWeb/Arsse/Db/Exception.fileUnreadable' => 'Insufficient permissions to open database file "{0}" for reading',
@ -125,7 +83,9 @@ return [
'Exception.JKingWeb/Arsse/Db/Exception.fileUncreatable' => 'Insufficient permissions to create new database file "{0}"',
'Exception.JKingWeb/Arsse/Db/Exception.fileCorrupt' => 'Database file "{0}" is corrupt or not a valid database',
'Exception.JKingWeb/Arsse/Db/Exception.connectionFailure' => 'Could not connect to {engine} database: {message}',
// indicates programming error
'Exception.JKingWeb/Arsse/Db/Exception.paramTypeInvalid' => 'Prepared statement parameter type "{0}" is invalid',
// indicates programming error
'Exception.JKingWeb/Arsse/Db/Exception.paramTypeUnknown' => 'Prepared statement parameter type "{0}" is valid, but not implemented',
'Exception.JKingWeb/Arsse/Db/Exception.paramTypeMissing' => 'Prepared statement parameter type for parameter #{0} was not specified',
'Exception.JKingWeb/Arsse/Db/Exception.updateManual' =>
@ -149,9 +109,13 @@ return [
other {Automatic updating of the {driver_name} database failed because its version, {current}, is newer than the requested version, {target}}
}',
'Exception.JKingWeb/Arsse/Db/Exception.engineErrorGeneral' => '{0}',
// indicates programming error
'Exception.JKingWeb/Arsse/Db/Exception.savepointStatusUnknown' => 'Savepoint status code {0} not implemented',
// indicates programming error
'Exception.JKingWeb/Arsse/Db/Exception.savepointInvalid' => 'Tried to {action} invalid savepoint {index}',
// indicates programming error
'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/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',

23
tests/cases/Conf/TestConf.php

@ -44,7 +44,10 @@ class TestConf extends \JKingWeb\Arsse\Test\AbstractTest {
/** @depends testLoadDefaultValues */
public function testImportFromArray() {
$arr = ['lang' => "xx"];
$arr = [
'lang' => "xx",
'purgeFeeds' => "P2D",
];
$conf = new Conf;
$conf->import($arr);
$this->assertEquals("xx", $conf->lang);
@ -96,6 +99,24 @@ class TestConf extends \JKingWeb\Arsse\Test\AbstractTest {
$conf = new Conf(self::$path."confCorrupt");
}
public function testImportBogusValue() {
$arr = [
'dbAutoUpdate' => "yes, please",
];
$conf = new Conf;
$this->assertException("typeMismatch", "Conf");
$conf->import($arr);
}
public function testImportBogusDriver() {
$arr = [
'dbDriver' => "this driver does not exist",
];
$conf = new Conf;
$this->assertException("semanticMismatch", "Conf");
$conf->import($arr);
}
public function testExportToArray() {
$conf = new Conf;
$conf->lang = ["en", "fr"]; // should not be exported: not scalar

28
tests/cases/Database/SeriesCleanup.php

@ -151,6 +151,20 @@ trait SeriesCleanup {
$this->compareExpectations($state);
}
public function testCleanUpOrphanedFeedsWithUnlimitedRetention() {
Arsse::$conf->import([
'purgeFeeds' => null,
]);
Arsse::$db->feedCleanup();
$now = gmdate("Y-m-d H:i:s");
$state = $this->primeExpectations($this->data, [
'arsse_feeds' => ["id","orphaned"]
]);
$state['arsse_feeds']['rows'][0][1] = null;
$state['arsse_feeds']['rows'][2][1] = $now;
$this->compareExpectations($state);
}
public function testCleanUpOldArticlesWithStandardRetention() {
Arsse::$db->articleCleanup();
$state = $this->primeExpectations($this->data, [
@ -163,7 +177,9 @@ trait SeriesCleanup {
}
public function testCleanUpOldArticlesWithUnlimitedReadRetention() {
Arsse::$conf->purgeArticlesRead = "";
Arsse::$conf->import([
'purgeArticlesRead' => null,
]);
Arsse::$db->articleCleanup();
$state = $this->primeExpectations($this->data, [
'arsse_articles' => ["id"]
@ -175,7 +191,9 @@ trait SeriesCleanup {
}
public function testCleanUpOldArticlesWithUnlimitedUnreadRetention() {
Arsse::$conf->purgeArticlesUnread = "";
Arsse::$conf->import([
'purgeArticlesUnread' => null,
]);
Arsse::$db->articleCleanup();
$state = $this->primeExpectations($this->data, [
'arsse_articles' => ["id"]
@ -187,8 +205,10 @@ trait SeriesCleanup {
}
public function testCleanUpOldArticlesWithUnlimitedRetention() {
Arsse::$conf->purgeArticlesRead = "";
Arsse::$conf->purgeArticlesUnread = "";
Arsse::$conf->import([
'purgeArticlesRead' => null,
'purgeArticlesUnread' => null,
]);
Arsse::$db->articleCleanup();
$state = $this->primeExpectations($this->data, [
'arsse_articles' => ["id"]

2
tests/cases/REST/NextCloudNews/TestV1_2.php

@ -896,7 +896,7 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest {
}
public function testQueryTheServerStatus() {
$interval = Service::interval();
$interval = Arsse::$conf->serviceFrequency;
$valid = (new \DateTimeImmutable("now", new \DateTimezone("UTC")))->sub($interval);
$invalid = $valid->sub($interval)->sub($interval);
Phake::when(Arsse::$db)->metaGet("service_last_checkin")->thenReturn(Date::transform($valid, "sql"))->thenReturn(Date::transform($invalid, "sql"));

2
tests/cases/REST/TinyTinyRSS/TestAPI.php

@ -968,7 +968,7 @@ LONG_STRING;
public function testRetrieveTheServerConfiguration() {
$in = ['op' => "getConfig", 'sid' => "PriestsOfSyrinx"];
$interval = Service::interval();
$interval = Arsse::$conf->serviceFrequency;
$valid = (new \DateTimeImmutable("now", new \DateTimezone("UTC")))->sub($interval);
$invalid = $valid->sub($interval)->sub($interval);
Phake::when(Arsse::$db)->metaGet("service_last_checkin")->thenReturn(Date::transform($valid, "sql"))->thenReturn(Date::transform($invalid, "sql"));

22
tests/cases/Service/TestService.php

@ -24,26 +24,6 @@ class TestService extends \JKingWeb\Arsse\Test\AbstractTest {
$this->srv = new Service();
}
public function testComputeInterval() {
$in = [
Arsse::$conf->serviceFrequency,
"PT2M",
"PT5M",
"P2M",
"5M",
"interval",
];
foreach ($in as $index => $spec) {
try {
$exp = new \DateInterval($spec);
} catch (\Exception $e) {
$exp = new \DateInterval("PT2M");
}
Arsse::$conf->serviceFrequency = $spec;
$this->assertEquals($exp, Service::interval(), "Interval #$index '$spec' was not correctly calculated");
}
}
public function testCheckIn() {
$now = time();
$this->srv->checkIn();
@ -54,7 +34,7 @@ class TestService extends \JKingWeb\Arsse\Test\AbstractTest {
public function testReportHavingCheckedIn() {
// the mock's metaGet() returns null by default
$this->assertFalse(Service::hasCheckedIn());
$interval = Service::interval();
$interval = Arsse::$conf->serviceFrequency;
$valid = (new \DateTimeImmutable("now", new \DateTimezone("UTC")))->sub($interval);
$invalid = $valid->sub($interval)->sub($interval);
Phake::when(Arsse::$db)->metaGet("service_last_checkin")->thenReturn(Date::transform($valid, "sql"))->thenReturn(Date::transform($invalid, "sql"));

4
tests/lib/AbstractTest.php

@ -52,7 +52,7 @@ abstract class AbstractTest extends \PHPUnit\Framework\TestCase {
'dbMySQLPass' => "arsse_test",
'dbMySQLDb' => "arsse_test",
];
Arsse::$conf = ($force ? null : Arsse::$conf) ?? (new Conf)->import($defaults)->import($conf);
Arsse::$conf = (($force ? null : Arsse::$conf) ?? (new Conf))->import($defaults)->import($conf);
}
public function assertException(string $msg = "", string $prefix = "", string $type = "Exception") {
@ -68,7 +68,7 @@ abstract class AbstractTest extends \PHPUnit\Framework\TestCase {
$this->expectExceptionCode($code);
} else {
// expecting a standard PHP exception
$this->expectException(\Exception::class);
$this->expectException(\Throwable::class);
}
}

Loading…
Cancel
Save