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