• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

eliashaeussler / typo3-warming / 15559961755

10 Jun 2025 12:50PM UTC coverage: 91.289% (+0.7%) from 90.617%
15559961755

push

github

web-flow
Merge pull request #871 from eliashaeussler/feature/url-metadata

187 of 193 new or added lines in 9 files covered. (96.89%)

1530 of 1676 relevant lines covered (91.29%)

9.64 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

92.5
/Classes/Http/Message/UrlMetadataFactory.php
1
<?php
2

3
declare(strict_types=1);
4

5
/*
6
 * This file is part of the TYPO3 CMS extension "warming".
7
 *
8
 * Copyright (C) 2021-2025 Elias Häußler <elias@haeussler.dev>
9
 *
10
 * This program is free software: you can redistribute it and/or modify
11
 * it under the terms of the GNU General Public License as published by
12
 * the Free Software Foundation, either version 2 of the License, or
13
 * (at your option) any later version.
14
 *
15
 * This program is distributed in the hope that it will be useful,
16
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
17
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
18
 * GNU General Public License for more details.
19
 *
20
 * You should have received a copy of the GNU General Public License
21
 * along with this program. If not, see <https://www.gnu.org/licenses/>.
22
 */
23

24
namespace EliasHaeussler\Typo3Warming\Http\Message;
25

26
use CuyZ\Valinor;
27
use Psr\Http\Message;
28
use Symfony\Component\DependencyInjection;
29
use TYPO3\CMS\Core;
30
use TYPO3\CMS\Extbase;
31

32
/**
33
 * UrlMetadataFactory
34
 *
35
 * @author Elias Häußler <elias@haeussler.dev>
36
 * @license GPL-2.0-or-later
37
 */
