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

TYPO3-Headless / headless / 26440568390

26 May 2026 08:13AM UTC coverage: 72.91%. First build
26440568390

Pull #892

github

web-flow
Merge da0000d78 into fdf45e8bf
Pull Request #892: [BUGFIX] Do not modify external links, fixes for cropping, small optimization backported from 5.x branch

48 of 60 new or added lines in 11 files covered. (80.0%)

1160 of 1591 relevant lines covered (72.91%)

8.44 hits per line

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

88.03
/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 Features $features;
44
    private Resolver $resolver;
45
    private SiteFinder $siteFinder;
46
    private array $conf = [];
47
    private array $variants = [];
48
    private HeadlessModeInterface $headlessMode;
49
    private array $frontendDomains = [];
50
    private array $backendDomains = [];
51

52
    public function __construct(
53
        ?Features $features = null,
54
        ?Resolver $resolver = null,
55
        ?SiteFinder $siteFinder = null,
56
        ?ServerRequestInterface $serverRequest = null,
57
        ?HeadlessModeInterface $headlessMode = null
58
    ) {
59
        $this->features = $features ?? GeneralUtility::makeInstance(Features::class);
48✔
60
        $this->resolver = $resolver ?? GeneralUtility::makeInstance(Resolver::class, 'site', []);
48✔
61
        $this->siteFinder = $siteFinder ?? GeneralUtility::makeInstance(SiteFinder::class);
48✔
62
        $this->headlessMode = $headlessMode ?? GeneralUtility::makeInstance(HeadlessModeInterface::class);
48✔
63
        $request = $serverRequest ?? ($GLOBALS['TYPO3_REQUEST'] ?? null);
48✔
64

65
        if ($request instanceof ServerRequestInterface) {
48✔
66
            $this->extractConfigurationFromRequest($request, $this);
27✔
67
        }
68
    }
69

70
    public function withSite(Site $site): HeadlessFrontendUrlInterface
71
    {
72
        return $this->handleSiteConfiguration($site, clone $this);
13✔
73
    }
74

75
    public function withRequest(ServerRequestInterface $request): HeadlessFrontendUrlInterface
76
    {
77
        return $this->extractConfigurationFromRequest($request, clone $this);
31✔
78
    }
79

80
    public function withLanguage(SiteLanguage $language): HeadlessFrontendUrlInterface
81
    {
82
        return $this->handleLanguageConfiguration($language, clone $this);
1✔
83
    }
84

85
    public function getFrontendUrlWithSite($url, SiteInterface $site, string $returnField = 'frontendBase'): string
86
    {
87
        $this->handleSiteConfiguration($site, $this);
38✔
88
        $siteLanguage = $this->overrideByLanguageIfNecessary($site, $url);
38✔
89
        if ($siteLanguage !== null) {
38✔
90
            $this->handleLanguageConfiguration($siteLanguage, $this);
×
91
        }
92

93
        $targetUri = new Uri($this->sanitizeBaseUrl($url));
38✔
94

95
        if (!$this->headlessMode->isEnabled() ||
38✔
96
            $targetUri->getHost() === '' ||
9✔
97
            $this->isExternalUrl($targetUri->getHost()) ||
9✔
98
            $this->alreadyFrontendLink($targetUri->getHost())) {
38✔
99
            return $url;
35✔
100
        }
101

102
        try {
103
            $frontendBaseUrl = $this->resolveWithVariants(
7✔
104
                $this->conf[$returnField] ?? '',
7✔
105
                $this->variants,
7✔
106
                $returnField
7✔
107
            );
7✔
108

109
            if ($frontendBaseUrl === '') {
7✔
110
                return $url;
2✔
111
            }
112

113
            $frontendBase = GeneralUtility::makeInstance(Uri::class, $this->sanitizeBaseUrl($frontendBaseUrl));
5✔
114
            $frontBase = $frontendBase->getHost();
5✔
115
            $frontExtraPath = $frontendBase->getPath();
5✔
116
            $frontPort = $frontendBase->getPort();
5✔
117

118
            $targetUri = $targetUri->withHost($frontBase);
5✔
119
            if ($targetUri->getScheme() === '') {
5✔
120
                $targetUri = $targetUri->withScheme($frontendBase->getScheme());
×
121
            }
122

123
            if ($targetUri->getFragment() !== '') {
5✔
124
                $targetUri = $targetUri->withHost('');
×
125
                $targetUri = $targetUri->withScheme('');
×
126
            }
127

128
            if ($frontExtraPath) {
5✔
129
                $targetUri = $targetUri->withPath($this->handleFrontendAndBackendPaths($frontExtraPath, $targetUri, $site->getBase()->getPath()));
2✔
130
            }
131

132
            if ($site->getBase()->getPort() === $frontPort) {
5✔
133
                return (string)$targetUri;
3✔
134
            }
135

136
            if ($frontPort) {
2✔
137
                $targetUri = $targetUri->withPort($frontPort);
2✔
138
            }
139

140
            return (string)$targetUri;
2✔
141
        } catch (SiteNotFoundException $e) {
×
142
            $this->logError($e->getMessage());
×
143
        }
144

145
        return $url;
×
146
    }
