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

TYPO3-Headless / headless / 25552700505

08 May 2026 11:18AM UTC coverage: 74.7% (+1.7%) from 73.04%
25552700505

Pull #881

github

web-flow
Merge cf3b72b38 into c36c6b6bf
Pull Request #881: [WIP] TYPO3 v14 support - ongoing work - not stable

38 of 58 new or added lines in 10 files covered. (65.52%)

23 existing lines in 2 files now uncovered.

1181 of 1581 relevant lines covered (74.7%)

6.63 hits per line

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

90.24
/Classes/Utility/UrlUtility.php
1
<?php
2

3
/*
4
 * This file is part of the "headless" Extension for TYPO3 CMS.
5
 *
6
 * For the full copyright and license information, please read the
7
 * LICENSE.md file that was distributed with this source code.
8
 */
9

10
declare(strict_types=1);
11

12
namespace FriendsOfTYPO3\Headless\Utility;
13

14
use Psr\Http\Message\ServerRequestInterface;
15
use Psr\Http\Message\UriInterface;
16
use Psr\Log\LoggerAwareInterface;
17
use Psr\Log\LoggerAwareTrait;
18
use Symfony\Component\ExpressionLanguage\SyntaxError;
19
use TYPO3\CMS\Core\Configuration\Features;
20
use TYPO3\CMS\Core\Exception\SiteNotFoundException;
21
use TYPO3\CMS\Core\ExpressionLanguage\Resolver;
22
use TYPO3\CMS\Core\Http\Uri;
23
use TYPO3\CMS\Core\Site\Entity\Site;
24
use TYPO3\CMS\Core\Site\Entity\SiteInterface;
25
use TYPO3\CMS\Core\Site\Entity\SiteLanguage;
26
use TYPO3\CMS\Core\Site\SiteFinder;
27
use TYPO3\CMS\Core\Utility\GeneralUtility;
28

29
use function array_key_exists;
30
use function array_merge;
31
use function array_unique;
32
use function ltrim;
33
use function rtrim;
34
use function str_contains;
35
use function str_starts_with;
36
use function strlen;
37
use function substr;
38

