18d9c2b68SGreg Roach<?php 28d9c2b68SGreg Roach 38d9c2b68SGreg Roach/** 48d9c2b68SGreg Roach * webtrees: online genealogy 5d11be702SGreg Roach * Copyright (C) 2023 webtrees development team 68d9c2b68SGreg Roach * This program is free software: you can redistribute it and/or modify 78d9c2b68SGreg Roach * it under the terms of the GNU General Public License as published by 88d9c2b68SGreg Roach * the Free Software Foundation, either version 3 of the License, or 98d9c2b68SGreg Roach * (at your option) any later version. 108d9c2b68SGreg Roach * This program is distributed in the hope that it will be useful, 118d9c2b68SGreg Roach * but WITHOUT ANY WARRANTY; without even the implied warranty of 128d9c2b68SGreg Roach * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 138d9c2b68SGreg Roach * GNU General Public License for more details. 148d9c2b68SGreg Roach * You should have received a copy of the GNU General Public License 158d9c2b68SGreg Roach * along with this program. If not, see <https://www.gnu.org/licenses/>. 168d9c2b68SGreg Roach */ 178d9c2b68SGreg Roach 188d9c2b68SGreg Roachdeclare(strict_types=1); 198d9c2b68SGreg Roach 208d9c2b68SGreg Roachnamespace Fisharebest\Webtrees; 218d9c2b68SGreg Roach 22b55cbc6bSGreg Roachuse Aura\Router\Route; 238d9c2b68SGreg Roachuse Closure; 24b55cbc6bSGreg Roachuse Fisharebest\Webtrees\Contracts\UserInterface; 2581b729d3SGreg Roachuse Fisharebest\Webtrees\Http\Exceptions\HttpBadRequestException; 268d9c2b68SGreg Roachuse Psr\Http\Message\ServerRequestInterface; 278d9c2b68SGreg Roach 288d9c2b68SGreg Roachuse function array_reduce; 29748dbe15SGreg Roachuse function array_walk_recursive; 308d9c2b68SGreg Roachuse function ctype_digit; 31d2d056daSGreg Roachuse function in_array; 328d9c2b68SGreg Roachuse function is_array; 338d9c2b68SGreg Roachuse function is_int; 348d9c2b68SGreg Roachuse function is_string; 358d9c2b68SGreg Roachuse function parse_url; 3681b729d3SGreg Roachuse function preg_match; 378d9c2b68SGreg Roachuse function str_starts_with; 3865625b93SGreg Roachuse function substr; 398d9c2b68SGreg Roach 408d9c2b68SGreg Roach/** 418d9c2b68SGreg Roach * Validate a parameter from an HTTP request 428d9c2b68SGreg Roach */ 438d9c2b68SGreg Roachclass Validator 448d9c2b68SGreg Roach{ 4504b3bc82SGreg Roach /** @var array<int|string|Tree|UserInterface|array<int|string>> */ 468d9c2b68SGreg Roach private array $parameters; 478d9c2b68SGreg Roach 48f507cef9SGreg Roach private ServerRequestInterface $request; 49f507cef9SGreg Roach 508d9c2b68SGreg Roach /** @var array<Closure> */ 518d9c2b68SGreg Roach private array $rules = []; 528d9c2b68SGreg Roach 538d9c2b68SGreg Roach /** 5404b3bc82SGreg Roach * @param array<int|string|Tree|UserInterface|array<int|string>> $parameters 55f507cef9SGreg Roach * @param ServerRequestInterface $request 56a5f003cfSGreg Roach * @param string $encoding 578d9c2b68SGreg Roach */ 58a5f003cfSGreg Roach private function __construct(array $parameters, ServerRequestInterface $request, string $encoding) 598d9c2b68SGreg Roach { 60a5f003cfSGreg Roach if ($encoding === 'UTF-8') { 61748dbe15SGreg Roach // All keys and values must be valid UTF-8 62748dbe15SGreg Roach $check_utf8 = static function ($value, $key): void { 63748dbe15SGreg Roach if (is_string($key) && preg_match('//u', $key) !== 1) { 64748dbe15SGreg Roach throw new HttpBadRequestException('Invalid UTF-8 characters in request'); 65748dbe15SGreg Roach } 66748dbe15SGreg Roach if (is_string($value) && preg_match('//u', $value) !== 1) { 67748dbe15SGreg Roach throw new HttpBadRequestException('Invalid UTF-8 characters in request'); 68748dbe15SGreg Roach } 69748dbe15SGreg Roach }; 70748dbe15SGreg Roach 71748dbe15SGreg Roach array_walk_recursive($parameters, $check_utf8); 72a5f003cfSGreg Roach } 73748dbe15SGreg Roach 748d9c2b68SGreg Roach $this->parameters = $parameters; 75f507cef9SGreg Roach $this->request = $request; 768d9c2b68SGreg Roach } 778d9c2b68SGreg Roach 788d9c2b68SGreg Roach /** 798d9c2b68SGreg Roach * @param ServerRequestInterface $request 808d9c2b68SGreg Roach * 818d9c2b68SGreg Roach * @return self 828d9c2b68SGreg Roach */ 83b55cbc6bSGreg Roach public static function attributes(ServerRequestInterface $request): self 84b55cbc6bSGreg Roach { 85a5f003cfSGreg Roach return new self($request->getAttributes(), $request, 'UTF-8'); 86b55cbc6bSGreg Roach } 87b55cbc6bSGreg Roach 88b55cbc6bSGreg Roach /** 89b55cbc6bSGreg Roach * @param ServerRequestInterface $request 90b55cbc6bSGreg Roach * 91b55cbc6bSGreg Roach * @return self 92b55cbc6bSGreg Roach */ 938d9c2b68SGreg Roach public static function parsedBody(ServerRequestInterface $request): self 948d9c2b68SGreg Roach { 95a5f003cfSGreg Roach return new self((array) $request->getParsedBody(), $request, 'UTF-8'); 968d9c2b68SGreg Roach } 978d9c2b68SGreg Roach 988d9c2b68SGreg Roach /** 998d9c2b68SGreg Roach * @param ServerRequestInterface $request 1008d9c2b68SGreg Roach * 1018d9c2b68SGreg Roach * @return self 1028d9c2b68SGreg Roach */ 1038d9c2b68SGreg Roach public static function queryParams(ServerRequestInterface $request): self 1048d9c2b68SGreg Roach { 105a5f003cfSGreg Roach return new self($request->getQueryParams(), $request, 'UTF-8'); 1068d9c2b68SGreg Roach } 1078d9c2b68SGreg Roach 1088d9c2b68SGreg Roach /** 109b55cbc6bSGreg Roach * @param ServerRequestInterface $request 110b55cbc6bSGreg Roach * 111b55cbc6bSGreg Roach * @return self 112b55cbc6bSGreg Roach */ 113b55cbc6bSGreg Roach public static function serverParams(ServerRequestInterface $request): self 114b55cbc6bSGreg Roach { 115a5f003cfSGreg Roach // Headers should be ASCII. 116a5f003cfSGreg Roach // However, we cannot enforce this as some servers add GEOIP headers with non-ASCII placenames. 117a5f003cfSGreg Roach return new self($request->getServerParams(), $request, 'ASCII'); 118b55cbc6bSGreg Roach } 119b55cbc6bSGreg Roach 120b55cbc6bSGreg Roach /** 1218d9c2b68SGreg Roach * @param int $minimum 1228d9c2b68SGreg Roach * @param int $maximum 1238d9c2b68SGreg Roach * 1248d9c2b68SGreg Roach * @return self 1258d9c2b68SGreg Roach */ 1268d9c2b68SGreg Roach public function isBetween(int $minimum, int $maximum): self 1278d9c2b68SGreg Roach { 128*1ff45046SGreg Roach $this->rules[] = static function (int|null $value) use ($minimum, $maximum): int|null { 1292b1a9a98SGreg Roach if (is_int($value) && $value >= $minimum && $value <= $maximum) { 1308d9c2b68SGreg Roach return $value; 1318d9c2b68SGreg Roach } 1328d9c2b68SGreg Roach 13381b729d3SGreg Roach return null; 1348d9c2b68SGreg Roach }; 1358d9c2b68SGreg Roach 1368d9c2b68SGreg Roach return $this; 1378d9c2b68SGreg Roach } 1388d9c2b68SGreg Roach 13981b729d3SGreg Roach /** 1400acf1b4bSGreg Roach * @param array<int|string,int|string> $values 1411c6adce8SGreg Roach * 1426612c384SGreg Roach * @return self 1431c6adce8SGreg Roach */ 1441c6adce8SGreg Roach public function isInArray(array $values): self 1451c6adce8SGreg Roach { 146ebe785f4SGreg Roach $this->rules[] = static fn (int|string|null $value): int|string|null => in_array($value, $values, true) ? $value : null; 1471c6adce8SGreg Roach 1481c6adce8SGreg Roach return $this; 1491c6adce8SGreg Roach } 150c3bff7b4SGreg Roach 151c3bff7b4SGreg Roach /** 1520acf1b4bSGreg Roach * @param array<int|string,int|string> $values 153158900c2SGreg Roach * 1546612c384SGreg Roach * @return self 155158900c2SGreg Roach */ 156158900c2SGreg Roach public function isInArrayKeys(array $values): self 157158900c2SGreg Roach { 158158900c2SGreg Roach return $this->isInArray(array_keys($values)); 159158900c2SGreg Roach } 160158900c2SGreg Roach 161158900c2SGreg Roach /** 1626612c384SGreg Roach * @return self 163c3bff7b4SGreg Roach */ 164c3bff7b4SGreg Roach public function isNotEmpty(): self 165c3bff7b4SGreg Roach { 166*1ff45046SGreg Roach $this->rules[] = static fn (string|null $value): string|null => $value !== null && $value !== '' ? $value : null; 167c3bff7b4SGreg Roach 168c3bff7b4SGreg Roach return $this; 169c3bff7b4SGreg Roach } 170c3bff7b4SGreg Roach 1711c6adce8SGreg Roach /** 1726612c384SGreg Roach * @return self 17381b729d3SGreg Roach */ 174f507cef9SGreg Roach public function isLocalUrl(): self 1758d9c2b68SGreg Roach { 176f507cef9SGreg Roach $base_url = $this->request->getAttribute('base_url', ''); 177f507cef9SGreg Roach 178*1ff45046SGreg Roach $this->rules[] = static function (string|null $value) use ($base_url): string|null { 179c3bff7b4SGreg Roach if ($value !== null) { 1808d9c2b68SGreg Roach $value_info = parse_url($value); 1818d9c2b68SGreg Roach $base_url_info = parse_url($base_url); 1828d9c2b68SGreg Roach 183f507cef9SGreg Roach if (is_array($value_info) && is_array($base_url_info)) { 1848d9c2b68SGreg Roach $scheme_ok = ($value_info['scheme'] ?? 'http') === ($base_url_info['scheme'] ?? 'http'); 1858d9c2b68SGreg Roach $host_ok = ($value_info['host'] ?? '') === ($base_url_info['host'] ?? ''); 1868d9c2b68SGreg Roach $port_ok = ($value_info['port'] ?? '') === ($base_url_info['port'] ?? ''); 1878d9c2b68SGreg Roach $user_ok = ($value_info['user'] ?? '') === ($base_url_info['user'] ?? ''); 1888d9c2b68SGreg Roach $path_ok = str_starts_with($value_info['path'] ?? '/', $base_url_info['path'] ?? '/'); 1898d9c2b68SGreg Roach 1908d9c2b68SGreg Roach if ($scheme_ok && $host_ok && $port_ok && $user_ok && $path_ok) { 1918d9c2b68SGreg Roach return $value; 1928d9c2b68SGreg Roach } 1938d9c2b68SGreg Roach } 1948d9c2b68SGreg Roach } 1958d9c2b68SGreg Roach 1962b1a9a98SGreg Roach return null; 19781b729d3SGreg Roach }; 19881b729d3SGreg Roach 19981b729d3SGreg Roach return $this; 20081b729d3SGreg Roach } 20181b729d3SGreg Roach 20281b729d3SGreg Roach /** 2036612c384SGreg Roach * @return self 20481b729d3SGreg Roach */ 205b55cbc6bSGreg Roach public function isTag(): self 206b55cbc6bSGreg Roach { 207*1ff45046SGreg Roach $this->rules[] = static function (string|null $value): string|null { 208c3bff7b4SGreg Roach if ($value !== null && preg_match('/^' . Gedcom::REGEX_TAG . '$/', $value) === 1) { 209b55cbc6bSGreg Roach return $value; 210b55cbc6bSGreg Roach } 211b55cbc6bSGreg Roach 212b55cbc6bSGreg Roach return null; 213b55cbc6bSGreg Roach }; 214b55cbc6bSGreg Roach 215b55cbc6bSGreg Roach return $this; 216b55cbc6bSGreg Roach } 217b55cbc6bSGreg Roach 218b55cbc6bSGreg Roach /** 2196612c384SGreg Roach * @return self 220b55cbc6bSGreg Roach */ 22181b729d3SGreg Roach public function isXref(): self 22281b729d3SGreg Roach { 223748dbe15SGreg Roach $this->rules[] = static function ($value) { 224748dbe15SGreg Roach if (is_string($value) && preg_match('/^' . Gedcom::REGEX_XREF . '$/', $value) === 1) { 22581b729d3SGreg Roach return $value; 22681b729d3SGreg Roach } 22781b729d3SGreg Roach 228748dbe15SGreg Roach if (is_array($value)) { 229a5f003cfSGreg Roach foreach ($value as $v) { 230a5f003cfSGreg Roach if (!is_string($v) || preg_match('/^' . Gedcom::REGEX_XREF . '$/', $v) !== 1) { 231a5f003cfSGreg Roach return null; 232a5f003cfSGreg Roach } 233a5f003cfSGreg Roach } 234a5f003cfSGreg Roach 235a5f003cfSGreg Roach return $value; 236748dbe15SGreg Roach } 237748dbe15SGreg Roach 23881b729d3SGreg Roach return null; 2398d9c2b68SGreg Roach }; 2408d9c2b68SGreg Roach 2418d9c2b68SGreg Roach return $this; 2428d9c2b68SGreg Roach } 2438d9c2b68SGreg Roach 2448d9c2b68SGreg Roach /** 2458d9c2b68SGreg Roach * @param string $parameter 246b55cbc6bSGreg Roach * @param bool|null $default 247b55cbc6bSGreg Roach * 248b55cbc6bSGreg Roach * @return bool 249b55cbc6bSGreg Roach */ 2502c6f1bd5SGreg Roach public function boolean(string $parameter, bool|null $default = null): bool 251b55cbc6bSGreg Roach { 252b55cbc6bSGreg Roach $value = $this->parameters[$parameter] ?? null; 253b55cbc6bSGreg Roach 25446b31fc1SGreg Roach if (in_array($value, ['1', 'on', true], true)) { 255b55cbc6bSGreg Roach return true; 256b55cbc6bSGreg Roach } 257b55cbc6bSGreg Roach 258b55cbc6bSGreg Roach if (in_array($value, ['0', '', false], true)) { 259b55cbc6bSGreg Roach return false; 260b55cbc6bSGreg Roach } 261b55cbc6bSGreg Roach 262b55cbc6bSGreg Roach if ($default === null) { 263b55cbc6bSGreg Roach throw new HttpBadRequestException(I18N::translate('The parameter “%s” is missing.', $parameter)); 264b55cbc6bSGreg Roach } 265b55cbc6bSGreg Roach 266b55cbc6bSGreg Roach return $default; 267b55cbc6bSGreg Roach } 268b55cbc6bSGreg Roach 269b55cbc6bSGreg Roach /** 270b55cbc6bSGreg Roach * @param string $parameter 27181b729d3SGreg Roach * 27281b729d3SGreg Roach * @return array<string> 27381b729d3SGreg Roach */ 274b55cbc6bSGreg Roach public function array(string $parameter): array 27581b729d3SGreg Roach { 276b55cbc6bSGreg Roach $value = $this->parameters[$parameter] ?? null; 27781b729d3SGreg Roach 27804b3bc82SGreg Roach if (!is_array($value) && $value !== null) { 27904b3bc82SGreg Roach throw new HttpBadRequestException(I18N::translate('The parameter “%s” is missing.', $parameter)); 280b55cbc6bSGreg Roach } 281b55cbc6bSGreg Roach 282*1ff45046SGreg Roach $callback = static fn (array|null $value, Closure $rule): array|null => $rule($value); 283b55cbc6bSGreg Roach 284748dbe15SGreg Roach return array_reduce($this->rules, $callback, $value) ?? []; 28581b729d3SGreg Roach } 28681b729d3SGreg Roach 28781b729d3SGreg Roach /** 28881b729d3SGreg Roach * @param string $parameter 289770da671SGreg Roach * @param float|null $default 290770da671SGreg Roach * 291770da671SGreg Roach * @return float 292770da671SGreg Roach */ 2932c6f1bd5SGreg Roach public function float(string $parameter, float|null $default = null): float 294770da671SGreg Roach { 295770da671SGreg Roach $value = $this->parameters[$parameter] ?? null; 296770da671SGreg Roach 297770da671SGreg Roach if (is_numeric($value)) { 298770da671SGreg Roach $value = (float) $value; 299770da671SGreg Roach } else { 300770da671SGreg Roach $value = null; 301770da671SGreg Roach } 302770da671SGreg Roach 303*1ff45046SGreg Roach $callback = static fn (?float $value, Closure $rule): float|null => $rule($value); 304770da671SGreg Roach 305770da671SGreg Roach $value = array_reduce($this->rules, $callback, $value) ?? $default; 306770da671SGreg Roach 307770da671SGreg Roach if ($value === null) { 308770da671SGreg Roach throw new HttpBadRequestException(I18N::translate('The parameter “%s” is missing.', $parameter)); 309770da671SGreg Roach } 310770da671SGreg Roach 311770da671SGreg Roach return $value; 312770da671SGreg Roach } 313770da671SGreg Roach 314770da671SGreg Roach /** 315770da671SGreg Roach * @param string $parameter 316b55cbc6bSGreg Roach * @param int|null $default 31781b729d3SGreg Roach * 31881b729d3SGreg Roach * @return int 31981b729d3SGreg Roach */ 3202c6f1bd5SGreg Roach public function integer(string $parameter, int|null $default = null): int 32181b729d3SGreg Roach { 322b55cbc6bSGreg Roach $value = $this->parameters[$parameter] ?? null; 323b55cbc6bSGreg Roach 32465625b93SGreg Roach if (is_string($value)) { 32565625b93SGreg Roach if (ctype_digit($value)) { 326b55cbc6bSGreg Roach $value = (int) $value; 32765625b93SGreg Roach } elseif (str_starts_with($value, '-') && ctype_digit(substr($value, 1))) { 32865625b93SGreg Roach $value = (int) $value; 32965625b93SGreg Roach } 33065625b93SGreg Roach } 33165625b93SGreg Roach 33265625b93SGreg Roach if (!is_int($value)) { 333b55cbc6bSGreg Roach $value = null; 334b55cbc6bSGreg Roach } 335b55cbc6bSGreg Roach 336*1ff45046SGreg Roach $callback = static fn (int|null $value, Closure $rule): int|null => $rule($value); 337b55cbc6bSGreg Roach 338748dbe15SGreg Roach $value = array_reduce($this->rules, $callback, $value) ?? $default; 33981b729d3SGreg Roach 3402b1a9a98SGreg Roach if ($value === null) { 3412b1a9a98SGreg Roach throw new HttpBadRequestException(I18N::translate('The parameter “%s” is missing.', $parameter)); 34281b729d3SGreg Roach } 34381b729d3SGreg Roach 3442b1a9a98SGreg Roach return $value; 34581b729d3SGreg Roach } 34681b729d3SGreg Roach 34781b729d3SGreg Roach /** 34881b729d3SGreg Roach * @param string $parameter 34981b729d3SGreg Roach * 350b55cbc6bSGreg Roach * @return Route 351b55cbc6bSGreg Roach */ 352b55cbc6bSGreg Roach public function route(string $parameter = 'route'): Route 353b55cbc6bSGreg Roach { 354b55cbc6bSGreg Roach $value = $this->parameters[$parameter] ?? null; 355b55cbc6bSGreg Roach 356b55cbc6bSGreg Roach if ($value instanceof Route) { 357b55cbc6bSGreg Roach return $value; 358b55cbc6bSGreg Roach } 359b55cbc6bSGreg Roach 360b55cbc6bSGreg Roach throw new HttpBadRequestException(I18N::translate('The parameter “%s” is missing.', $parameter)); 361b55cbc6bSGreg Roach } 362b55cbc6bSGreg Roach 363b55cbc6bSGreg Roach /** 364b55cbc6bSGreg Roach * @param string $parameter 365b55cbc6bSGreg Roach * @param string|null $default 366b55cbc6bSGreg Roach * 36781b729d3SGreg Roach * @return string 36881b729d3SGreg Roach */ 3692c6f1bd5SGreg Roach public function string(string $parameter, string|null $default = null): string 37081b729d3SGreg Roach { 371b55cbc6bSGreg Roach $value = $this->parameters[$parameter] ?? null; 37281b729d3SGreg Roach 373b55cbc6bSGreg Roach if (!is_string($value)) { 374b55cbc6bSGreg Roach $value = null; 375b55cbc6bSGreg Roach } 376b55cbc6bSGreg Roach 377*1ff45046SGreg Roach $callback = static fn (string|null $value, Closure $rule): string|null => $rule($value); 378b55cbc6bSGreg Roach 379748dbe15SGreg Roach $value = array_reduce($this->rules, $callback, $value) ?? $default; 380b55cbc6bSGreg Roach 381748dbe15SGreg Roach if ($value === null) { 3822b1a9a98SGreg Roach throw new HttpBadRequestException(I18N::translate('The parameter “%s” is missing.', $parameter)); 38381b729d3SGreg Roach } 38481b729d3SGreg Roach 3852b1a9a98SGreg Roach return $value; 38681b729d3SGreg Roach } 387b55cbc6bSGreg Roach 388b55cbc6bSGreg Roach /** 389b55cbc6bSGreg Roach * @param string $parameter 390b55cbc6bSGreg Roach * 391b55cbc6bSGreg Roach * @return Tree 392b55cbc6bSGreg Roach */ 393b55cbc6bSGreg Roach public function tree(string $parameter = 'tree'): Tree 394b55cbc6bSGreg Roach { 395b55cbc6bSGreg Roach $value = $this->parameters[$parameter] ?? null; 396b55cbc6bSGreg Roach 397b55cbc6bSGreg Roach if ($value instanceof Tree) { 398b55cbc6bSGreg Roach return $value; 399b55cbc6bSGreg Roach } 400b55cbc6bSGreg Roach 401b55cbc6bSGreg Roach throw new HttpBadRequestException(I18N::translate('The parameter “%s” is missing.', $parameter)); 402b55cbc6bSGreg Roach } 403b55cbc6bSGreg Roach 404*1ff45046SGreg Roach public function treeOptional(string $parameter = 'tree'): Tree|null 405b55cbc6bSGreg Roach { 406b55cbc6bSGreg Roach $value = $this->parameters[$parameter] ?? null; 407b55cbc6bSGreg Roach 408b55cbc6bSGreg Roach if ($value === null || $value instanceof Tree) { 409b55cbc6bSGreg Roach return $value; 410b55cbc6bSGreg Roach } 411b55cbc6bSGreg Roach 412b55cbc6bSGreg Roach throw new HttpBadRequestException(I18N::translate('The parameter “%s” is missing.', $parameter)); 413b55cbc6bSGreg Roach } 414b55cbc6bSGreg Roach 415b55cbc6bSGreg Roach public function user(string $parameter = 'user'): UserInterface 416b55cbc6bSGreg Roach { 417b55cbc6bSGreg Roach $value = $this->parameters[$parameter] ?? null; 418b55cbc6bSGreg Roach 419b55cbc6bSGreg Roach if ($value instanceof UserInterface) { 420b55cbc6bSGreg Roach return $value; 421b55cbc6bSGreg Roach } 422b55cbc6bSGreg Roach 423b55cbc6bSGreg Roach throw new HttpBadRequestException(I18N::translate('The parameter “%s” is missing.', $parameter)); 424b55cbc6bSGreg Roach } 4258d9c2b68SGreg Roach} 426