Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
38.71% covered (danger)
38.71%
48 / 124
33.33% covered (danger)
33.33%
4 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
HttpUtils
38.71% covered (danger)
38.71%
48 / 124
33.33% covered (danger)
33.33%
4 / 12
408.38
0.00% covered (danger)
0.00%
0 / 1
 getBotRegexes
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
1
 isBot
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
3.14
 getEInkRegexes
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 isEInk
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
3.14
 measure
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
20
 getNormalizedPath
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
20
 stripParams
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
42
 dieWithHttpError
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 redirect
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
2
 validateGetParams
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
42
 curlInit
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
2
 curlExec
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
20
 sendHttpResponseCode
n/a
0 / 0
n/a
0 / 0
1
 sendHeader
n/a
0 / 0
n/a
0 / 0
1
 sendHttpBody
n/a
0 / 0
n/a
0 / 0
1
 exitExecution
n/a
0 / 0
n/a
0 / 0
1
1<?php
2
3namespace Olz\Utils;
4
5use Olz\Components\Error\OlzErrorPage\OlzErrorPage;
6use Olz\Components\Page\OlzFooter\OlzFooter;
7use Olz\Components\Page\OlzHeaderWithoutRouting\OlzHeaderWithoutRouting;
8use Olz\Entity\Counter;
9use PHPStan\PhpDocParser\Ast\Type\TypeNode;
10use PhpTypeScriptApi\PhpStan\PhpStanUtils;
11use PhpTypeScriptApi\PhpStan\ValidateVisitor;
12use Symfony\Component\HttpFoundation\Request;
13use Symfony\Component\HttpFoundation\Response;
14
15class HttpUtils {
16    use WithUtilsTrait;
17
18    /** @var non-empty-string */
19    protected static string $user_agent_string = "Mozilla/5.0 (compatible; olz-website/1.0; +https://github.com/olzimmerberg/olz-website/blob/main/src/Utils/HttpUtils.php)";
20
21    /** @return array<string> */
22    public function getBotRegexes(): array {
23        return [
24            '/bingbot/i',
25            '/googlebot/i',
26            '/google/i',
27            '/facebookexternalhit/i',
28            '/applebot/i',
29            '/yandexbot/i',
30            '/ecosia/i',
31            '/phpservermon/i',
32            '/OlzSystemTest\//i',
33            '/bot\//i',
34            '/crawler\//i',
35        ];
36    }
37
38    public function isBot(string $user_agent): bool {
39        foreach ($this->getBotRegexes() as $regex) {
40            if (preg_match($regex, $user_agent)) {
41                return true;
42            }
43        }
44        return false;
45    }
46
47    /** @return array<string> */
48    public function getEInkRegexes(): array {
49        return [
50            '/kindle\//i',
51            '/pocketbook\//i',
52        ];
53    }
54
55    public function isEInk(string $user_agent): bool {
56        foreach ($this->getEInkRegexes() as $regex) {
57            if (preg_match($regex, $user_agent)) {
58                return true;
59            }
60        }
61        return false;
62    }
63
64    /**
65     * @param array<string>               $get_params
66     * @param callable(Request): Response $get_response
67     */
68    #[IgnoreInTrace]
69    public function measure(
70        Request $request,
71        array $get_params,
72        callable $get_response,
73    ): Response {
74        $is_bot = $this->isBot($this->server()['HTTP_USER_AGENT'] ?? '');
75        $normalized_path = $this->getNormalizedPath($request, $get_params);
76        $counter_repo = $this->entityManager()->getRepository(Counter::class);
77        if (!$is_bot) {
78            try {
79                $counter_repo->recordVisit($normalized_path);
80            } catch (\Throwable $th) {
81            }
82        }
83        $started_at = microtime(true);
84        $response = $get_response($request);
85        $duration = microtime(true) - $started_at;
86        try {
87            $counter_repo->recordLatency($normalized_path, $duration * 1000);
88        } catch (\Throwable $th) {
89        }
90        return $response;
91    }
92
93    /** @param array<string> $get_params */
94    public function getNormalizedPath(Request $request, array $get_params = []): string {
95        $path = "{$request->getBasePath()}{$request->getPathInfo()}";
96        $query = [];
97        foreach ($get_params as $key) {
98            $value = $request->query->get($key);
99            if ($value !== null) {
100                $query[] = "{$key}={$value}";
101            }
102        }
103        $pretty_query = empty($query) ? '' : '?'.implode('&', $query);
104        return "{$path}{$pretty_query}";
105    }
106
107    /** @param array<string> $get_params */
108    public function stripParams(Request $request, array $get_params = []): void {
109        $should_strip = false;
110        $query = [];
111        foreach ($request->query->all() as $key => $value) {
112            if (in_array($key, $get_params)) {
113                $should_strip = true;
114            } elseif (is_string($value)) {
115                $query[] = "{$key}={$value}";
116            }
117        }
118        if (!$should_strip) {
119            return;
120        }
121        $path = "{$request->getBasePath()}{$request->getPathInfo()}";
122        $pretty_query = empty($query) ? '' : '?'.implode('&', $query);
123        $this->redirect("{$path}{$pretty_query}", ['status' => 308]);
124    }
125
126    public function dieWithHttpError(int $http_status_code): void {
127        $this->sendHttpResponseCode($http_status_code);
128
129        $out = OlzErrorPage::render([
130            'http_status_code' => $http_status_code,
131        ]);
132
133        $this->sendHttpBody($out);
134        $this->exitExecution();
135    }
136
137    /**
138     * @param array{
139     *   status?: int,
140     *   no_cache?: true,
141     * } $config
142     */
143    public function redirect(string $redirect_url, array $config = []): void {
144        $http_status_code = $config['status'] ?? 301;
145        $no_cache = $config['no_cache'] ?? false;
146        $this->sendHeader("Location: {$redirect_url}");
147        if ($no_cache) {
148            $this->sendHeader('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
149        }
150        $this->sendHttpResponseCode($http_status_code);
151
152        $out = "";
153        $out .= OlzHeaderWithoutRouting::render([
154            'title' => "Weiterleitung...",
155        ]);
156
157        $enc_redirect_url = json_encode($redirect_url);
158        $out .= <<<ZZZZZZZZZZ
159            <div class='content-full'>
160                <h2>Automatische Weiterleitung...</h2>
161                <p>Falls die automatische Weiterleitung nicht funktionieren sollte, kannst du auch diesen Link anklicken:</p>
162                <p><b><a href='{$redirect_url}' class='linkint' id='redirect-link'>{$redirect_url}</a></b></p>
163                <script type='text/javascript'>
164                    window.setTimeout(function () {
165                        window.location.href = {$enc_redirect_url};
166                    }, 1000);
167                </script>
168            </div>
169            ZZZZZZZZZZ;
170
171        $out .= OlzFooter::render();
172        $this->sendHttpBody($out);
173        $this->exitExecution();
174    }
175
176    /**
177     * @template T of array
178     *
179     * @param class-string<HttpParams<T>>             $params_class
180     * @param ?array<string, ?(string|array<string>)> $get_params
181     * @param array{just_log?: bool}                  $options
182     *
183     * @return T
184     */
185    public function validateGetParams(string $params_class, ?array $get_params = null, array $options = []): array {
186        if ($get_params === null) {
187            $get_params = $this->getParams();
188        }
189        $utils = new PhpStanUtils();
190        $generics = $utils->getSuperGenerics($params_class, HttpParams::class);
191        $type = $generics[0] ?? null;
192        if (!$type) {
193            $this->dieWithHttpError(400);
194            throw new \Exception('should already have failed');
195        }
196        $resolved_type = $utils->resolveType($type, $params_class);
197        if (!$resolved_type instanceof TypeNode) {
198            $this->dieWithHttpError(400);
199            throw new \Exception('should already have failed');
200        }
201        $result = ValidateVisitor::validateDeserialize($utils, $get_params, $resolved_type, []);
202        if (!$result->isValid() && ($options['just_log'] ?? false) === false) {
203            $this->dieWithHttpError(400);
204            throw new \Exception('should already have failed');
205        }
206        return $result->getValue();
207    }
208
209    /**
210     * @param non-empty-string $url
211     * @param array{
212     *   headers?: array<string>,
213     *   userAgent?: non-empty-string,
214     *   connectTimeout?: int,
215     *   timeout?: int,
216     * } $options
217     */
218    public function curlInit(string $url, array $options = []): \CurlHandle {
219        $ch = curl_init();
220
221        $headers = [
222            'Cache-Control: no-cache, no-store, must-revalidate',
223            'Pragma: no-cache',
224            'Expires: 0',
225            ...($options['headers'] ?? []),
226        ];
227        curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
228        curl_setopt($ch, CURLOPT_URL, $url);
229        curl_setopt($ch, CURLOPT_USERAGENT, $options['userAgent'] ?? self::$user_agent_string);
230        curl_setopt($ch, CURLOPT_FRESH_CONNECT, true);
231        curl_setopt($ch, CURLOPT_FORBID_REUSE, true);
232        // @phpstan-ignore-next-line argument.type
233        curl_setopt($ch, CURLOPT_PROXY, null);
234        curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
235        curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
236        curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, $options['connectTimeout'] ?? 10);
237        curl_setopt($ch, CURLOPT_TIMEOUT, $options['timeout'] ?? 20);
238        return $ch;
239    }
240
241    public function curlExec(\CurlHandle $ch): string {
242        $result = curl_exec($ch);
243        $errno = curl_errno($ch);
244        $error = curl_error($ch);
245        if ($errno || !is_string($result)) {
246            $url = curl_getinfo($ch, CURLINFO_EFFECTIVE_URL);
247            throw new \Exception("Error fetching {$url}: {$error} ({$errno})");
248        }
249        return $result ?: '';
250    }
251
252    // @codeCoverageIgnoreStart
253    // Reason: Mock functions for tests.
254
255    protected function sendHttpResponseCode(int $http_response_code): void {
256        http_response_code($http_response_code);
257    }
258
259    protected function sendHeader(string $http_header_line): void {
260        header($http_header_line);
261    }
262
263    protected function sendHttpBody(string $http_body): void {
264        echo $http_body;
265    }
266
267    protected function exitExecution(): void {
268        exit('');
269    }
270
271    // @codeCoverageIgnoreEnd
272}