1<?php 2 3/** 4 * webtrees: online genealogy 5 * Copyright (C) 2022 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 */ 57 public function __construct(array $parameters, ServerRequestInterface $request) 58 { 59 // All keys and values must be valid UTF-8 60 $check_utf8 = static function ($value, $key): void { 61 if (is_string($key) && preg_match('//u', $key) !== 1) { 62 throw new HttpBadRequestException('Invalid UTF-8 characters in request'); 63 } 64 if (is_string($value) && preg_match('//u', $value) !== 1) { 65 throw new HttpBadRequestException('Invalid UTF-8 characters in request'); 66 } 67 }; 68 69 array_walk_recursive($parameters, $check_utf8); 70 71 $this->parameters = $parameters; 72 $this->request = $request; 73 } 74 75 /** 76 * @param ServerRequestInterface $request 77 * 78 * @return self 79 */ 80 public static function attributes(ServerRequestInterface $request): self 81 { 82 return new self($request->getAttributes(), $request); 83 } 84 85 /** 86 * @param ServerRequestInterface $request 87 * 88 * @return self 89 */ 90 public static function parsedBody(ServerRequestInterface $request): self 91 { 92 return new self((array) $request->getParsedBody(), $request); 93 } 94 95 /** 96 * @param ServerRequestInterface $request 97 * 98 * @return self 99 */ 100 public static function queryParams(ServerRequestInterface $request): self 101 { 102 return new self($request->getQueryParams(), $request); 103 } 104 105 /** 106 * @param ServerRequestInterface $request 107 * 108 * @return self 109 */ 110 public static function serverParams(ServerRequestInterface $request): self 111 { 112 return new self($request->getServerParams(), $request); 113 } 114 115 /** 116 * @param int $minimum 117 * @param int $maximum 118 * 119 * @return self 120 */ 121 public function isBetween(int $minimum, int $maximum): self 122 { 123 $this->rules[] = static function (?int $value) use ($minimum, $maximum): ?int { 124 if (is_int($value) && $value >= $minimum && $value <= $maximum) { 125 return $value; 126 } 127 128 return null; 129 }; 130 131 return $this; 132 } 133 134 /** 135 * @param array<string> $values 136 * 137 * @return self 138 */ 139 public function isInArray(array $values): self 140 { 141 $this->rules[] = static fn (/*int|string|null*/ $value)/*: int|string|null*/ => $value !== null && in_array($value, $values, true) ? $value : null; 142 143 return $this; 144 } 145 146 /** 147 * @param array<string> $values 148 * 149 * @return self 150 */ 151 public function isInArrayKeys(array $values): self 152 { 153 return $this->isInArray(array_keys($values)); 154 } 155 156 /** 157 * @return self 158 */ 159 public function isNotEmpty(): self 160 { 161 $this->rules[] = static fn (?string $value): ?string => $value !== null && $value !== '' ? $value : null; 162 163 return $this; 164 } 165 166 /** 167 * @return self 168 */ 169 public function isLocalUrl(): self 170 { 171 $base_url = $this->request->getAttribute('base_url', ''); 172 173 $this->rules[] = static function (?string $value) use ($base_url): ?string { 174 if ($value !== null) { 175 $value_info = parse_url($value); 176 $base_url_info = parse_url($base_url); 177 178 if (is_array($value_info) && is_array($base_url_info)) { 179 $scheme_ok = ($value_info['scheme'] ?? 'http') === ($base_url_info['scheme'] ?? 'http'); 180 $host_ok = ($value_info['host'] ?? '') === ($base_url_info['host'] ?? ''); 181 $port_ok = ($value_info['port'] ?? '') === ($base_url_info['port'] ?? ''); 182 $user_ok = ($value_info['user'] ?? '') === ($base_url_info['user'] ?? ''); 183 $path_ok = str_starts_with($value_info['path'] ?? '/', $base_url_info['path'] ?? '/'); 184 185 if ($scheme_ok && $host_ok && $port_ok && $user_ok && $path_ok) { 186 return $value; 187 } 188 } 189 } 190 191 return null; 192 }; 193 194 return $this; 195 } 196 197 /** 198 * @return self 199 */ 200 public function isTag(): self 201 { 202 $this->rules[] = static function (?string $value): ?string { 203 if ($value !== null && preg_match('/^' . Gedcom::REGEX_TAG . '$/', $value) === 1) { 204 return $value; 205 } 206 207 return null; 208 }; 209 210 return $this; 211 } 212 213 /** 214 * @return self 215 */ 216 public function isXref(): self 217 { 218 $this->rules[] = static function ($value) { 219 if (is_string($value) && preg_match('/^' . Gedcom::REGEX_XREF . '$/', $value) === 1) { 220 return $value; 221 } 222 223 if (is_array($value)) { 224 return array_filter($value, static fn ($x): bool => is_string($x) && preg_match('/^' . Gedcom::REGEX_XREF . '$/', $x) === 1); 225 } 226 227 return null; 228 }; 229 230 return $this; 231 } 232 233 /** 234 * @param string $parameter 235 * @param bool|null $default 236 * 237 * @return bool 238 */ 239 public function boolean(string $parameter, bool $default = null): bool 240 { 241 $value = $this->parameters[$parameter] ?? null; 242 243 if (in_array($value, ['1', 'on', true], true)) { 244 return true; 245 } 246 247 if (in_array($value, ['0', '', false], true)) { 248 return false; 249 } 250 251 if ($default === null) { 252 throw new HttpBadRequestException(I18N::translate('The parameter “%s” is missing.', $parameter)); 253 } 254 255 return $default; 256 } 257 258 /** 259 * @param string $parameter 260 * 261 * @return array<string> 262 */ 263 public function array(string $parameter): array 264 { 265 $value = $this->parameters[$parameter] ?? null; 266 267 if (!is_array($value) && $value !== null) { 268 throw new HttpBadRequestException(I18N::translate('The parameter “%s” is missing.', $parameter)); 269 } 270 271 $callback = static fn (?array $value, Closure $rule): ?array => $rule($value); 272 273 return array_reduce($this->rules, $callback, $value) ?? []; 274 } 275 276 /** 277 * @param string $parameter 278 * @param int|null $default 279 * 280 * @return int 281 */ 282 public function integer(string $parameter, int $default = null): int 283 { 284 $value = $this->parameters[$parameter] ?? null; 285 286 if (is_string($value)) { 287 if (ctype_digit($value)) { 288 $value = (int) $value; 289 } elseif (str_starts_with($value, '-') && ctype_digit(substr($value, 1))) { 290 $value = (int) $value; 291 } 292 } 293 294 if (!is_int($value)) { 295 $value = null; 296 } 297 298 $callback = static fn (?int $value, Closure $rule): ?int => $rule($value); 299 300 $value = array_reduce($this->rules, $callback, $value) ?? $default; 301 302 if ($value === null) { 303 throw new HttpBadRequestException(I18N::translate('The parameter “%s” is missing.', $parameter)); 304 } 305 306 return $value; 307 } 308 309 /** 310 * @param string $parameter 311 * 312 * @return Route 313 */ 314 public function route(string $parameter = 'route'): Route 315 { 316 $value = $this->parameters[$parameter] ?? null; 317 318 if ($value instanceof Route) { 319 return $value; 320 } 321 322 throw new HttpBadRequestException(I18N::translate('The parameter “%s” is missing.', $parameter)); 323 } 324 325 /** 326 * @param string $parameter 327 * @param string|null $default 328 * 329 * @return string 330 */ 331 public function string(string $parameter, string $default = null): string 332 { 333 $value = $this->parameters[$parameter] ?? null; 334 335 if (!is_string($value)) { 336 $value = null; 337 } 338 339 $callback = static fn (?string $value, Closure $rule): ?string => $rule($value); 340 341 $value = array_reduce($this->rules, $callback, $value) ?? $default; 342 343 if ($value === null) { 344 throw new HttpBadRequestException(I18N::translate('The parameter “%s” is missing.', $parameter)); 345 } 346 347 return $value; 348 } 349 350 /** 351 * @param string $parameter 352 * 353 * @return Tree 354 */ 355 public function tree(string $parameter = 'tree'): Tree 356 { 357 $value = $this->parameters[$parameter] ?? null; 358 359 if ($value instanceof Tree) { 360 return $value; 361 } 362 363 throw new HttpBadRequestException(I18N::translate('The parameter “%s” is missing.', $parameter)); 364 } 365 366 /** 367 * @param string $parameter 368 * 369 * @return Tree|null 370 */ 371 public function treeOptional(string $parameter = 'tree'): ?Tree 372 { 373 $value = $this->parameters[$parameter] ?? null; 374 375 if ($value === null || $value instanceof Tree) { 376 return $value; 377 } 378 379 throw new HttpBadRequestException(I18N::translate('The parameter “%s” is missing.', $parameter)); 380 } 381 382 /** 383 * @param string $parameter 384 * 385 * @return UserInterface 386 */ 387 public function user(string $parameter = 'user'): UserInterface 388 { 389 $value = $this->parameters[$parameter] ?? null; 390 391 if ($value instanceof UserInterface) { 392 return $value; 393 } 394 395 throw new HttpBadRequestException(I18N::translate('The parameter “%s” is missing.', $parameter)); 396 } 397} 398