1
1
Fork 0
mirror of https://code.mensbeam.com/MensBeam/Arsse.git synced 2024-12-22 13:12:41 +00:00
Arsse/lib/Conf.php
J. King bb89083444 Perform strict validation of query parameters
This is in fact stricter than Miniflux, which ignores duplicate values
and does not validate anything other than the string enumerations
2021-01-30 21:37:19 -05:00

330 lines
18 KiB
PHP

<?php
/** @license MIT
* Copyright 2017 J. King, Dustin Wilson et al.
* See LICENSE and AUTHORS files for details */
/** Conf class */
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.
* All public properties are configuration parameters that may be set by the server administrator. */
class Conf {
/** @var string Default language to use for logging and errors */
public $lang = "en";
/** @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 \DateInterval|null Number of seconds to wait before returning a timeout error when connecting to a database (null waits forever; not applicable to SQLite) */
public $dbTimeoutConnect = 5.0;
/** @var \DateInterval|null Number of seconds to wait before returning a timeout error when executing a database operation (null waits forever; not applicable to SQLite) */
public $dbTimeoutExec = null;
/** @var \DateInterval|null Number of seconds to wait before returning a timeout error when acquiring a database lock (null waits forever) */
public $dbTimeoutLock = 60.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 string Host name, address, or socket path of PostgreSQL database server (if using PostgreSQL) */
public $dbPostgreSQLHost = "";
/** @var string Log-in user name for PostgreSQL database server (if using PostgreSQL) */
public $dbPostgreSQLUser = "arsse";
/** @var string Log-in password for PostgreSQL database server (if using PostgreSQL) */
public $dbPostgreSQLPass = "";
/** @var integer Listening port for PostgreSQL database server (if using PostgreSQL over TCP) */
public $dbPostgreSQLPort = 5432;
/** @var string Database name on PostgreSQL database server (if using PostgreSQL) */
public $dbPostgreSQLDb = "arsse";
/** @var string Schema name in PostgreSQL database (if using PostgreSQL) */
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 or address of MySQL database server (if using MySQL) */
public $dbMySQLHost = "localhost";
/** @var string Log-in user name for MySQL database server (if using MySQL) */
public $dbMySQLUser = "arsse";
/** @var string Log-in password for MySQL database server (if using MySQL) */
public $dbMySQLPass = "";
/** @var integer Listening port for MySQL database server (if using MySQL over TCP) */
public $dbMySQLPort = 3306;
/** @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 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 */
public $userHTTPAuthRequired = false;
/** @var integer Desired length of temporary user passwords */
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 \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 \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 Feed update service driver to use, one of "serial" or "subprocess". 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 \DateInterval Number of seconds to wait for data when fetching feeds from foreign servers */
public $fetchTimeout = 10.0;
/** @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 = null;
/** @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 \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 \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";
/** @var string Application name to present to clients during authentication */
public $httpRealm = "The Advanced RSS Environment";
/** @var string Space-separated list of origins from which to allow cross-origin resource sharing */
public $httpOriginsAllowed = "*";
/** @var string Space-separated list of origins from which to deny cross-origin resource sharing */
public $httpOriginsDenied = "";
### OBSOLETE SETTINGS
/** @var \DateInterval|null (OBSOLETE) Number of seconds for SQLite to wait before returning a timeout error when trying to acquire a write lock on the database (zero does not wait) */
public $dbSQLite3Timeout = null; // previously 60.0
protected const EXPECTED_TYPES = [
'dbTimeoutExec' => "double",
'dbTimeoutLock' => "double",
'dbTimeoutConnect' => "double",
'dbSQLite3Timeout' => "double",
];
protected $types = [];
/** Creates a new configuration object
* @param string $import_file Optional file to read configuration data from
* @see self::importFile() */
public function __construct(string $import_file = "") {
$this->types = $this->propertyDiscover();
foreach (array_keys($this->types) as $prop) {
$this->$prop = $this->propertyImport($prop, $this->$prop);
}
if ($import_file !== "") {
$this->importFile($import_file);
}
}
/** Layers configuration data from a file into an existing object
*
* 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)) {
throw new Conf\Exception("fileMissing", $file);
} elseif (!is_readable($file)) {
throw new Conf\Exception("fileUnreadable", $file);
}
try {
ob_start();
$arr = (@include $file);
} catch (\Throwable $e) {
$arr = null;
} finally {
ob_end_clean();
}
if (!is_array($arr)) {
throw new Conf\Exception("fileCorrupt", $file);
}
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 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 = $this->propertyImport($key, $value, $file);
}
return $this;
}
/** Outputs configuration settings, either non-default ones or all, as an associative array
* @param bool $full Whether to output all configuration options rather than only changed ones */
public function export(bool $full = false): array {
$conf = new \ReflectionObject($this);
$ref = (new \ReflectionClass($this))->getDefaultProperties();
$out = [];
foreach ($conf->getProperties(\ReflectionProperty::IS_PUBLIC) as $prop) {
$name = $prop->name;
$value = $prop->getValue($this);
if ($prop->isDefault()) {
$default = $ref[$name];
// if the property is a known property (rather than one added by a hypothetical plug-in)
// we convert intervals to strings and then export anything which doesn't match the default value
$value = $this->propertyExport($name, $value);
if ((is_scalar($value) || is_null($value)) && ($full || $value !== $ref[$name])) {
$out[$name] = $value;
}
} elseif (is_scalar($value) || is_null($value)) {
// otherwise export the property only if it is scalar
$out[$name] = $value;
}
}
return $out;
}
/** Outputs configuration settings, either non-default ones or all, to a file in a format suitable for later import
* @param string $file Full path and file name for the file to import to; the containing directory must already exist
* @param bool $full Whether to output all configuration options rather than only changed ones */
public function exportFile(string $file, bool $full = false): bool {
$arr = $this->export($full);
$conf = new \ReflectionObject($this);
$out = "<?php return [".PHP_EOL;
foreach ($arr as $prop => $value) {
$match = null;
$doc = $comment = "";
// retrieve the property's docblock, if it exists
try {
$doc = (new \ReflectionProperty(self::class, $prop))->getDocComment();
// parse the docblock to extract the property description
if (preg_match("<@var\s+\S+\s+(.+?)(?:\s*\*/)?\s*$>m", $doc, $match)) {
$comment = $match[1];
}
} catch (\ReflectionException $e) {
}
// append the docblock description if there is one, or an empty comment otherwise
$out .= " // ".$comment.PHP_EOL;
// append the property and an export of its value to the output
$out .= " ".var_export($prop, true)." => ".var_export($value, true).",".PHP_EOL;
}
$out .= "];".PHP_EOL;
// write the configuration representation to the requested file
if (!@file_put_contents($file, $out)) {
// if it fails throw an exception
$err = file_exists($file) ? "fileUnwritable" : "fileUncreatable";
throw new Conf\Exception($err, $file);
}
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 {
// catch-all for custom properties
$type = Value::T_MIXED; // @codeCoverageIgnore
}
$out[$p->name] = ['name' => $match[0], 'const' => $type];
}
return $out;
}
protected function propertyImport(string $key, $value, string $file = "") {
$typeName = $this->types[$key]['name'] ?? "mixed";
$typeConst = $this->types[$key]['const'] ?? Value::T_MIXED;
$nullable = (int) (bool) ($this->types[$key]['const'] & Value::M_NULL);
try {
if ($typeName === "\\DateInterval") {
// date intervals have special handling: if the existing value (ultimately, the default value)
// 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
$mode = $nullable ? Value::M_STRICT | Value::M_NULL : Value::M_STRICT;
if (is_string($value)) {
$value = Value::normalize($value, Value::T_INTERVAL | $mode);
}
switch (self::EXPECTED_TYPES[$key] ?? gettype($this->$key)) {
case "integer":
// no properties are currently typed as integers
return Value::normalize($value, Value::T_INT | $mode); // @codeCoverageIgnore
case "double":
return Value::normalize($value, Value::T_FLOAT | $mode);
case "string":
case "object":
return $value;
default:
// this should never occur
throw new Conf\Exception("ambiguousDefault", ['param' => $key]); // @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) {
$type = $this->types[$key]['const'] & ~(Value::M_STRICT | Value::M_DROP | Value::M_NULL | Value::M_ARRAY);
throw new Conf\Exception("typeMismatch", ['param' => $key, 'type' => Value::TYPE_NAMES[$type], 'file' => $file, 'nullable' => $nullable]);
}
}
protected function propertyExport(string $key, $value) {
$value = ($value instanceof \DateInterval) ? Value::normalize($value, Value::T_STRING) : $value;
switch ($key) {
case "dbDriver":
return array_flip(Database::DRIVER_NAMES)[$value] ?? $value;
case "userDriver":
return array_flip(User::DRIVER_NAMES)[$value] ?? $value;
case "serviceDriver":
return array_flip(Service::DRIVER_NAMES)[$value] ?? $value;
default:
return $value;
}
}
}