xref: /webtrees/app/Validator.php (revision 0acf1b4bae16cbf21a23b10e22e968d2efdf4aea)
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    {
1282b1a9a98SGreg Roach        $this->rules[] = static function (?int $value) use ($minimum, $maximum): ?int {
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    /**
140*0acf1b4bSGreg 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    /**
152*0acf1b4bSGreg 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    {
166c3bff7b4SGreg Roach        $this->rules[] = static fn (?string $value): ?string => $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
1782b1a9a98SGreg Roach        $this->rules[] = static function (?string $value) use ($base_url): ?string {
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    {
207b55cbc6bSGreg Roach        $this->rules[] = static function (?string $value): ?string {
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     */
250b55cbc6bSGreg Roach    public function boolean(string $parameter, bool $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
282b55cbc6bSGreg Roach        $callback = static fn (?array $value, Closure $rule): ?array => $rule($value);
283b55cbc6bSGreg Roach
284748dbe15SGreg Roach        return array_reduce($this->rules, $callback, $value) ?? [];
28581b729d3SGreg Roach    }
28681b729d3SGreg Roach
28781b729d3SGreg Roach    /**
28881b729d3SGreg Roach     * @param string   $parameter
289b55cbc6bSGreg Roach     * @param int|null $default
29081b729d3SGreg Roach     *
29181b729d3SGreg Roach     * @return int
29281b729d3SGreg Roach     */
293b55cbc6bSGreg Roach    public function integer(string $parameter, int $default = null): int
29481b729d3SGreg Roach    {
295b55cbc6bSGreg Roach        $value = $this->parameters[$parameter] ?? null;
296b55cbc6bSGreg Roach
29765625b93SGreg Roach        if (is_string($value)) {
29865625b93SGreg Roach            if (ctype_digit($value)) {
299b55cbc6bSGreg Roach                $value = (int) $value;
30065625b93SGreg Roach            } elseif (str_starts_with($value, '-') && ctype_digit(substr($value, 1))) {
30165625b93SGreg Roach                $value = (int) $value;
30265625b93SGreg Roach            }
30365625b93SGreg Roach        }
30465625b93SGreg Roach
30565625b93SGreg Roach        if (!is_int($value)) {
306b55cbc6bSGreg Roach            $value = null;
307b55cbc6bSGreg Roach        }
308b55cbc6bSGreg Roach
309b55cbc6bSGreg Roach        $callback = static fn (?int $value, Closure $rule): ?int => $rule($value);
310b55cbc6bSGreg Roach
311748dbe15SGreg Roach        $value = array_reduce($this->rules, $callback, $value) ?? $default;
31281b729d3SGreg Roach
3132b1a9a98SGreg Roach        if ($value === null) {
3142b1a9a98SGreg Roach            throw new HttpBadRequestException(I18N::translate('The parameter “%s” is missing.', $parameter));
31581b729d3SGreg Roach        }
31681b729d3SGreg Roach
3172b1a9a98SGreg Roach        return $value;
31881b729d3SGreg Roach    }
31981b729d3SGreg Roach
32081b729d3SGreg Roach    /**
32181b729d3SGreg Roach     * @param string $parameter
32281b729d3SGreg Roach     *
323b55cbc6bSGreg Roach     * @return Route
324b55cbc6bSGreg Roach     */
325b55cbc6bSGreg Roach    public function route(string $parameter = 'route'): Route
326b55cbc6bSGreg Roach    {
327b55cbc6bSGreg Roach        $value = $this->parameters[$parameter] ?? null;
328b55cbc6bSGreg Roach
329b55cbc6bSGreg Roach        if ($value instanceof Route) {
330b55cbc6bSGreg Roach            return $value;
331b55cbc6bSGreg Roach        }
332b55cbc6bSGreg Roach
333b55cbc6bSGreg Roach        throw new HttpBadRequestException(I18N::translate('The parameter “%s” is missing.', $parameter));
334b55cbc6bSGreg Roach    }
335b55cbc6bSGreg Roach
336b55cbc6bSGreg Roach    /**
337b55cbc6bSGreg Roach     * @param string      $parameter
338b55cbc6bSGreg Roach     * @param string|null $default
339b55cbc6bSGreg Roach     *
34081b729d3SGreg Roach     * @return string
34181b729d3SGreg Roach     */
342b55cbc6bSGreg Roach    public function string(string $parameter, string $default = null): string
34381b729d3SGreg Roach    {
344b55cbc6bSGreg Roach        $value = $this->parameters[$parameter] ?? null;
34581b729d3SGreg Roach
346b55cbc6bSGreg Roach        if (!is_string($value)) {
347b55cbc6bSGreg Roach            $value = null;
348b55cbc6bSGreg Roach        }
349b55cbc6bSGreg Roach
350b55cbc6bSGreg Roach        $callback = static fn (?string $value, Closure $rule): ?string => $rule($value);
351b55cbc6bSGreg Roach
352748dbe15SGreg Roach        $value =  array_reduce($this->rules, $callback, $value) ?? $default;
353b55cbc6bSGreg Roach
354748dbe15SGreg Roach        if ($value === null) {
3552b1a9a98SGreg Roach            throw new HttpBadRequestException(I18N::translate('The parameter “%s” is missing.', $parameter));
35681b729d3SGreg Roach        }
35781b729d3SGreg Roach
3582b1a9a98SGreg Roach        return $value;
35981b729d3SGreg Roach    }
360b55cbc6bSGreg Roach
361b55cbc6bSGreg Roach    /**
362b55cbc6bSGreg Roach     * @param string $parameter
363b55cbc6bSGreg Roach     *
364b55cbc6bSGreg Roach     * @return Tree
365b55cbc6bSGreg Roach     */
366b55cbc6bSGreg Roach    public function tree(string $parameter = 'tree'): Tree
367b55cbc6bSGreg Roach    {
368b55cbc6bSGreg Roach        $value = $this->parameters[$parameter] ?? null;
369b55cbc6bSGreg Roach
370b55cbc6bSGreg Roach        if ($value instanceof Tree) {
371b55cbc6bSGreg Roach            return $value;
372b55cbc6bSGreg Roach        }
373b55cbc6bSGreg Roach
374b55cbc6bSGreg Roach        throw new HttpBadRequestException(I18N::translate('The parameter “%s” is missing.', $parameter));
375b55cbc6bSGreg Roach    }
376b55cbc6bSGreg Roach
377b55cbc6bSGreg Roach    /**
378b55cbc6bSGreg Roach     * @param string $parameter
379b55cbc6bSGreg Roach     *
380b55cbc6bSGreg Roach     * @return Tree|null
381b55cbc6bSGreg Roach     */
382b55cbc6bSGreg Roach    public function treeOptional(string $parameter = 'tree'): ?Tree
383b55cbc6bSGreg Roach    {
384b55cbc6bSGreg Roach        $value = $this->parameters[$parameter] ?? null;
385b55cbc6bSGreg Roach
386b55cbc6bSGreg Roach        if ($value === null || $value instanceof Tree) {
387b55cbc6bSGreg Roach            return $value;
388b55cbc6bSGreg Roach        }
389b55cbc6bSGreg Roach
390b55cbc6bSGreg Roach        throw new HttpBadRequestException(I18N::translate('The parameter “%s” is missing.', $parameter));
391b55cbc6bSGreg Roach    }
392b55cbc6bSGreg Roach
393b55cbc6bSGreg Roach    /**
394b55cbc6bSGreg Roach     * @param string $parameter
395b55cbc6bSGreg Roach     *
396b55cbc6bSGreg Roach     * @return UserInterface
397b55cbc6bSGreg Roach     */
398b55cbc6bSGreg Roach    public function user(string $parameter = 'user'): UserInterface
399b55cbc6bSGreg Roach    {
400b55cbc6bSGreg Roach        $value = $this->parameters[$parameter] ?? null;
401b55cbc6bSGreg Roach
402b55cbc6bSGreg Roach        if ($value instanceof UserInterface) {
403b55cbc6bSGreg Roach            return $value;
404b55cbc6bSGreg Roach        }
405b55cbc6bSGreg Roach
406b55cbc6bSGreg Roach        throw new HttpBadRequestException(I18N::translate('The parameter “%s” is missing.', $parameter));
407b55cbc6bSGreg Roach    }
4088d9c2b68SGreg Roach}
409