147

148
    public function getFrontendUrlForPage(string $url, int $pageUid, string $returnField = 'frontendBase'): string
149
    {
150
        try {
151
            return $this->getFrontendUrlWithSite(
33✔
152
                $url,
33✔
153
                $this->siteFinder->getSiteByPageId($pageUid),
33✔
154
                $returnField
33✔
155
            );
33✔
156
        } catch (SiteNotFoundException $e) {
1✔
157
            $this->logError($e->getMessage());
1✔
158
        }
159

160
        return $url;
1✔
161
    }
162

163
    public function getFrontendUrl(): string
164
    {
165
        return $this->resolveWithVariants($this->conf['frontendBase'] ?? '', $this->variants);
8✔
166
    }
167

168
    public function getProxyUrl(): string
169
    {
170
        return $this->resolveWithVariants($this->conf['frontendApiProxy'] ?? '', $this->variants, 'frontendApiProxy');
5✔
171
    }
172

173
    public function getStorageProxyUrl(): string
174
    {
175
        return $this->resolveWithVariants($this->conf['frontendFileApi'] ?? '', $this->variants, 'frontendFileApi');
4✔
176
    }
177

178
    public function resolveKey(string $key): string
179
    {
180
        return $this->resolveWithVariants($this->conf[$key] ?? '', $this->variants, $key);
4✔
181
    }
182

183
    public function prepareRelativeUrlIfPossible(string $targetUrl): string
184
    {
185
        $parsedTargetUrl = new Uri($this->sanitizeBaseUrl($targetUrl));
5✔
186
        $parsedProjectFrontendUrl = new Uri($this->sanitizeBaseUrl($this->getFrontendUrl()));
5✔
187

188
        if ($parsedTargetUrl->getHost() === $parsedProjectFrontendUrl->getHost()) {
5✔
189
            return '/' . ltrim($parsedTargetUrl->getPath() . ($parsedTargetUrl->getQuery() ? '?' . $parsedTargetUrl->getQuery() : ''), '/');
4✔
190
        }
191

192
        return $targetUrl;
3✔
193
    }
194

195
    /**
196
     * @codeCoverageIgnore
197
     */
198
    protected function logError(string $message): void
199
    {
200
        if ($this->logger) {
201
            $this->logger->error($message);
202
        }
203
    }
204

205
    /**
206
     * If a site base contains "/" or "www.domain.com", it is ensured that
207
     * parse_url() can handle this kind of configuration properly.
208
     */
209
    private function sanitizeBaseUrl(string $base): string
210
    {
211
        if (str_starts_with($base, '#')) {
44✔
212
            return $base;
1✔
213
        }
214

215
        // no protocol ("//") and the first part is no "/" (path), means that this is a domain like
216
        // "www.domain.com/blabla", and we want to ensure that this one then gets a "no-scheme agnostic" part
217
        if (!empty($base) && !str_contains($base, '//')   && $base[0] !== '/') {
44✔
218
            // either a scheme is added, or no scheme but with domain, or a path which is not absolute
219
            // make the base prefixed with a slash, so it is recognized as path, not as domain
220
            // treat as path
221
            if (!str_contains($base, '.')) {
1✔
222
                $base = '/' . $base;
1✔
223
            } else {
224
                // treat as domain name
225
                $base = '//' . $base;
1✔
226
            }
227
        }
228
        return $base;
44✔
229
    }
230

231
    private function resolveWithVariants(
232
        string $frontendUrl,
233
        array $variants = [],
234
        string $returnField = 'frontendBase'
235
    ): string {
236
        $frontendUrl = rtrim($frontendUrl, '/');
15✔
237
        if ($variants === []) {
15✔
238
            return $frontendUrl;
4✔
239
        }
240

241
        foreach ($variants as $baseVariant) {
12✔
242
            try {
243
                if ($this->resolver->evaluate($baseVariant['condition'])) {
12✔
244
                    return rtrim($baseVariant[$returnField] ?? '', '/');
12✔
245
                }
246
            } catch (SyntaxError $e) {
1✔
247
                $this->logError($e->getMessage());
1✔
248
                // silently fail and do not evaluate
249
                // no logger here, as Site is currently cached and serialized
250
            }
251
        }
252
        return $frontendUrl;
3✔
253
    }
254

255
    private function handleLanguageConfiguration(SiteLanguage $language, HeadlessFrontendUrlInterface $object): HeadlessFrontendUrlInterface