38
#[DependencyInjection\Attribute\Autoconfigure(public: true)]
39
final readonly class UrlMetadataFactory
40
{
41
    private const REQUEST_HEADER = 'X-Warming-Request-Claim';
42
    private const RESPONSE_HEADER = 'X-Warming-Url-Metadata';
43

44
    private Valinor\Mapper\TreeMapper $mapper;
45

46
    public function __construct()
20✔
47
    {
48
        $this->mapper = $this->createMapper();
20✔
49
    }
50

51
    /**
52
     * Create url metadata object from authorized request.
53
     */
54
    public function createForRequest(Message\RequestInterface $request): ?UrlMetadata
4✔
55
    {
56
        // Early return if request header is missing (this usually happens on "normal"
57
        // frontend requests, which were not triggered by EXT:warming)
58
        if (!$request->hasHeader(self::REQUEST_HEADER)) {
4✔
59
            return null;
1✔
60
        }
61

62
        $requestUrl = $this->decryptHeaderValue($request, self::REQUEST_HEADER);
3✔
63

64
        // Early return if request is invalid (encrypted header value does not match request url)
65
        if ($requestUrl !== (string)$request->getUri()) {
3✔
66
            return null;
1✔
67
        }
68

69
        return new UrlMetadata();
2✔
70
    }
71

72
    /**
73
     * Decrypt and hydrate url metadata object from given response.
74
     */
75
    public function createFromResponse(Message\ResponseInterface $response): ?UrlMetadata
16✔
76
    {
77
        if (!$response->hasHeader(self::RESPONSE_HEADER)) {
16✔
78
            return null;
2✔
79
        }
80

81
        $headerValue = $this->decryptHeaderValue($response, self::RESPONSE_HEADER);
14✔
82

83
        if ($headerValue === null) {
14✔
84
            return null;
3✔
85
        }
86

87
        try {
88
            $source = new Valinor\Mapper\Source\JsonSource($headerValue);
11✔
89

90
            return $this->mapper->map(UrlMetadata::class, $source);
11✔
91
        } catch (Valinor\Mapper\MappingError|Valinor\Mapper\Source\Exception\InvalidSource) {
2✔
92
            return null;
2✔
93
        }
94
    }
95

96
    /**
97
     * Decrypt and hydrate url metadata object from given response headers.
98
     *
99
     * @param array<string> $headers
100
     */
101
    public function createFromResponseHeaders(array $headers): ?UrlMetadata
5✔
102
    {
103
        $response = new Core\Http\Response(headers: $this->parseHeaderLines($headers));
5✔
104

105
        return $this->createFromResponse($response);
5✔
106
    }
107

108
    /**
109
     * Enrich (prepare) given request for further url metadata enrichment.
110
     *
111
     * @template T of Message\RequestInterface
112
     * @param T $request
113
     * @return T
114
     */
115
    public function enrichRequest(Message\RequestInterface $request): Message\RequestInterface
6✔
116
    {
117
        return $request->withHeader(
6✔
118
            self::REQUEST_HEADER,
6✔
119
            $this->encryptHeaderValue((string)$request->getUri()),
6✔
120
        );
6✔
121
    }
122

123
    /**
124
     * Enrich given response with decrypted url metadata.
125
     *
126
     * @template T of Message\ResponseInterface
127
     * @param T $response
128
     * @return T
129
     */
130
    public function enrichResponse(
4✔
131
        Message\ResponseInterface $response,
132
        UrlMetadata $metadata,
133
    ): Message\ResponseInterface {
134
        return $response->withHeader(self::RESPONSE_HEADER, $this->encryptHeaderValue($metadata));
4✔
135
    }
136

137
    /**
138
     * Enrich given response with decrypted url metadata.
139
     */
140
    public function enrichException(
2✔
141
        Core\Http\ImmediateResponseException|Core\Error\Http\StatusException $exception,
142
        UrlMetadata $metadata,
143
    ): void {
144
        if ($exception instanceof Core\Http\ImmediateResponseException) {
2✔
145
            $this->injectViaReflection(
1✔
146
                $exception,
1✔
147
                $this->enrichResponse($exception->getResponse(), $metadata),
1✔
148
                'response',
1✔
149
            );
1✔
150
        } else {
151
            $statusHeaders = $exception->getStatusHeaders();
1✔
152
            $statusHeaders[] = sprintf('%s: %s', self::RESPONSE_HEADER, $this->encryptHeaderValue($metadata));
1✔
153

154
            $this->injectViaReflection($exception, $statusHeaders, 'statusHeaders');
1✔
155
        }
156
    }
157

158
    private function encryptHeaderValue(string|\JsonSerializable $value): string
11✔
159
    {
160
        if ($value instanceof \JsonSerializable) {
11✔
161
            $value = (string)json_encode($value);
5✔
162
        }
163

164
        if (class_exists(Core\Crypto\HashService::class)) {
11✔
165
            $hashValue = Core\Utility\GeneralUtility::makeInstance(Core\Crypto\HashService::class)->appendHmac(
11✔
166
                $value,
11✔
167
                self::class,
11✔
168
            );
11✔
169
        } else {
170
            // @todo Remove once support for TYPO3 v12 is dropped
171
            /* @phpstan-ignore classConstant.deprecatedClass, method.deprecatedClass */
NEW
172
            $hashValue = Core\Utility\GeneralUtility::makeInstance(Extbase\Security\Cryptography\HashService::class)->appendHmac(
×
NEW
173
                $value,
×
NEW
174
            );
×
175
        }
176

177
        return base64_encode($hashValue);
11✔
178
    }
179

180
    private function decryptHeaderValue(Message\MessageInterface $message, string $headerName): ?string
17✔
181
    {
182
        $value = base64_decode($message->getHeader($headerName)[0] ?? '', true);
17✔
183

184
        if ($value === false || $value === '') {
17✔
185
            return null;
1✔
186
        }
187

188
        try {
189
            if (class_exists(Core\Crypto\HashService::class)) {
16✔
190
                return Core\Utility\GeneralUtility::makeInstance(Core\Crypto\HashService::class)
16✔
191
                    ->validateAndStripHmac($value, self::class)
16✔
192
                ;
16✔
193
            }
194

195
            // @todo Remove once support for TYPO3 v12 is dropped
196
            /* @phpstan-ignore classConstant.deprecatedClass, method.deprecatedClass */
NEW
197
            return Core\Utility\GeneralUtility::makeInstance(Extbase\Security\Cryptography\HashService::class)
×
NEW
198
                ->validateAndStripHmac($value)
×
NEW
199
            ;
×
200
        } catch (Core\Exception) {
3✔
201
            return null;
3✔
202
        }
203
    }
204

205
    /**
206
     * @param array<string> $statusHeaders
207
     * @return array<string, list<string>>
208
     */
209
    private function parseHeaderLines(array $statusHeaders): array
5✔
210
    {
211
        $headers = [];
5✔
212

213
        foreach ($statusHeaders as $headerLine) {
5✔
214
            if (str_contains($headerLine, ':')) {
4✔
215
                [$headerName, $headerValue] = explode(':', $headerLine, 2);
4✔
216
                $headers[$headerName] ??= [];
4✔
217
                $headers[$headerName][] = trim($headerValue);
4✔
218
            }
219
        }
220

221
        return $headers;
5✔
222
    }
223

224
    private function injectViaReflection(object $object, mixed $value, string $propertyName): void
2✔
225
    {
226
        $reflectionObject = new \ReflectionObject($object);
2✔
227
        $reflectionProperty = $reflectionObject->getProperty($propertyName);
2✔
228
        $reflectionProperty->setValue($object, $value);
2✔
229
    }
230

231
    /**
232
     * @return Valinor\Mapper\TreeMapper
233
     */
234
    private function createMapper(): Valinor\Mapper\TreeMapper
20✔
235
    {
236
        return (new Valinor\MapperBuilder())
20✔
237
            ->mapper()
20✔
238
        ;
20✔
239
    }
240
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc