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

TYPO3-Headless / headless / 22961647766

11 Mar 2026 03:55PM UTC coverage: 73.227% (-0.08%) from 73.307%
22961647766

Pull #871

github

web-flow
Merge b58824cd1 into a27ff50cb
Pull Request #871: [BUGFIX] Improve language based urls

25 of 34 new or added lines in 1 file covered. (73.53%)

1146 of 1565 relevant lines covered (73.23%)

8.38 hits per line

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

90.55
/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

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

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

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

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

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

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

92
        if (!$this->headlessMode->isEnabled() || $this->alreadyFrontendLink($url)) {
37✔
93
            return $url;
32✔
94
        }
95

96
        try {
97
            $frontendBaseUrl = $this->resolveWithVariants(
8✔
98
                $this->conf[$returnField] ?? '',
8✔
99
                $this->variants,
8✔
100
                $returnField
8✔
101
            );
8✔
102

103
            if ($frontendBaseUrl === '') {
8✔
104
                return $url;
2✔
105
            }
106

107
            $frontendBase = GeneralUtility::makeInstance(Uri::class, $this->sanitizeBaseUrl($frontendBaseUrl));
6✔
108
            $frontBase = $frontendBase->getHost();
6✔
109
            $frontExtraPath = $frontendBase->getPath();
6✔
110
            $frontPort = $frontendBase->getPort();
6✔
111
            $targetUri = new Uri($this->sanitizeBaseUrl($url));
6✔
112
            $targetUri = $targetUri->withHost($frontBase);
6✔
113
            if ($targetUri->getScheme() === '') {
6✔
114
                $targetUri = $targetUri->withScheme($frontendBase->getScheme());
1✔
115
            }
116

117
            if ($targetUri->getFragment() !== '') {
6✔
118
                $targetUri = $targetUri->withHost('');
1✔
119
                $targetUri = $targetUri->withScheme('');
1✔
120
            }
121

122
            if ($frontExtraPath) {
6✔
123
                $targetUri = $targetUri->withPath($this->handleFrontendAndBackendPaths($frontExtraPath, $targetUri, $site->getBase()->getPath()));
2✔
124
            }
125

126
            if ($site->getBase()->getPort() === $frontPort) {
6✔
127
                return (string)$targetUri;
4✔
128
            }
129

130
            if ($frontPort) {
2✔
131
                $targetUri = $targetUri->withPort($frontPort);
2✔
132
            }
133

134
            return (string)$targetUri;
2✔
135
        } catch (SiteNotFoundException $e) {
×
136
            $this->logError($e->getMessage());
×
137
        }
138

139
        return $url;
×
140
    }
141

142
    public function getFrontendUrlForPage(string $url, int $pageUid, string $returnField = 'frontendBase'): string
143
    {
144
        try {
145
            return $this->getFrontendUrlWithSite(
33✔
146
                $url,
33✔
147
                $this->siteFinder->getSiteByPageId($pageUid),
33✔
148
                $returnField
33✔
149
            );
33✔
150
        } catch (SiteNotFoundException $e) {
1✔
151
            $this->logError($e->getMessage());
1✔
152
        }
153

154
        return $url;
1✔
155
    }
156

157
    public function getFrontendUrl(): string
158
    {
159
        return $this->resolveWithVariants($this->conf['frontendBase'] ?? '', $this->variants);
8✔
160
    }
161

162
    public function getProxyUrl(): string
163
    {
164
        return $this->resolveWithVariants($this->conf['frontendApiProxy'] ?? '', $this->variants, 'frontendApiProxy');
5✔
165
    }
166

167
    public function getStorageProxyUrl(): string
168
    {
169
        return $this->resolveWithVariants($this->conf['frontendFileApi'] ?? '', $this->variants, 'frontendFileApi');
4✔
170
    }
171

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

177
    public function prepareRelativeUrlIfPossible(string $targetUrl): string
178
    {
179
        $parsedTargetUrl = new Uri($this->sanitizeBaseUrl($targetUrl));
5✔
180
        $parsedProjectFrontendUrl = new Uri($this->sanitizeBaseUrl($this->getFrontendUrl()));
5✔
181

182
        if ($parsedTargetUrl->getHost() === $parsedProjectFrontendUrl->getHost()) {
5✔
183
            return '/' . ltrim($parsedTargetUrl->getPath() . ($parsedTargetUrl->getQuery() ? '?' . $parsedTargetUrl->getQuery() : ''), '/');
4✔
184
        }
185

186
        return $targetUrl;
3✔
187
    }
188

189
    /**
190
     * @codeCoverageIgnore
191
     */
192
    protected function logError(string $message): void
193
    {
194
        if ($this->logger) {
195
            $this->logger->error($message);
196
        }
197
    }
198

199
    /**
200
     * If a site base contains "/" or "www.domain.com", it is ensured that
201
     * parse_url() can handle this kind of configuration properly.
202
     */
203
    private function sanitizeBaseUrl(string $base): string
204
    {
205
        if (str_starts_with($base, '#')) {
42✔
206
            return $base;
1✔
207
        }
208

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

225
    private function resolveWithVariants(
226
        string $frontendUrl,
227
        array $variants = [],
228
        string $returnField = 'frontendBase'
229
    ): string {
230
        $frontendUrl = rtrim($frontendUrl, '/');
16✔
231
        if ($variants === []) {
16✔
232
            return $frontendUrl;
4✔
233
        }
234

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

249
    private function handleLanguageConfiguration(SiteLanguage $language, HeadlessFrontendUrlInterface $object): HeadlessFrontendUrlInterface
250
    {
251
        $langConf = $language->toArray();
28✔
252
        $variants = $langConf['baseVariants'] ?? [];
28✔
253
        $frontendBase = trim($langConf['frontendBase'] ?? '');
28✔
254
        $frontendApiProxy = trim($langConf['frontendApiProxy'] ?? '');
28✔
255
        $frontendFileApi = trim($langConf['frontendFileApi'] ?? '');
28✔
256
        $overrides = [];
28✔
257

258
        if ($frontendBase !== '') {
28✔
259
            $overrides['frontendBase'] =  $frontendBase;
1✔
260
            $this->frontendDomains[] = (new Uri($this->sanitizeBaseUrl($frontendBase)))->getHost();
1✔
261
        }
262

263
        if ($frontendApiProxy !== '') {
28✔
264
            $overrides['frontendApiProxy'] =  $frontendApiProxy;
1✔
265
        }
266

267
        if ($frontendFileApi !== '') {
28✔
268
            $overrides['frontendFileApi'] =  $frontendFileApi;
1✔
269
        }
270

271
        $object->conf = array_merge($object->conf, $overrides);
28✔
272

273
        if ($variants !== []) {
28✔
274
            $object->variants = $variants;
2✔
275
        }
276

277
        return $object;
28✔
278
    }
279

280
    private function handleSiteConfiguration(Site $site, UrlUtility $object): self
281
    {
282
        $object->conf = $site->getConfiguration();
43✔
283
        $object->variants = $object->conf['baseVariants'] ?? [];
43✔
284
        $this->frontendDomains = [];
43✔
285

286
        $base = trim($object->conf['frontendBase'] ?? '');
43✔
287
        if ($base !== '') {
43✔
288
            $this->frontendDomains[] = (new Uri($this->sanitizeBaseUrl($base)))->getHost();
2✔
289
        }
290

291
        return $object;
43✔
292
    }
293

294
    private function extractConfigurationFromRequest(ServerRequestInterface $request, HeadlessFrontendUrlInterface $object): HeadlessFrontendUrlInterface
295
    {
296
        $site = $request->getAttribute('site');
32✔
297

298
        if ($site instanceof Site) {
32✔
299
            $object->handleSiteConfiguration($site, $object);
29✔
300
        }
301

302
        $language = $request->getAttribute('language');
32✔
303
        if ($language instanceof SiteLanguage) {
32✔
304
            $object->handleLanguageConfiguration($language, $object);
27✔
305
        }
306

307
        $object->headlessMode = $object->headlessMode->withRequest($request);
32✔
308

309
        return $object;
32✔
310
    }
311

312
    private function handleFrontendAndBackendPaths(string $frontendPath, UriInterface $targetUri, string $baseBackendPath = ''): string
313
    {
314
        return rtrim($frontendPath, '/') . ($targetUri->getPath() !== '' ? '/' . ltrim(substr($targetUri->getPath(), strlen($baseBackendPath)), '/') : '');
2✔
315
    }
316

317
    private function overrideByLanguageIfNecessary(SiteInterface $site, string $backendUrl): ?SiteLanguage
318
    {
319
        $backendUri = GeneralUtility::makeInstance(Uri::class, $this->sanitizeBaseUrl($backendUrl));
37✔
320

321
        foreach ($site->getLanguages() as $language) {
37✔
322
            $conf = $language->toArray();
28✔
323

324
            if (!array_key_exists('frontendBase', $conf)) {
28✔
325
                continue;
28✔
326
            }
327

NEW
328
            $base = trim($conf['frontendBase'] ?? '');
×
329

NEW
330
            if ($base === '') {
×
NEW
331
                continue;
×
332
            }
333

NEW
334
            $this->frontendDomains[] = (new Uri($this->sanitizeBaseUrl($base)))->getHost();
×
335

NEW
336
            if ($language->getBase()->getHost() === $backendUri->getHost()) {
×
NEW
337
                return $language;
×
338
            }
339

NEW
340
            if ($backendUri->getPath() !== '/' && str_starts_with($backendUri->getPath(), $language->getBase()->getPath())) {
×
NEW
341
                return $language;
×
342
            }
343
        }
344

345
        $this->frontendDomains = array_unique($this->frontendDomains);
37✔
346

347
        return null;
37✔
348
    }
349

350
    protected function alreadyFrontendLink(string $url): bool
351
    {
352
        $targetUri = new Uri($this->sanitizeBaseUrl($url));
8✔
353

354
        return in_array($targetUri->getHost(), $this->frontendDomains, true);
8✔
355
    }
356
}
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