256
    {
257
        $langConf = $language->toArray();
28✔
258
        $variants = $langConf['baseVariants'] ?? [];
28✔
259
        $frontendBase = trim($langConf['frontendBase'] ?? '');
28✔
260
        $frontendApiProxy = trim($langConf['frontendApiProxy'] ?? '');
28✔
261
        $frontendFileApi = trim($langConf['frontendFileApi'] ?? '');
28✔
262
        $overrides = [];
28✔
263

264
        if ($language->getBase()->getHost() !== '') {
28✔
265
            $this->backendDomains[] = $language->getBase()->getHost();
1✔
266
        }
267

268
        if ($frontendBase !== '') {
28✔
269
            $overrides['frontendBase'] =  $frontendBase;
1✔
270
            $this->frontendDomains[] = (new Uri($this->sanitizeBaseUrl($frontendBase)))->getHost();
1✔
271
        }
272

273
        if ($frontendApiProxy !== '') {
28✔
274
            $overrides['frontendApiProxy'] =  $frontendApiProxy;
1✔
275
        }
276

277
        if ($frontendFileApi !== '') {
28✔
278
            $overrides['frontendFileApi'] =  $frontendFileApi;
1✔
279
        }
280

281
        $object->conf = array_merge($object->conf, $overrides);
28✔
282

283
        if ($variants !== []) {
28✔
284
            $object->variants = $variants;
2✔
285
        }
286

287
        return $object;
28✔
288
    }
289

290
    private function handleSiteConfiguration(Site $site, UrlUtility $object): self
291
    {
292
        $object->conf = $site->getConfiguration();
43✔
293
        $object->variants = $object->conf['baseVariants'] ?? [];
43✔
294

295
        $this->frontendDomains = [];
43✔
296
        $this->backendDomains = [];
43✔
297
        $this->backendDomains[] = $site->getBase()->getHost();
43✔
298

299
        foreach ($object->variants as $variant) {
43✔
300
            $variantBase = trim($variant['base'] ?? '');
15✔
301
            if ($variantBase !== '') {
15✔
302
                $object->backendDomains[] = (new Uri($this->sanitizeBaseUrl($variantBase)))->getHost();
15✔
303
            }
304
        }
305

306
        $base = trim($object->conf['frontendBase'] ?? '');
43✔
307
        if ($base !== '') {
43✔
308
            $this->frontendDomains[] = (new Uri($this->sanitizeBaseUrl($base)))->getHost();
2✔
309
        }
310

311
        return $object;
43✔
312
    }
313

314
    private function extractConfigurationFromRequest(ServerRequestInterface $request, HeadlessFrontendUrlInterface $object): HeadlessFrontendUrlInterface
315
    {
316
        $site = $request->getAttribute('site');
32✔
317

318
        if ($site instanceof Site) {
32✔
319
            $object->handleSiteConfiguration($site, $object);
29✔
320
        }
321

322
        $language = $request->getAttribute('language');
32✔
323
        if ($language instanceof SiteLanguage) {
32✔
324
            $object->handleLanguageConfiguration($language, $object);
27✔
325
        }
326

327
        $object->headlessMode = $object->headlessMode->withRequest($request);
32✔
328

329
        return $object;
32✔
330
    }
331

332
    private function handleFrontendAndBackendPaths(string $frontendPath, UriInterface $targetUri, string $baseBackendPath = ''): string
333
    {
334
        return rtrim($frontendPath, '/') . ($targetUri->getPath() !== '' ? '/' . ltrim(substr($targetUri->getPath(), strlen($baseBackendPath)), '/') : '');
2✔
335
    }
336

337
    private function overrideByLanguageIfNecessary(SiteInterface $site, string $backendUrl): ?SiteLanguage
338
    {
339
        $backendUri = GeneralUtility::makeInstance(Uri::class, $this->sanitizeBaseUrl($backendUrl));
38✔
340
        $matchedLanguage = null;
38✔
341
        foreach ($site->getLanguages() as $language) {
38✔
342
            $conf = $language->toArray();
28✔
343

344
            if (!array_key_exists('frontendBase', $conf)) {
28✔
345
                continue;
28✔
346
            }
347

348
            $base = trim($conf['frontendBase'] ?? '');
×
349

350
            if ($base === '') {
×
351
                continue;
×
352
            }
353

NEW
354
            if ($language->getBase()->getHost() !== '') {
×
NEW
355
                $this->backendDomains[] = $language->getBase()->getHost();
×
356
            }
357
            $this->frontendDomains[] = (new Uri($this->sanitizeBaseUrl($base)))->getHost();
×
358

359
            if ($language->getBase()->getHost() === $backendUri->getHost()) {
×
360
                $matchedLanguage = $language;
×
361
            } elseif ($backendUri->getPath() !== '/' && str_starts_with($backendUri->getPath(), $language->getBase()->getPath())) {
×
362
                $matchedLanguage = $language;
×
363
            }
364
        }
365

366
        $this->backendDomains = array_unique($this->backendDomains);
38✔
367
        $this->frontendDomains = array_unique($this->frontendDomains);
38✔
368

369
        return $matchedLanguage;
38✔
370
    }
371

372
    protected function alreadyFrontendLink(string $url): bool
373
    {
374
        return in_array($url, $this->frontendDomains, true);
7✔
375
    }
376

377
    protected function isExternalUrl(string $url): bool
378
    {
379
        return !in_array($url, array_merge($this->backendDomains, $this->frontendDomains), true);
9✔
380
    }
381
}
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