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

TYPO3-Headless / headless / 25548171879

08 May 2026 09:30AM UTC coverage: 74.7% (+1.7%) from 73.04%
25548171879

Pull #881

github

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

37 of 57 new or added lines in 10 files covered. (64.91%)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

127
        return $url;
×
128
    }
129

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

142
        return $url;
1✔
143
    }
144

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

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

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

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

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

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

174
        return $targetUrl;
3✔
175
    }
176

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

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

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

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

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

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

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

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

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

259
        $object->conf = array_merge($object->conf, $overrides);
3✔
260

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

265
        return $object;
3✔
266
    }
267

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

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

279
        return $object;
19✔
280
    }
281

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

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

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

295
        $object->headlessMode = $object->headlessMode->withRequest($request);
7✔
296

297
        return $object;
7✔
298
    }
299

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

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

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

316
            $base = trim($conf['frontendBase'] ?? '');
×
317

318
            if ($base === '') {
×
319
                continue;
×
320
            }
321

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

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

331
        $object->frontendDomains = array_unique($object->frontendDomains);
13✔
332

333
        return $matchedLanguage;
13✔
334
    }
335

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

340
        return in_array($targetUri->getHost(), $this->frontendDomains, true);
9✔
341
    }
342
}
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