39
class UrlUtility implements LoggerAwareInterface, HeadlessFrontendUrlInterface
40
{
41
    use LoggerAwareTrait;
42

43
    private array $conf = [];
44
    private array $variants = [];
45
    private array $frontendDomains = [];
46

47
    public function __construct(
48
        private readonly Features $features,
49
        private readonly Resolver $resolver,
50
        private readonly SiteFinder $siteFinder,
51
        private HeadlessModeInterface $headlessMode,
52
    ) {}
49✔
53

54
    public function withSite(Site $site): HeadlessFrontendUrlInterface
55
    {
56
        return $this->handleSiteConfiguration($site, clone $this);
12✔
57
    }
58

59
    public function withRequest(ServerRequestInterface $request): HeadlessFrontendUrlInterface
60
    {
61
        return $this->extractConfigurationFromRequest($request, clone $this);
7✔
62
    }
63

64
    public function withLanguage(SiteLanguage $language): HeadlessFrontendUrlInterface
65
    {
66
        return $this->handleLanguageConfiguration($language, clone $this);
1✔
67
    }
68

69
    public function getFrontendUrlWithSite($url, SiteInterface $site, string $returnField = 'frontendBase'): string
70
    {
71
        $clone = clone $this;
13✔
72
        $clone->handleSiteConfiguration($site, $clone);
13✔
73
        $siteLanguage = $clone->overrideByLanguageIfNecessary($clone, $site, $url);
13✔
74
        if ($siteLanguage !== null) {
13✔
NEW
75
            $clone->handleLanguageConfiguration($siteLanguage, $clone);
×
76
        }
77

78
        if (!$clone->headlessMode->isEnabled() || $clone->alreadyFrontendLink($url)) {
13✔
79
            return $url;
7✔
80
        }
81

82
        try {
83
            $frontendBaseUrl = $clone->resolveWithVariants(
9✔
84
                $clone->conf[$returnField] ?? '',
9✔
85
                $clone->variants,
9✔
86
                $returnField
9✔
87
            );
9✔
88

89
            if ($frontendBaseUrl === '') {
9✔
90
                return $url;
2✔
91
            }
92

93
            $frontendBase = GeneralUtility::makeInstance(Uri::class, $clone->sanitizeBaseUrl($frontendBaseUrl));
7✔
94
            $frontBase = $frontendBase->getHost();
7✔
95
            $frontExtraPath = $frontendBase->getPath();
7✔
96
            $frontPort = $frontendBase->getPort();
7✔
97
            $targetUri = new Uri($clone->sanitizeBaseUrl($url));
7✔
98
            $targetUri = $targetUri->withHost($frontBase);
7✔
99
            if ($targetUri->getScheme() === '') {
7✔
100
                $targetUri = $targetUri->withScheme($frontendBase->getScheme());
1✔
101
            }
102

103
            if ($targetUri->getFragment() !== '') {
7✔
104
                $targetUri = $targetUri->withHost('');
1✔
105
                $targetUri = $targetUri->withScheme('');
1✔
106
            }
107

108
            if ($frontExtraPath) {
7✔
109
                $targetUri = $targetUri->withPath($clone->handleFrontendAndBackendPaths($frontExtraPath, $targetUri, $site->getBase()->getPath()));
2✔
110
            }
111

112
            if ($site->getBase()->getPort() === $frontPort) {
7✔
113
                return (string)$targetUri;
5✔
114
            }
115

116
            if ($frontPort) {
2✔
117
                $targetUri = $targetUri->withPort($frontPort);
2✔
118
            }
119

120
            return (string)$targetUri;
2✔
121
        } catch (SiteNotFoundException $e) {
×
122
            $this->logError($e->getMessage());
×
123
        }
124

125
        return $url;
×
126
    }
127

128
    public function getFrontendUrlForPage(string $url, int $pageUid, string $returnField = 'frontendBase'): string
129
    {
130
        try {
131
            return $this->getFrontendUrlWithSite(
8✔
132
                $url,
8✔
133
                $this->siteFinder->getSiteByPageId($pageUid),
8✔
134
                $returnField
8✔
135
            );
8✔
136
        } catch (SiteNotFoundException $e) {
1✔
137
            $this->logError($e->getMessage());
1✔
138
        }
139

140
        return $url;
1✔
141
    }
142

143
    public function getFrontendUrl(): string
144
    {
145
        return $this->resolveWithVariants($this->conf['frontendBase'] ?? '', $this->variants);
8✔
146
    }
147

148
    public function getProxyUrl(): string
149
    {
150
        return $this->resolveWithVariants($this->conf['frontendApiProxy'] ?? '', $this->variants, 'frontendApiProxy');
5✔
151
    }
152

153
    public function getStorageProxyUrl(): string
154
    {
155
        return $this->resolveWithVariants($this->conf['frontendFileApi'] ?? '', $this->variants, 'frontendFileApi');
4✔
156
    }
157

158
    public function resolveKey(string $key): string
159
    {
160
        return $this->resolveWithVariants($this->conf[$key] ?? '', $this->variants, $key);
4✔
161
    }
162

163
    public function prepareRelativeUrlIfPossible(string $targetUrl): string
164
    {
165
        $parsedTargetUrl = new Uri($this->sanitizeBaseUrl($targetUrl));
5✔
166
        $parsedProjectFrontendUrl = new Uri($this->sanitizeBaseUrl($this->getFrontendUrl()));
5✔
167

168
        if ($parsedTargetUrl->getHost() === $parsedProjectFrontendUrl->getHost()) {
5✔
169
            return '/' . ltrim($parsedTargetUrl->getPath() . ($parsedTargetUrl->getQuery() ? '?' . $parsedTargetUrl->getQuery() : ''), '/');
4✔
170
        }
171

172
        return $targetUrl;
3✔
173
    }
174

175
    /**
176
     * @codeCoverageIgnore
177
     */
178
    protected function logError(string $message): void
179
    {
180
        if ($this->logger) {
181
            $this->logger->error($message);
182
        }
183
    }
184

185
    /**
186
     * If a site base contains "/" or "www.domain.com", it is ensured that
187
     * parse_url() can handle this kind of configuration properly.
188
     */
189
    private function sanitizeBaseUrl(string $base): string
190
    {
191
        if (str_starts_with($base, '#')) {
18✔
192
            return $base;
1✔
193
        }
194

195
        // no protocol ("//") and the first part is no "/" (path), means that this is a domain like
196
        // "www.domain.com/blabla", and we want to ensure that this one then gets a "no-scheme agnostic" part
197
        if (!empty($base) && !str_contains($base, '//')   && $base[0] !== '/') {
18✔
198
            // either a scheme is added, or no scheme but with domain, or a path which is not absolute
199
            // make the base prefixed with a slash, so it is recognized as path, not as domain
200
            // treat as path
201
            if (!str_contains($base, '.')) {
1✔
202
                $base = '/' . $base;
1✔
203
            } else {
204
                // treat as domain name
205
                $base = '//' . $base;
1✔
206
            }
207
        }
208
        return $base;
18✔
209
    }
210

211
    private function resolveWithVariants(
212
        string $frontendUrl,
213
        array $variants = [],
214
        string $returnField = 'frontendBase'
215
    ): string {
216
        $frontendUrl = rtrim($frontendUrl, '/');
17✔
217
        if ($variants === []) {
17✔
218
            return $frontendUrl;
5✔
219
        }
220

221
        foreach ($variants as $baseVariant) {
13✔
222
            try {
223
                if ($this->resolver->evaluate($baseVariant['condition'])) {
13✔
224
                    return rtrim($baseVariant[$returnField] ?? '', '/');
13✔
225
                }
226
            } catch (SyntaxError $e) {
1✔
227
                $this->logError($e->getMessage());
1✔
228
                // silently fail and do not evaluate
229
                // no logger here, as Site is currently cached and serialized
230
            }
231
        }
232
        return $frontendUrl;
3✔
233
    }
234

235
    private function handleLanguageConfiguration(SiteLanguage $language, HeadlessFrontendUrlInterface $object): HeadlessFrontendUrlInterface
236
    {
237
        $langConf = $language->toArray();
3✔
238
        $variants = $langConf['baseVariants'] ?? [];
3✔
239
        $frontendBase = trim($langConf['frontendBase'] ?? '');
3✔
240
        $frontendApiProxy = trim($langConf['frontendApiProxy'] ?? '');
3✔
241
        $frontendFileApi = trim($langConf['frontendFileApi'] ?? '');
3✔
242
        $overrides = [];
3✔
243

244
        if ($frontendBase !== '') {
3✔
245
            $overrides['frontendBase'] =  $frontendBase;
1✔
246
            $object->frontendDomains[] = (new Uri($this->sanitizeBaseUrl($frontendBase)))->getHost();
1✔
247
        }
248

249
        if ($frontendApiProxy !== '') {
3✔
250
            $overrides['frontendApiProxy'] =  $frontendApiProxy;
1✔
251
        }
252

253
        if ($frontendFileApi !== '') {
3✔
254
            $overrides['frontendFileApi'] =  $frontendFileApi;
1✔
255
        }
256

257
        $object->conf = array_merge($object->conf, $overrides);
3✔
258

259
        if ($variants !== []) {
3✔
260
            $object->variants = $variants;
2✔
261
        }
262

263
        return $object;
3✔
264
    }
265

266
    private function handleSiteConfiguration(Site $site, UrlUtility $object): self
267
    {
268
        $object->conf = $site->getConfiguration();
19✔
269
        $object->variants = $object->conf['baseVariants'] ?? [];
19✔
270
        $object->frontendDomains = [];
19✔
271

272
        $base = trim($object->conf['frontendBase'] ?? '');
19✔
273
        if ($base !== '') {
19✔
274
            $object->frontendDomains[] = (new Uri($this->sanitizeBaseUrl($base)))->getHost();
3✔
275
        }
276

277
        return $object;
19✔
278
    }
279

280
    private function extractConfigurationFromRequest(ServerRequestInterface $request, HeadlessFrontendUrlInterface $object): HeadlessFrontendUrlInterface
281
    {
282
        $site = $request->getAttribute('site');
7✔
283

284
        if ($site instanceof Site) {
7✔
285
            $object->handleSiteConfiguration($site, $object);
4✔
286
        }
287

288
        $language = $request->getAttribute('language');
7✔
289
        if ($language instanceof SiteLanguage) {
7✔
290
            $object->handleLanguageConfiguration($language, $object);
2✔
291
        }
292

293
        $object->headlessMode = $object->headlessMode->withRequest($request);
7✔
294

295
        return $object;
7✔
296
    }
297

298
    private function handleFrontendAndBackendPaths(string $frontendPath, UriInterface $targetUri, string $baseBackendPath = ''): string
299
    {
300
        return rtrim($frontendPath, '/') . ($targetUri->getPath() !== '' ? '/' . ltrim(substr($targetUri->getPath(), strlen($baseBackendPath)), '/') : '');
2✔
301
    }
302

303
    private function overrideByLanguageIfNecessary(UrlUtility $object, SiteInterface $site, string $backendUrl): ?SiteLanguage
304
    {
305
        $backendUri = GeneralUtility::makeInstance(Uri::class, $this->sanitizeBaseUrl($backendUrl));
13✔
306
        $matchedLanguage = null;
13✔
307
        foreach ($site->getLanguages() as $language) {
13✔
308
            $conf = $language->toArray();
3✔
309

310
            if (!array_key_exists('frontendBase', $conf)) {
3✔
311
                continue;
3✔
312
            }
313

314
            $base = trim($conf['frontendBase'] ?? '');
×
315

316
            if ($base === '') {
×
317
                continue;
×
318
            }
319

NEW
320
            $object->frontendDomains[] = (new Uri($this->sanitizeBaseUrl($base)))->getHost();
×
321

322
            if ($language->getBase()->getHost() === $backendUri->getHost()) {
×
323
                $matchedLanguage = $language;
×
324
            } elseif ($backendUri->getPath() !== '/' && str_starts_with($backendUri->getPath(), $language->getBase()->getPath())) {
×
325
                $matchedLanguage = $language;
×
326
            }
327
        }
328

329
        $object->frontendDomains = array_unique($object->frontendDomains);
13✔
330

331
        return $matchedLanguage;
13✔
332
    }
333

334
    protected function alreadyFrontendLink(string $url): bool
335
    {
336
        $targetUri = new Uri($this->sanitizeBaseUrl($url));
9✔
337

338
        return in_array($targetUri->getHost(), $this->frontendDomains, true);
9✔
339
    }
340
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc