1<?php 2 3/** 4 * webtrees: online genealogy 5 * Copyright (C) 2023 webtrees development team 6 * This program is free software: you can redistribute it and/or modify 7 * it under the terms of the GNU General Public License as published by 8 * the Free Software Foundation, either version 3 of the License, or 9 * (at your option) any later version. 10 * This program is distributed in the hope that it will be useful, 11 * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 * GNU General Public License for more details. 14 * You should have received a copy of the GNU General Public License 15 * along with this program. If not, see <https://www.gnu.org/licenses/>. 16 */ 17 18declare(strict_types=1); 19 20namespace Fisharebest\Webtrees; 21 22use Aura\Router\Route; 23use Closure; 24use Fisharebest\Webtrees\Contracts\UserInterface; 25use Fisharebest\Webtrees\Http\Exceptions\HttpBadRequestException; 26use Psr\Http\Message\ServerRequestInterface; 27 28use function array_reduce; 29use function array_walk_recursive; 30use function ctype_digit; 31use function in_array; 32use function is_array; 33use function is_int; 34use function is_string; 35use function parse_url; 36use function preg_match; 37use function str_starts_with; 38use function substr; 39 40/** 41 * Validate a parameter from an HTTP request 42 */ 43class Validator 44{ 45 /** @var array<int|string|Tree|UserInterface|array<int|string>> */ 46 private array $parameters; 47 48 private ServerRequestInterface $request; 49 50 /** @var array<Closure> */ 51 private array $rules = []; 52 53 /** 54 * @param array<int|string|Tree|UserInterface|array<int|string>> $parameters 55 * @param ServerRequestInterface $request 56 * @param string $encoding 57 */ 58 private function __construct(array $parameters, ServerRequestInterface $request, string $encoding) 59 { 60 if ($encoding === 'UTF-8') { 61 // All keys and values must be valid UTF-8 62 $check_utf8 = static function ($value, $key): void { 63 if (is_string($key) && preg_match('//u', $key) !== 1) { 64 throw new HttpBadRequestException('Invalid UTF-8 characters in request'); 65 } 66 if (is_string($value) && preg_match('//u', $value) !== 1) { 67 throw new HttpBadRequestException('Invalid UTF-8 characters in request'); 68 } 69 }; 70 71 array_walk_recursive($parameters, $check_utf8); 72 } 73 74 $this->parameters = $parameters; 75 $this->request = $request; 76 } 77 78 /** 79 * @param ServerRequestInterface $request 80 * 81 * @return self 82 */ 83 public static function attributes(ServerRequestInterface $request): self 84 { 85 return new self($request->getAttributes(), $request, 'UTF-8'); 86 } 87 88 /** 89 * @param ServerRequestInterface $request 90 * 91 * @return self 92 */ 93 public static function parsedBody(ServerRequestInterface $request): self 94 { 95 return new self((array) $request->getParsedBody(), $request, 'UTF-8'); 96 } 97 98 /** 99 * @param ServerRequestInterface $request 100 * 101 * @return self 102 */ 103 public static function queryParams(ServerRequestInterface $request): self 104 { 105 return new self($request->getQueryParams(), $request, 'UTF-8'); 106 } 107 108 /** 109 * @param ServerRequestInterface $request 110 * 111 * @return self 112 */ 113 public static function serverParams(ServerRequestInterface $request): self 114 { 115 // Headers should be ASCII. 116 // However, we cannot enforce this as some servers add GEOIP headers with non-ASCII placenames. 117 return new self($request->getServerParams(), $request, 'ASCII'); 118 } 119 120 /** 121 * @param int $minimum 122 * @param int $maximum 123 * 124 * @return self 125 */ 126 public function isBetween(int $minimum, int $maximum): self 127 { 128 $this->rules[] = static function (?int $value) use ($minimum, $maximum): ?int { 129 if (is_int($value) && $value >= $minimum && $value <= $maximum) { 130 return $value; 131 } 132 133 return null; 134 }; 135 136 return $this; 137 } 138 139 /** 140 * @param array<int|string,int|string> $values 141 * 142 * @return self 143 */ 144 public function isInArray(array $values): self 145 { 146 $this->rules[] = static fn (int|string|null $value): int|string|null => in_array($value, $values, true) ? $value : null; 147 148 return $this; 149 } 150 151 /** 152 * @param array<int|string,int|string> $values 153 * 154 * @return self 155 */ 156 public function isInArrayKeys(array $values): self 157 { 158 return $this->isInArray(array_keys($values)); 159 } 160 161 /** 162 * @return self 163 */ 164 public function isNotEmpty(): self 165 { 166 $this->rules[] = static fn (?string $value): ?string => $value !== null && $value !== '' ? $value : null; 167 168 return $this; 169 } 170 171 /** 172 * @return self 173 */ 174 public function isLocalUrl(): self 175 { 176 $base_url = $this->request->getAttribute('base_url', ''); 177 178 $this->rules[] = static function (?string $value) use ($base_url): ?string { 179 if ($value !== null) { 180 $value_info = parse_url($value); 181 $base_url_info = parse_url($base_url); 182 183 if (is_array($value_info) && is_array($base_url_info)) { 184 $scheme_ok = ($value_info['scheme'] ?? 'http') === ($base_url_info['scheme'] ?? 'http'); 185 $host_ok = ($value_info['host'] ?? '') === ($base_url_info['host'] ?? ''); 186 $port_ok = ($value_info['port'] ?? '') === ($base_url_info['port'] ?? ''); 187 $user_ok = ($value_info['user'] ?? '') === ($base_url_info['user'] ?? ''); 188 $path_ok = str_starts_with($value_info['path'] ?? '/', $base_url_info['path'] ?? '/'); 189 190 if ($scheme_ok && $host_ok && $port_ok && $user_ok && $path_ok) { 191 return $value; 192 } 193 } 194 } 195 196 return null; 197 }; 198 199 return $this; 200 } 201 202 /** 203 * @return self 204 */ 205 public function isTag(): self 206 { 207 $this->rules[] = static function (?string $value): ?string { 208 if ($value !== null && preg_match('/^' . Gedcom::REGEX_TAG . '$/', $value) === 1) { 209 return $value; 210 } 211 212 return null; 213 }; 214 215 return $this; 216 } 217 218 /** 219 * @return self 220 */ 221 public function isXref(): self 222 { 223 $this->rules[] = static function ($value) { 224 if (is_string($value) && preg_match('/^' . Gedcom::REGEX_XREF . '$/', $value) === 1) { 225 return $value; 226 } 227 228 if (is_array($value)) { 229 foreach ($value as $v) { 230 if (!is_string($v) || preg_match('/^' . Gedcom::REGEX_XREF . '$/', $v) !== 1) { 231 return null; 232 } 233 } 234 235 return $value; 236 } 237 238 return null; 239 }; 240 241 return $this; 242 } 243 244 /** 245 * @param string $parameter 246 * @param bool|null $default 247 * 248 * @return bool 249 */ 250 public function boolean(string $parameter, bool|null $default = null): bool 251 { 252 $value = $this->parameters[$parameter] ?? null; 253 254 if (in_array($value, ['1', 'on', true], true)) { 255 return true; 256 } 257 258 if (in_array($value, ['0', '', false], true)) { 259 return false; 260 } 261 262 if ($default === null) { 263 throw new HttpBadRequestException(I18N::translate('The parameter “%s” is missing.', $parameter)); 264 } 265 266 return $default; 267 } 268 269 /** 270 * @param string $parameter 271 * 272 * @return array<string> 273 */ 274 public function array(string $parameter): array 275 { 276 $value = $this->parameters[$parameter] ?? null; 277 278 if (!is_array($value) && $value !== null) { 279 throw new HttpBadRequestException(I18N::translate('The parameter “%s” is missing.', $parameter)); 280 } 281 282 $callback = static fn (?array $value, Closure $rule): ?array => $rule($value); 283 284 return array_reduce($this->rules, $callback, $value) ?? []; 285 } 286 287 /** 288 * @param string $parameter 289 * @param float|null $default 290 * 291 * @return float 292 */ 293 public function float(string $parameter, float|null $default = null): float 294 { 295 $value = $this->parameters[$parameter] ?? null; 296 297 if (is_numeric($value)) { 298 $value = (float) $value; 299 } else { 300 $value = null; 301 } 302 303 $callback = static fn (?float $value, Closure $rule): ?float => $rule($value); 304 305 $value = array_reduce($this->rules, $callback, $value) ?? $default; 306 307 if ($value === null) { 308 throw new HttpBadRequestException(I18N::translate('The parameter “%s” is missing.', $parameter)); 309 } 310 311 return $value; 312 } 313 314 /** 315 * @param string $parameter 316 * @param int|null $default 317 * 318 * @return int 319 */ 320 public function integer(string $parameter, int|null $default = null): int 321 { 322 $value = $this->parameters[$parameter] ?? null; 323 324 if (is_string($value)) { 325 if (ctype_digit($value)) { 326 $value = (int) $value; 327 } elseif (str_starts_with($value, '-') && ctype_digit(substr($value, 1))) { 328 $value = (int) $value; 329 } 330 } 331 332 if (!is_int($value)) { 333 $value = null; 334 } 335 336 $callback = static fn (?int $value, Closure $rule): ?int => $rule($value); 337 338 $value = array_reduce($this->rules, $callback, $value) ?? $default; 339 340 if ($value === null) { 341 throw new HttpBadRequestException(I18N::translate('The parameter “%s” is missing.', $parameter)); 342 } 343 344 return $value; 345 } 346 347 /** 348 * @param string $parameter 349 * 350 * @return Route 351 */ 352 public function route(string $parameter = 'route'): Route 353 { 354 $value = $this->parameters[$parameter] ?? null; 355 356 if ($value instanceof Route) { 357 return $value; 358 } 359 360 throw new HttpBadRequestException(I18N::translate('The parameter “%s” is missing.', $parameter)); 361 } 362 363 /** 364 * @param string $parameter 365 * @param string|null $default 366 * 367 * @return string 368 */ 369 public function string(string $parameter, string|null $default = null): string 370 { 371 $value = $this->parameters[$parameter] ?? null; 372 373 if (!is_string($value)) { 374 $value = null; 375 } 376 377 $callback = static fn (?string $value, Closure $rule): ?string => $rule($value); 378 379 $value = array_reduce($this->rules, $callback, $value) ?? $default; 380 381 if ($value === null) { 382 throw new HttpBadRequestException(I18N::translate('The parameter “%s” is missing.', $parameter)); 383 } 384 385 return $value; 386 } 387 388 /** 389 * @param string $parameter 390 * 391 * @return Tree 392 */ 393 public function tree(string $parameter = 'tree'): Tree 394 { 395 $value = $this->parameters[$parameter] ?? null; 396 397 if ($value instanceof Tree) { 398 return $value; 399 } 400 401 throw new HttpBadRequestException(I18N::translate('The parameter “%s” is missing.', $parameter)); 402 } 403 404 /** 405 * @param string $parameter 406 * 407 * @return Tree|null 408 */ 409 public function treeOptional(string $parameter = 'tree'): ?Tree 410 { 411 $value = $this->parameters[$parameter] ?? null; 412 413 if ($value === null || $value instanceof Tree) { 414 return $value; 415 } 416 417 throw new HttpBadRequestException(I18N::translate('The parameter “%s” is missing.', $parameter)); 418 } 419 420 /** 421 * @param string $parameter 422 * 423 * @return UserInterface 424 */ 425 public function user(string $parameter = 'user'): UserInterface 426 { 427 $value = $this->parameters[$parameter] ?? null; 428 429 if ($value instanceof UserInterface) { 430 return $value; 431 } 432 433 throw new HttpBadRequestException(I18N::translate('The parameter “%s” is missing.', $parameter)); 434 } 435} 436