SproutCMS

This is the code documentation for the SproutCMS project

source of /sprout/Helpers/Validity.php

  1. <?php
  2. /*
  3.  * Copyright (C) 2017 Karmabunny Pty Ltd.
  4.  *
  5.  * This file is a part of SproutCMS.
  6.  *
  7.  * SproutCMS is free software: you can redistribute it and/or modify it under the terms
  8.  * of the GNU General Public License as published by the Free Software Foundation, either
  9.  * version 2 of the License, or (at your option) any later version.
  10.  *
  11.  * For more information, visit <http://getsproutcms.com>.
  12.  */
  13. namespace Sprout\Helpers;
  14.  
  15. use InvalidArgumentException;
  16.  
  17. use Kohana;
  18.  
  19. use karmabunny\pdb\Exceptions\RowMissingException;
  20. use Sprout\Exceptions\ValidationException;
  21.  
  22.  
  23. /**
  24.  * New validation class for Sprout 3.
  25.  * All of its methods should give useful errors by throwing a {@see ValidationException}.
  26.  * Used with the {@see Validator} class.
  27.  */
  28. class Validity
  29. {
  30.  
  31. /**
  32.   * Checks the length of a string is within an allowed range
  33.   *
  34.   * @example
  35.   * $valid->check('name', 'Validity::length', 1, 100)
  36.   *
  37.   * @param string $val The value
  38.   * @param int $min Minimum length
  39.   * @param int $max Maximum length
  40.   * @throws ValidationException If item is too short or too long
  41.   */
  42. public static function length($val, $min, $max = PHP_INT_MAX)
  43. {
  44. $len = mb_strlen($val);
  45. if ($len < $min) {
  46. throw new ValidationException("Shorter than minimum allowed length of {$min} characters");
  47. }
  48. if ($len > $max) {
  49. throw new ValidationException("Longer than maximum allowed length of {$max} characters");
  50. }
  51. }
  52.  
  53.  
  54. /**
  55.   * Validate email, commonly used characters only
  56.   *
  57.   * @example
  58.   * $valid->check('email', 'Validity::email')
  59.   *
  60.   * @param string email address
  61.   * @throws ValidationException
  62.   */
  63. public static function email($val)
  64. {
  65. $regex = '/^[-_a-z0-9\'+*$^&%=~!?{}]++(?:\.[-_a-z0-9\'+*$^&%=~!?{}]+)*+@(?:(?![-.])[-a-z0-9.]+(?<![-.])\.[a-z]{2,6}|\d{1,3}(?:\.\d{1,3}){3})(?::\d++)?$/iD';
  66.  
  67. if (!preg_match($regex, $val)) {
  68. throw new ValidationException('Invalid email address');
  69. }
  70. }
  71.  
  72.  
  73. /**
  74.   * Validate password by length, type of characters, and list of common passwords
  75.   *
  76.   * @example
  77.   * $valid->check('password', 'Validity::password')
  78.   *
  79.   * @param string $val Password to validate
  80.   * @throws ValidationException
  81.   */
  82. public static function password($val)
  83. {
  84. $length = (int) Kohana::config('sprout.password_length');
  85. if ($length < 6) $length = 6;
  86.  
  87. $classes = Kohana::config('sprout.password_classes');
  88. if (!is_int($classes)) $classes = 2;
  89.  
  90. $bad_list = Kohana::config('sprout.password_bad_list');
  91. if (!is_bool($bad_list)) $bad_list = true;
  92.  
  93. $errs = Security::passwordComplexity($val, $length, $classes, $bad_list);
  94.  
  95. if (count($errs) > 0) {
  96. throw new ValidationException(
  97. ucfirst(strtolower(implode('; ', $errs)))
  98. );
  99. }
  100. }
  101.  
  102.  
  103. /**
  104.   * Checks if a phone number is valid.
  105.   *
  106.   * @example
  107.   * $valid->check('mobile', 'Validity::phone', 10)
  108.   *
  109.   * @param string $val Phone number
  110.   * @param int $min_digits Minimum number of digits required in phone number.
  111.   * This can be less than 8 for fields which allow short numbers like 000 or 13 11 66
  112.   * @throws ValidationException
  113.   */
  114. public static function phone($val, $min_digits = 8)
  115. {
  116. $min_digits = (int) $min_digits;
  117. if ($min_digits <= 0) $min_digits = 8;
  118.  
  119. // Allow international numbers starting with + and country code, e.g. +61 for Australia
  120. $clean = preg_replace('/^\+[0-9]+ */', '', $val);
  121.  
  122. // Allow area code in parentheses, e.g. in Australia (08) or Mexico (01 55)
  123. $clean = preg_replace('/^\(([0-9]+(?: [0-9]+)*)\)/', '$1', $clean);
  124.  
  125. // Allow all kinds of different digit separation:
  126. // space (AU), dash - (US), dot . and slash / (crazy Belgians)
  127. if (preg_match('#[^\- 0-9/\.]#', $clean)) {
  128. if (preg_match('#[\+\(\)]#', $clean)) {
  129. throw new ValidationException("Invalid format");
  130. }
  131. throw new ValidationException("Contains invalid characters");
  132. }
  133.  
  134. // Check length meets the minimum requirement
  135. $len = strlen(preg_replace('/[^0-9]/', '', $val));
  136. if ($len < $min_digits) {
  137. throw new ValidationException("Must contain at least {$min_digits} digits");
  138. }
  139. if ($len > 15) {
  140. throw new ValidationException("Cannot contain more than 15 digits");
  141. }
  142. }
  143.  
  144.  
  145. /**
  146.   * Checks if a value is a positive integer
  147.   *
  148.   * @example
  149.   * $valid->check('region_id', 'Validity::positiveInt')
  150.   *
  151.   * @param string $val Value to check
  152.   * @throws ValidationException
  153.   */
  154. public static function positiveInt($val)
  155. {
  156. if (preg_match('/[^0-9]/', $val)) {
  157. throw new ValidationException("Value must be a whole number that is greater than zero");
  158. }
  159.  
  160. $int = (int) $val;
  161. if ($int <= 0) {
  162. throw new ValidationException("Value must be greater than zero");
  163. }
  164. }
  165.  
  166.  
  167. /**
  168.   * Checks whether a string is made up of the kinds of characters that make up prose
  169.   *
  170.   * Allowed: letters, numbers, space, punctuation
  171.   * Allowed punctuation:
  172.   * ' " / ! ? @ # $ % & ( ) - : ; . ,
  173.   *
  174.   * @example
  175.   * $valid->check('name', 'Validity::proseText')
  176.   *
  177.   * @param string $str
  178.   * @throws ValidationException
  179.   */
  180. public static function proseText($str)
  181. {
  182. // pL = letters, pN = numbers
  183. if (preg_match('/[^-\pL\pN \'"\/!?@#$%&():;.,]/u', (string) $str)) {
  184. throw new ValidationException('Non prose characters found');
  185. }
  186. }
  187.  
  188.  
  189. /**
  190.   * Checks if a value is a date in MySQL format (YYYY-MM-DD)
  191.   *
  192.   * @example
  193.   * $valid->check('date_published', 'Validity::dateMySQL')
  194.   *
  195.   * @param string $val Value to check
  196.   * @throws ValidationException
  197.   */
  198. public static function dateMySQL($val)
  199. {
  200. $matches = null;
  201. if (!preg_match('/^([0-9]{4})-([0-9]{2})-([0-9]{2})$/', $val, $matches)) {
  202. throw new ValidationException('Invalid date format');
  203. }
  204.  
  205. if ($matches[1] < 1900 or $matches[1] > 2100) {
  206. throw new ValidationException('Year is outside of range of 1900 to 2100');
  207. }
  208.  
  209. if ($matches[2] < 1 or $matches[2] > 12) {
  210. throw new ValidationException('Month is outside of range of 1 to 12');
  211. }
  212.  
  213. if ($matches[3] < 1 or $matches[3] > 31) {
  214. throw new ValidationException('Day is outside of range of 1 to 31');
  215. }
  216. }
  217.  
  218.  
  219. /**
  220.   * Checks if a value is a time in MySQL format (HH:MM:SS)
  221.   *
  222.   * @example
  223.   * $valid->check('event_time', 'Validity::timeMySQL')
  224.   *
  225.   * @param string $val Value to check
  226.   * @throws ValidationException
  227.   */
  228. public static function timeMySQL($val)
  229. {
  230. $matches = null;
  231. if (!preg_match('/^([0-9]{2}):([0-9]{2}):([0-9]{2})$/', $val, $matches)) {
  232. throw new ValidationException('Invalid time format');
  233. }
  234.  
  235. if ($matches[1] < 0 or $matches[1] > 23) {
  236. throw new ValidationException('Hour is outside of range of 0 to 23');
  237. }
  238.  
  239. if ($matches[2] < 0 or $matches[2] > 59) {
  240. throw new ValidationException('Minute is outside of range of 0 to 59');
  241. }
  242.  
  243. if ($matches[3] < 0 or $matches[3] > 59) {
  244. throw new ValidationException('Second is outside of range of 0 to 59');
  245. }
  246. }
  247.  
  248.  
  249. /**
  250.   * Checks if a value is a datetime in MySQL format (YYYY-MM-DD HH:MM:SS)
  251.   *
  252.   * @example
  253.   * $valid->check('start_date', 'Validity::datetimeMySQL')
  254.   *
  255.   * @param string $val Value to check
  256.   * @throws ValidationException
  257.   */
  258. public static function datetimeMySQL($val)
  259. {
  260. $matches = null;
  261. if (!preg_match('/^([0-9]{4}-[0-9]{2}-[0-9]{2}) ([0-9]{2}:[0-9]{2}:[0-9]{2})$/', $val, $matches)) {
  262. throw new ValidationException('Invalid datedate format');
  263. }
  264.  
  265. self::dateMySQL($matches[1]);
  266. self::timeMySQL($matches[2]);
  267. }
  268.  
  269.  
  270. /**
  271.   * Checks that a unique value doesn't already exist in the database, e.g. a username or email address.
  272.   * This is to give a friendlier frontend to DB errors pertaining to UNIQUE constraints.
  273.   * N.B. this function uses LIKE for case-insensitive matching, so it's even stricter than a UNIQUE constraint.
  274.   *
  275.   * @example
  276.   * $valid->check('code', 'Validity::uniqueValue', 'events', 'code')
  277.   *
  278.   * @example
  279.   * $valid->check('email', 'Validity::uniqueValue', 'users', 'email', UserAuth::get_id())
  280.   *
  281.   * @param string $val Value to check
  282.   * @param string $table Table to search for an extant matching value
  283.   * @param string $column Column in specified table
  284.   * @param int $id The ID of the record being edited (0 if adding a new record). This prevents an exception being
  285.   * thrown when the record being edited itself is found.
  286.   * @param string $error_msg Error message to be included in the exception
  287.   * @throws ValidationException
  288.   * @throws InvalidArgumentException If $table or $column is invalid
  289.   */
  290. public static function uniqueValue($val, $table, $column, $id = 0, $error_msg = '')
  291. {
  292. Pdb::validateIdentifier($table);
  293. Pdb::validateIdentifier($column);
  294. $id = (int) $id;
  295.  
  296. $q = "SELECT id FROM ~{$table} WHERE {$column} LIKE ? AND id != ?";
  297. $res = Pdb::q($q, [Pdb::likeEscape($val), $id], 'arr');
  298. if (count($res) > 0) {
  299. if (!$error_msg) $error_msg = 'Must be a unique value';
  300. throw new ValidationException($error_msg);
  301. }
  302. }
  303.  
  304.  
  305. /**
  306.   * Checks all selected values belong to a database SET definition
  307.   *
  308.   * @example
  309.   * $valid->check('days', 'Validity::allInSet', 'events', 'days')
  310.   *
  311.   * @param array $val The value
  312.   * @param string $table The DB table which contains the SET column
  313.   * @param string $col The SET column
  314.   * @throws Exception If the data isn't an array or the column isn't an ENUM or SET
  315.   * @throws ValidationException If item is too short or too long
  316.   */
  317. public static function allInSet(array $val, $table, $col) {
  318. $set = Pdb::extractEnumArr($table, $col);
  319. foreach ($val as $choice) {
  320. if (!in_array($choice, $set)) {
  321. throw new ValidationException("Invalid value: {$choice}");
  322. }
  323. }
  324. }
  325.  
  326.  
  327. /**
  328.   * At least one value must be specified (e.g. one of email/phone/mobile)
  329.   *
  330.   * @example
  331.   * $valid->multipleCheck(['email', 'phone'], 'Validity::oneRequired')
  332.   *
  333.   * @param array $vals Values to check
  334.   * @throws ValidationException
  335.   */
  336. public static function oneRequired(array $vals)
  337. {
  338. foreach ($vals as $v) {
  339. if (is_array($v) and count($v) > 0) {
  340. return;
  341. } else if ($v != '') {
  342. return;
  343. }
  344. }
  345.  
  346. throw new ValidationException("At least one of these must be provided");
  347. }
  348.  
  349.  
  350. /**
  351.   * All field values must match (e.g. password1 and password2 must match)
  352.   *
  353.   * @example
  354.   * $valid->multipleCheck(['password1', 'password2'], 'Validity::allMatch')
  355.   *
  356.   * @param array $vals Values to check
  357.   * @throws ValidationException
  358.   */
  359. public static function allMatch(array $vals)
  360. {
  361. $unique = array_unique($vals);
  362. if (count($unique) > 1) {
  363. throw new ValidationException("Provided values do not match");
  364. }
  365. }
  366.  
  367.  
  368.  
  369. /**
  370.   * All field values must be unique (e.g. home phone and work phone cannot be the same)
  371.   *
  372.   * @example
  373.   * $valid->multipleCheck(['home_phone', 'work_phone'], 'Validity::allUnique')
  374.   *
  375.   * @param array $vals Values to check
  376.   * @throws ValidationException
  377.   */
  378. public static function allUnique(array $vals)
  379. {
  380. $unique = array_unique($vals);
  381. if (count($unique) != count($vals)) {
  382. throw new ValidationException("Provided values must not be the same");
  383. }
  384. }
  385.  
  386.  
  387. /**
  388.   * Checks a value is one of the allowed values
  389.   *
  390.   * @example
  391.   * $valid->check('vowel', 'Validity::inArray', ['a', 'e', 'i', 'o', 'u'])
  392.   *
  393.   * @param string $val
  394.   * @param array $allowed
  395.   * @throws ValidationException
  396.   */
  397. public static function inArray($val, array $allowed)
  398. {
  399. if (!in_array($val, $allowed)) {
  400. throw new ValidationException('Invalid value');
  401. }
  402. }
  403.  
  404.  
  405. /**
  406.   * Checks each value of an array is one of the allowed values
  407.   *
  408.   * @example
  409.   * $_POST['vowels'] = ['a', 'i']
  410.   * $valid->check('vowel', 'Validity::allInArray', ['a', 'e', 'i', 'o', 'u'])
  411.   *
  412.   * @param string $val
  413.   * @param array $allowed
  414.   * @throws ValidationException
  415.   */
  416. public static function allInArray(array $val, array $allowed)
  417. {
  418. if (count(array_diff($val, $allowed)) > 0) {
  419. throw new ValidationException('Invalid value');
  420. }
  421. }
  422.  
  423.  
  424. /**
  425.   * Checks a value matches an ID in a corresponding table
  426.   *
  427.   * @example
  428.   * $valid->check('user_id', 'Validity::inTable', 'users')
  429.   *
  430.   * @param string $val
  431.   * @param string $table
  432.   * @return void
  433.   * @throws ValidationException
  434.   */
  435. public static function inTable($val, $table)
  436. {
  437. // Should be required if an empty (null/zero) value isn't allowed
  438. if ($val == '') return;
  439.  
  440. try {
  441. Pdb::validateIdentifier($table);
  442. $q = "SELECT id FROM ~{$table} WHERE id = ?";
  443. Pdb::query($q, [$val], 'val');
  444. } catch (RowMissingException $ex) {
  445. throw new ValidationException('Invalid value');
  446. }
  447. }
  448.  
  449.  
  450. /**
  451.   * Checks all selected values match IDs in a corresponding table
  452.   *
  453.   * @example
  454.   * $_POST['favourite_cities'] = (array) @$_POST['favourite_cities']
  455.   * $valid->check('favourite_cities', 'Validity::allInTable', 'cities')
  456.   *
  457.   * @param array $val
  458.   * @param string $table
  459.   * @return void
  460.   * @throws ValidationException
  461.   */
  462. public static function allInTable(array $val, $table)
  463. {
  464. // Should be required if empty value isn't allowed
  465. if (count($val) == 0) return;
  466.  
  467. // Extract IDs from autofill list submissions
  468. // This is a stop-gap measure until autofill list is reworked to only submit ID values
  469. foreach ($val as &$el) {
  470. if (is_array($el)) {
  471. if (!isset($el['id'])) {
  472. throw new ValidationException('Invalid value');
  473. }
  474. $el = $el['id'];
  475. }
  476. }
  477.  
  478. $val = array_unique($val);
  479.  
  480. $found_ids = [];
  481. try {
  482. Pdb::validateIdentifier($table);
  483. $params = [];
  484. $where = Pdb::buildClause([['id', 'IN', $val]], $params);
  485.  
  486. $q = "SELECT id FROM ~{$table} WHERE {$where}";
  487. $found_ids = Pdb::query($q, $params, 'col');
  488. } catch (RowMissingException $ex) {
  489. }
  490.  
  491. if (count($val) != count($found_ids)) {
  492. throw new ValidationException('Invalid value');
  493. }
  494. }
  495.  
  496.  
  497. /**
  498.   * Checks that a value is numeric (integral or decimal)
  499.   *
  500.   * @example
  501.   * $valid->check('cost', 'Validity::numeric')
  502.   *
  503.   * @param string $val
  504.   * @throws ValidationException
  505.   */
  506. public static function numeric($val)
  507. {
  508. if (!is_numeric($val)) {
  509. throw new ValidationException('Value must be a number');
  510. }
  511. }
  512.  
  513.  
  514. /**
  515.   * Checks that a value is binary; either a '1' or a '0'.
  516.   *
  517.   * @example
  518.   * $valid->check('active', 'Validity::binary')
  519.   *
  520.   * @param string $val
  521.   * @throws ValidationException
  522.   */
  523. public static function binary($val)
  524. {
  525. if ($val !== '1' and $val !== 1 and $val !== '0' and $val !== 0) {
  526. throw new ValidationException('Value must be a "1" or "0"');
  527. }
  528. }
  529.  
  530.  
  531. /**
  532.   * Checks that a value is numeric (integral or decimal) and within a given inclusive range
  533.   *
  534.   * @example
  535.   * $valid->check('cost', 'Validity::range', 0, 5000)
  536.   *
  537.   * @param string $val
  538.   * @param number $min The minimum the value may be
  539.   * @param number $max The maximum the value may be
  540.   * @throws ValidationException
  541.   */
  542. public static function range($val, $min, $max)
  543. {
  544. static::numeric($val);
  545.  
  546. if ($val < $min or $val > $max) {
  547. throw new ValidationException("Value must be no less than {$min} and no greater than {$max}");
  548. }
  549. }
  550.  
  551.  
  552.  
  553. /**
  554.   * Validates a value meant for an ENUM field
  555.   *
  556.   * @param string $val
  557.   * @param string $table The DB table which contains the ENUM column
  558.   * @param string $col The ENUM column
  559.   * @return bool
  560.   */
  561. public static function inEnum($val, $table, $col)
  562. {
  563. $enum = Pdb::extractEnumArr($table, $col);
  564. if (in_array($val, $enum)) return true;
  565. throw new ValidationException("Invalid value");
  566. }
  567.  
  568. /**
  569.   * Checks that a date range is valid.
  570.   *
  571.   * @example
  572.   * $valid->multipleCheck(['date_start', 'date_end'], 'Validity::dateRange', '1999-01-01', '2099-01-01')
  573.   *
  574.   * @param array $vals The values to check; there must be exactly two with the 'start' field name occuring first in the array
  575.   * @param string $min (optional) A date string (compatible with strtotime) for the minimum of the date range.
  576.   * @param string $max (optional) A date string (compatible with strtotime) for the maximum of the date range.
  577.   * @param bool $enforce_ordering (optional) Ensures that the start date is less than or equal to the end date. On by default.
  578.   */
  579. public static function dateRange(array $vals, $min = null, $max = null, $enforce_ordering = true)
  580. {
  581. if (count($vals) != 2) {
  582. throw new InvalidArgumentException('Incorrect number of fields. A date range must only contain two dates: a start and an end date.');
  583. }
  584.  
  585. list ($date_start, $date_end) = $vals;
  586.  
  587. static::dateMySQL($date_start);
  588. static::dateMySQL($date_end);
  589.  
  590. $ts_start = strtotime($date_start);
  591. $ts_end = strtotime($date_end);
  592.  
  593. // Ideally we'd just switch the values around but that isn't possible
  594. if ($enforce_ordering and $ts_start > $ts_end) {
  595. throw new ValidationException("The start date, {$date_start}, cannot be later than the end date {$date_end}");
  596. }
  597.  
  598. if ($min) {
  599. $ts_min = strtotime($min);
  600.  
  601. if ($ts_start < $ts_min) {
  602. throw new ValidationException("The start of this date range is outside the minimum of {$min}");
  603. }
  604. }
  605.  
  606. if ($max) {
  607. $ts_max = strtotime($max);
  608.  
  609. if ($ts_end > $ts_max) {
  610. throw new ValidationException("The end of this date range is outside the maximum of {$max}");
  611. }
  612. }
  613. }
  614.  
  615.  
  616. /**
  617.   * Checks that a value matches a regular expression
  618.   * @param string $val value
  619.   * @param string $pattern Regex pattern for preg_match. Consider starting with /^ and ending with $/
  620.   * @return void
  621.   * @throws ValidationException If the value doesn't match the pattern
  622.   */
  623. public static function regex($val, $pattern)
  624. {
  625. if (!preg_match($pattern, $val)) {
  626. throw new ValidationException('Incorrect format');
  627. }
  628. }
  629.  
  630.  
  631. /**
  632.   * Checks that a value is a valid IPv4 address
  633.   * @param string $val value
  634.   * @return void
  635.   * @throws ValidationException If the value isn't a valid IPv4 address
  636.   */
  637. public static function ipv4Addr($val)
  638. {
  639. if (!preg_match('/^[0-9]+(?:\.[0-9]+){3}$/', $val)) {
  640. throw new ValidationException('Invalid IP address');
  641. }
  642.  
  643. $parts = explode('.', $val);
  644. foreach ($parts as $part) {
  645. $part = (int) $part;
  646. if ($part > 255) throw new ValidationException('Invalid IP address');
  647. }
  648. }
  649.  
  650.  
  651. /**
  652.   * Checks that a value is a valid IPv4 CIDR block
  653.   * @param string $val value
  654.   * @return void
  655.   * @throws ValidationException If the value isn't a valid IPv4 CIDR block
  656.   */
  657. public static function ipv4Cidr($val)
  658. {
  659. if (strpos($val, '/') === false) {
  660. throw new ValidationException('Invalid CIDR block');
  661. }
  662.  
  663. list($ip, $mask) = explode('/', $val, 2);
  664. self::ipv4Addr($ip);
  665.  
  666. if (!preg_match('/^[0-9]{1,2}$/', $mask)) {
  667. throw new ValidationException('Invalid network mask');
  668. }
  669. $mask = (int) $mask;
  670. if ($mask > 32) {
  671. throw new ValidationException('Invalid network mask');
  672. }
  673. }
  674.  
  675.  
  676. /**
  677.   * Checks that a value is a valid IPv4 address or CIDR block
  678.   * @param string $val value
  679.   * @return void
  680.   * @throws ValidationException If the value isn't a valid IPv4 address or CIDR block
  681.   */
  682. public static function ipv4AddrOrCidr($val)
  683. {
  684. if (strpos($val, '/') === false) {
  685. self::ipv4Addr($val);
  686. } else {
  687. self::ipv4Cidr($val);
  688. }
  689. }
  690.  
  691. }
  692.