1
1
Fork 0
mirror of https://code.mensbeam.com/MensBeam/Arsse.git synced 2024-12-22 21:22:40 +00:00
Arsse/lib/Misc/ValueInfo.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

525 lines
24 KiB
PHP

<?php
/** @license MIT
* Copyright 2017 J. King, Dustin Wilson et al.
* See LICENSE and AUTHORS files for details */
declare(strict_types=1);
namespace JKingWeb\Arsse\Misc;
use JKingWeb\Arsse\ExceptionType;
class ValueInfo {
// universal
public const VALID = 1 << 0;
public const NULL = 1 << 1;
// integers
public const ZERO = 1 << 2;
public const NEG = 1 << 3;
public const FLOAT = 1 << 4;
// strings
public const EMPTY = 1 << 2;
public const WHITE = 1 << 3;
// normalization types
public const T_MIXED = 0; // pass through unchanged
public const T_NULL = 1; // convert to null
public const T_BOOL = 2; // convert to boolean
public const T_INT = 3; // convert to integer
public const T_FLOAT = 4; // convert to floating point
public const T_DATE = 5; // convert to DateTimeInterface instance
public const T_STRING = 6; // convert to string
public const T_ARRAY = 7; // convert to array
public const T_INTERVAL = 8; // convert to time interval
// normalization modes
public const M_LOOSE = 0;
public const M_NULL = 1 << 28; // pass nulls through regardless of target type
public const M_DROP = 1 << 29; // drop the value (return null) if the type doesn't match
public const M_STRICT = 1 << 30; // throw an exception if the type doesn't match
public const M_ARRAY = 1 << 31; // the value should be a flat array of values of the specified type; indexed and associative are both acceptable
public const TYPE_NAMES = [
self::T_MIXED => "mixed",
self::T_NULL => "null",
self::T_BOOL => "boolean",
self::T_INT => "integer",
self::T_FLOAT => "float",
self::T_DATE => "date",
self::T_STRING => "string",
self::T_ARRAY => "array",
self::T_INTERVAL => "interval",
];
// symbolic date and time formats
protected const DATE_FORMATS = [ // in out
'iso8601' => ["!Y-m-d\TH:i:s", "Y-m-d\TH:i:s\Z" ], // NOTE: ISO 8601 dates require special input processing because of varying formats for timezone offsets
'iso8601m' => ["!Y-m-d\TH:i:s.u", "Y-m-d\TH:i:s.u\Z" ], // NOTE: ISO 8601 dates require special input processing because of varying formats for timezone offsets
'microtime' => ["U.u", "0.u00 U" ], // NOTE: the actual input format at the user level matches the output format; pre-processing is required for PHP not to fail
'http' => ["!D, d M Y H:i:s \G\M\T", "D, d M Y H:i:s \G\M\T"],
'sql' => ["!Y-m-d H:i:s", "Y-m-d H:i:s" ],
'date' => ["!Y-m-d", "Y-m-d" ],
'time' => ["!H:i:s", "H:i:s" ],
'unix' => ["U", "U" ],
'float' => ["U.u", "U.u" ],
];
public static function normalize($value, int $type, string $dateInFormat = null, $dateOutFormat = null) {
$allowNull = ($type & self::M_NULL);
$strict = ($type & (self::M_STRICT | self::M_DROP));
$drop = ($type & self::M_DROP);
$arrayVal = ($type & self::M_ARRAY);
$type = ($type & ~(self::M_NULL | self::M_DROP | self::M_STRICT | self::M_ARRAY));
// if the value is null and this is allowed, simply return
if ($allowNull && is_null($value)) {
return null;
}
// if the value is supposed to be an array, handle it specially
if ($arrayVal) {
$value = self::normalize($value, self::T_ARRAY);
foreach ($value as $key => $v) {
$value[$key] = self::normalize($v, $type | ($allowNull ? self::M_NULL : 0) | ($strict ? self::M_STRICT : 0) | ($drop ? self::M_DROP : 0), $dateInFormat, $dateOutFormat);
}
return $value;
}
switch ($type) {
case self::T_MIXED:
return $value;
case self::T_NULL:
return null;
case self::T_BOOL:
if (is_bool($value)) {
return $value;
}
$out = self::bool($value);
if ($strict && is_null($out)) {
// if strict and input is not a boolean, this is an error
if ($drop) {
return null;
}
throw new ExceptionType("strictFailure", $type);
} elseif (is_float($value) && is_nan($value)) {
return false;
} elseif (is_null($out)) {
// if not strict and input is not a boolean, return a simple type-cast
return (bool) $value;
}
return $out;
case self::T_INT:
if (is_int($value)) {
return $value;
} elseif ($value instanceof \DateTimeInterface) {
if ($strict && !$drop) {
throw new ExceptionType("strictFailure", $type);
}
return (!$drop) ? (int) $value->getTimestamp(): null;
} elseif ($value instanceof \DateInterval) {
if ($strict && !$drop) {
throw new ExceptionType("strictFailure", $type);
} elseif ($drop) {
return null;
} else {
// returns the number of seconds in the interval
// days are assumed to contain (60 * 60 * 24) seconds
// months are assumed to contain 30 days
// years are assumed to contain 365 days
$s = 0;
if ($value->days !== false) {
$s += ($value->days * 24 * 60 * 60);
} else {
$s += ($value->y * 365 * 24 * 60 * 60);
$s += ($value->m * 30 * 24 * 60 * 60);
$s += ($value->d * 24 * 60 * 60);
}
$s += ($value->h * 60 * 60);
$s += ($value->i * 60);
$s += $value->s;
return $s;
}
}
$info = self::int($value);
if ($strict && !($info & self::VALID)) {
// if strict and input is not an integer, this is an error
if ($drop) {
return null;
}
throw new ExceptionType("strictFailure", $type);
} elseif (is_bool($value)) {
return (int) $value;
} elseif ($info & (self::VALID | self::FLOAT)) {
$out = strtolower((string) $value);
if (strpos($out, "e")) {
return (int) (float) $out;
} else {
return (int) $out;
}
} else {
return 0;
}
break; // @codeCoverageIgnore
case self::T_FLOAT:
if (is_float($value)) {
return $value;
} elseif ($value instanceof \DateTimeInterface) {
if ($strict && !$drop) {
throw new ExceptionType("strictFailure", $type);
}
return (!$drop) ? (float) $value->getTimestamp(): null;
} elseif ($value instanceof \DateInterval) {
if ($drop) {
return null;
} elseif ($strict) {
throw new ExceptionType("strictFailure", $type);
}
// convert the interval to an integer, and then add microseconds if available (since PHP 7.1, for intervals created from a DateTime difference operation)
$out = (float) self::normalize($value, self::T_INT);
$out += isset($value->f) ? $value->f : 0.0;
return $out;
} elseif (is_bool($value) && $strict) {
if ($drop) {
return null;
}
throw new ExceptionType("strictFailure", $type);
}
$out = filter_var($value, \FILTER_VALIDATE_FLOAT);
if ($strict && $out === false) {
// if strict and input is not a float, this is an error
if ($drop) {
return null;
}
throw new ExceptionType("strictFailure", $type);
}
return (float) $out;
case self::T_STRING:
if (is_string($value)) {
return $value;
}
if ($value instanceof \DateTimeInterface) {
$dateOutFormat = $dateOutFormat ?? "iso8601";
$dateOutFormat = isset(self::DATE_FORMATS[$dateOutFormat]) ? self::DATE_FORMATS[$dateOutFormat][1] : $dateOutFormat;
if ($value instanceof \DateTimeImmutable) {
return $value->setTimezone(new \DateTimeZone("UTC"))->format($dateOutFormat);
} elseif ($value instanceof \DateTime) {
return \DateTimeImmutable::createFromMutable($value)->setTimezone(new \DateTimeZone("UTC"))->format($dateOutFormat);
}
} elseif ($value instanceof \DateInterval) {
$dateSpec = "";
$timeSpec = "";
if ($value->days) {
$dateSpec = $value->days."D";
} else {
$dateSpec .= $value->y ? $value->y."Y": "";
$dateSpec .= $value->m ? $value->m."M": "";
$dateSpec .= $value->d ? $value->d."D": "";
}
$timeSpec .= $value->h ? $value->h."H": "";
$timeSpec .= $value->i ? $value->i."M": "";
$timeSpec .= $value->s ? $value->s."S": "";
$timeSpec = $timeSpec ? "T".$timeSpec : "";
if (!$dateSpec && !$timeSpec) {
return "PT0S";
} else {
return "P".$dateSpec.$timeSpec;
}
} elseif (is_float($value) && is_finite($value)) {
$out = (string) $value;
if (!strpos($out, "E")) {
return $out;
} else {
$out = sprintf("%F", $value);
return preg_match("/\.0{1,}$/", $out) ? (string) (int) $out : $out;
}
}
$info = self::str($value);
if (!($info & self::VALID)) {
if ($drop) {
return null;
} elseif ($strict) {
// if strict and input is not a string, this is an error
throw new ExceptionType("strictFailure", $type);
} elseif (!is_scalar($value)) {
return "";
} else {
return (string) $value;
}
} else {
return (string) $value;
}
break; // @codeCoverageIgnore
case self::T_DATE:
if ($value instanceof \DateTimeImmutable) {
return $value->setTimezone(new \DateTimeZone("UTC"));
} elseif ($value instanceof \DateTime) {
return \DateTimeImmutable::createFromMutable($value)->setTimezone(new \DateTimeZone("UTC"));
} elseif (is_int($value)) {
return \DateTimeImmutable::createFromFormat("U", (string) $value, new \DateTimeZone("UTC"));
} elseif (is_float($value) && is_finite($value)) {
return \DateTimeImmutable::createFromFormat("U.u", sprintf("%F", $value), new \DateTimeZone("UTC"));
} elseif (is_string($value)) {
try {
if (!is_null($dateInFormat)) {
$out = false;
if ($dateInFormat === "microtime") {
// PHP is not able to correctly handle the output of microtime() as the input of DateTime::createFromFormat(), so we fudge it to look like a float
if (preg_match("<^0\.\d{6}00 \d+$>", $value)) {
$value = substr($value, 11).".".substr($value, 2, 6);
} else {
throw new \Exception;
}
}
$f = isset(self::DATE_FORMATS[$dateInFormat]) ? self::DATE_FORMATS[$dateInFormat][0] : $dateInFormat;
if ($dateInFormat === "iso8601" || $dateInFormat === "iso8601m") {
// DateTimeImmutable::createFromFormat() doesn't provide one catch-all for ISO 8601 timezone specifiers, so we try all of them till one works
if ($dateInFormat === "iso8601m") {
$f2 = self::DATE_FORMATS["iso8601"][0];
$zones = [$f."", $f."\Z", $f."P", $f."O", $f2."", $f2."\Z", $f2."P", $f2."O"];
} else {
$zones = [$f."", $f."\Z", $f."P", $f."O"];
}
do {
$ftz = array_shift($zones);
$out = \DateTimeImmutable::createFromFormat($ftz, $value, new \DateTimeZone("UTC"));
} while (!$out && $zones);
} else {
$out = \DateTimeImmutable::createFromFormat($f, $value, new \DateTimeZone("UTC"));
}
if (!$out) {
throw new \Exception;
}
return $out;
} else {
return new \DateTimeImmutable($value, new \DateTimeZone("UTC"));
}
} catch (\Exception $e) {
if ($strict && !$drop) {
throw new ExceptionType("strictFailure", $type);
}
return null;
}
} elseif ($strict && !$drop) {
throw new ExceptionType("strictFailure", $type);
}
return null;
case self::T_ARRAY:
if (is_array($value)) {
return $value;
} elseif ($value instanceof \Traversable) {
$out = [];
foreach ($value as $k => $v) {
$out[$k] = $v;
}
return $out;
} else {
if ($drop) {
return null;
} elseif ($strict) {
// if strict and input is not a string, this is an error
throw new ExceptionType("strictFailure", $type);
} elseif (is_null($value) || (is_float($value) && is_nan($value))) {
return [];
} else {
return [$value];
}
}
break; // @codeCoverageIgnore
case self::T_INTERVAL:
if ($value instanceof \DateInterval) {
if ($value->invert) {
$value = clone $value;
$value->invert = 0;
}
$value->f = $value->f ?? 0.0; // add microseconds for PHP 7.0
return $value;
} elseif (is_null($value)) {
if ($strict && !$drop && !$allowNull) {
throw new ExceptionType("strictFailure", $type);
} else {
return null;
}
} elseif (is_bool($value) || is_array($value) || (is_float($value) && (is_infinite($value) || is_nan($value))) || $value instanceof \DateTimeInterface || (is_object($value) && !method_exists($value, "__toString"))) {
if ($strict && !$drop) {
throw new ExceptionType("strictFailure", $type);
} else {
return null;
}
} elseif (is_string($value) || is_object($value)) {
try {
$out = new \DateInterval((string) $value);
$out->f = 0.0;
return $out;
} catch (\Exception $e) {
if ($strict && !$drop) {
throw new ExceptionType("strictFailure", $type);
} elseif ($drop) {
return null;
} elseif (strtotime("now + $value") !== false) {
$out = \DateInterval::createFromDateString($value);
$out->f = 0.0;
return $out;
} else {
return null;
}
}
} elseif ($drop) {
return null;
} elseif ($strict) {
throw new ExceptionType("strictFailure", $type);
} else {
// input is a number, assume this is a number of seconds
// for legibility we convert large numbers to minutes, hours, and days as necessary
// the DateInterval constructor only allows 12 digits for any given part of an interval,
// so we also convert days to 365-day years where we must, and cap the number of years
// at (1e11 - 1); this being a very large number, the loss of precision is probably not
// significant in practical usage
$sec = abs($value);
$msec = (float) ($sec - (int) $sec);
$sec = (int) $sec;
$min = 0;
$hour = 0;
$day = 0;
$year = 0;
if ($sec >= 60) {
$min = ($sec - ($sec % 60)) / 60;
$sec %= 60;
}
if ($min >= 60) {
$hour = ($min - ($min % 60)) / 60;
$min %= 60;
}
if ($hour >= 24) {
$day = ($hour - ($hour % 24)) / 24;
$hour %= 24;
}
if ($day >= 999999999999) {
$year = ($day - ($day % 365)) / 365;
$day %= 365;
}
$spec = "P";
$spec .= $year ? $year."Y" : "";
$spec .= $day ? $day."D" : "";
$spec .= "T";
$spec .= $hour ? $hour."H" : "";
$spec .= $min ? $min."M" : "";
$spec .= $sec ? $sec."S" : "";
$spec .= ($spec === "PT") ? "0S" : "";
$spec = trim($spec, "T");
$out = new \DateInterval($spec);
$out->f = $msec;
return $out;
}
break; // @codeCoverageIgnore
default:
throw new ExceptionType("typeUnknown", $type); // @codeCoverageIgnore
}
}
public static function flatten(array $arr): array {
$arr = array_values($arr);
for ($a = 0; $a < sizeof($arr); $a++) {
if (is_array($arr[$a])) {
array_splice($arr, $a, 1, $arr[$a]);
$a--;
}
}
return $arr;
}
public static function int($value): int {
$out = 0;
if (is_null($value)) {
// check if the input is null
return self::NULL;
} elseif (is_string($value) || (is_object($value) && method_exists($value, "__toString"))) {
$value = strtolower((string) $value);
// normalize a string an integer or float if possible
if (!strlen($value)) {
// the empty string is equivalent to null when evaluating an integer
return self::NULL;
}
// interpret the value as a float
$float = filter_var($value, \FILTER_VALIDATE_FLOAT);
if ($float !== false) {
if (!fmod($float, 1)) {
// an integral float is acceptable
$value = (int) (!strpos($value, "e") ? $value : $float);
} else {
$out += self::FLOAT;
$value = $float;
}
} else {
return $out;
}
} elseif (is_float($value)) {
if (!fmod($value, 1)) {
// an integral float is acceptable
$value = (int) $value;
} else {
$out += self::FLOAT;
}
} elseif (!is_int($value)) {
// if the value is not an integer or integral float, stop
return $out;
}
// mark validity
if (is_int($value)) {
$out += self::VALID;
}
// mark zeroness
if (!$value) {
$out += self::ZERO;
}
// mark negativeness
if ($value < 0) {
$out += self::NEG;
}
return $out;
}
public static function str($value): int {
$out = 0;
// check if the input is null
if (is_null($value)) {
$out += self::NULL;
}
if (is_object($value) && method_exists($value, "__toString")) {
// if the value is an object which has a __toString method, this is acceptable
$value = (string) $value;
} elseif (!is_scalar($value) || is_bool($value) || (is_float($value) && !is_finite($value))) {
// otherwise if the value is not scalar, is a boolean, or is infinity or NaN, it cannot be valid
return $out;
}
// mark validity
$out += self::VALID;
if (!strlen((string) $value)) {
// mark emptiness
$out += self::EMPTY;
} elseif (!strlen(trim((string) $value))) {
// mark whitespacedness
$out += self::WHITE;
}
return $out;
}
public static function id($value, bool $allowNull = false): bool {
$info = self::int($value);
if ($allowNull && ($info & self::NULL)) { // null (and allowed)
return true;
} elseif (!($info & self::VALID)) { // not an integer
return false;
} elseif ($info & self::NEG) { // negative integer
return false;
} elseif (!$allowNull && ($info & self::ZERO)) { // zero (and not allowed)
return false;
} else { // non-negative integer
return true;
}
}
public static function bool($value, bool $default = null): ?bool {
if (is_null($value) || ValueInfo::str($value) & ValueInfo::WHITE) {
return $default;
}
$out = filter_var($value, \FILTER_VALIDATE_BOOLEAN, \FILTER_NULL_ON_FAILURE);
if (is_null($out) && (ValueInfo::int($value) & ValueInfo::VALID)) {
$out = (int) filter_var($value, \FILTER_VALIDATE_FLOAT);
return ($out == 1 || $out == 0) ? (bool) $out : $default;
}
return !is_null($out) ? $out : $default;
}
}