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

TYPO3-Headless / headless / 29398229494

15 Jul 2026 07:42AM UTC coverage: 69.663% (-5.8%) from 75.459%
29398229494

Pull #893

github

web-flow
Merge ab993c965 into 79b7c5472
Pull Request #893: [TASK] Reintroduce missing features, extension cleanup

360 of 545 new or added lines in 33 files covered. (66.06%)

167 existing lines in 7 files now uncovered.

1364 of 1958 relevant lines covered (69.66%)

7.27 hits per line

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

88.59
/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\Exception\SiteNotFoundException;
20
use TYPO3\CMS\Core\ExpressionLanguage\Resolver;
21
use TYPO3\CMS\Core\Http\Uri;
22
use TYPO3\CMS\Core\Site\Entity\Site;
23
use TYPO3\CMS\Core\Site\Entity\SiteInterface;
24
use TYPO3\CMS\Core\Site\Entity\SiteLanguage;
25
use TYPO3\CMS\Core\Site\SiteFinder;
26
use TYPO3\CMS\Core\Utility\GeneralUtility;
27

28
use function array_key_exists;
29
use function array_merge;
30
use function array_unique;
31
use function in_array;
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 strtolower;
38
use function substr;
39

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

44
    /** @var array<string, mixed> */
45
    private array $conf = [];
46
    /** @var array<int, array<string, mixed>> */
47
    private array $variants = [];
48
    /** @var array<int, string> */
49
    private array $frontendDomains = [];
50
    /** @var array<int, string> */
51
    private array $backendDomains = [];
52

53
    /** @var array<string, bool> */
54
    private array $variantConditionCache = [];
55

56
    public function __construct(
57
        private readonly Resolver $resolver,
58
        private readonly SiteFinder $siteFinder,
59
        private HeadlessModeInterface $headlessMode,
60
    ) {}
49✔
61

62
    public function withSite(Site $site): HeadlessFrontendUrlInterface
63
    {
64
        $clone = clone $this;
16✔
65
        $clone->applySite($site);
16✔
66
        return $clone;
16✔
67
    }
68

69
    public function withRequest(ServerRequestInterface $request): HeadlessFrontendUrlInterface
70
    {
71
        $clone = clone $this;
6✔
72
        $clone->applyRequest($request);
6✔
73
        return $clone;
6✔
74
    }
75

76
    public function withLanguage(SiteLanguage $language): HeadlessFrontendUrlInterface
77
    {
78
        $clone = clone $this;
1✔
79
        $clone->applyLanguage($language);
1✔
80
        return $clone;
1✔
81
    }
82

83
    /**
84
     * @param string|null $url
85
     */
86
    public function getFrontendUrlWithSite($url, SiteInterface $site, string $returnField = 'frontendBase'): string
87
    {
88
        $clone = clone $this;
14✔
89
        $clone->applySite($site);
14✔
90
        $siteLanguage = $clone->collectLanguageDomainsAndMatch($site, $url);
14✔
91
        if ($siteLanguage !== null) {
14✔
NEW
92
            $clone->applyLanguage($siteLanguage);
×
93
        }
94

95
        $targetUri = new Uri($clone->sanitizeBaseUrl($url));
14✔
96

97
        if (!$clone->headlessMode->isEnabled() ||
14✔
98
            $targetUri->getHost() === '' ||
11✔
99
            $clone->isExternalUrl($targetUri->getHost()) ||
11✔
100
            $clone->alreadyFrontendLink($targetUri->getHost())) {
14✔
101
            return $url;
9✔
102
        }
103

104
        try {
105
            $frontendBaseUrl = $clone->resolveWithVariants(
9✔
106
                $clone->conf[$returnField] ?? '',
9✔
107
                $clone->variants,
9✔
108
                $returnField
9✔
109
            );
9✔
110

111
            if ($frontendBaseUrl === '') {
9✔
112
                return $url;
2✔
113
            }
114

115
            $frontendBase = GeneralUtility::makeInstance(Uri::class, $clone->sanitizeBaseUrl($frontendBaseUrl));
7✔
116

117
            $scheme = strtolower($frontendBase->getScheme());
7✔
118
            if ($scheme !== '' && !in_array($scheme, ['http', 'https'], true)) {
7✔
NEW
119
                return $url;
×
120
            }
121

122
            $targetUri = $targetUri->withHost($frontendBase->getHost());
7✔
123

124
            if ($targetUri->getScheme() === '') {
7✔
UNCOV
125
                $targetUri = $targetUri->withScheme($frontendBase->getScheme());
×
126
            }
127

128
            if ($targetUri->getFragment() !== '') {
7✔
UNCOV
129
                $targetUri = $targetUri->withHost('');
×
UNCOV
130
                $targetUri = $targetUri->withScheme('');
×
131
            }
132

133
            $frontExtraPath = $frontendBase->getPath();
7✔
134
            if ($frontExtraPath) {
7✔
135
                $targetUri = $targetUri->withPath(
2✔
136
                    $clone->handleFrontendAndBackendPaths($frontExtraPath, $targetUri, $site->getBase()->getPath())
2✔
137
                );
2✔
138
            }
139

140
            $targetUri = $targetUri->withPort($frontendBase->getPort());
7✔
141

142
            return (string)$targetUri;
7✔
143
        } catch (SiteNotFoundException $e) {
×
144
            $this->logError($e->getMessage());
×
145
        }
146

147
        return $url;
×
148
    }
149

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

162
        return $url;
1✔
163
    }
164

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

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

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

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

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

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

194
        return $targetUrl;
3✔
195
    }
196

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

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

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

233
    /**
234
     * @param array<int, array<string, mixed>> $variants
235
     */
236
    private function resolveWithVariants(
237
        string $frontendUrl,
238
        array $variants = [],
239
        string $returnField = 'frontendBase'
240
    ): string {
241
        $frontendUrl = rtrim($frontendUrl, '/');
19✔
242
        if ($variants === []) {
19✔
243
            return $frontendUrl;
5✔
244
        }
245

246
        foreach ($variants as $baseVariant) {
15✔
247
            $condition = (string)($baseVariant['condition'] ?? '');
15✔
248
            if ($condition === '') {
15✔
NEW
249
                continue;
×
250
            }
251
            try {
252
                if (!array_key_exists($condition, $this->variantConditionCache)) {
15✔
253
                    $this->variantConditionCache[$condition] = (bool)$this->resolver->evaluate($condition);
15✔
254
                }
255
                if ($this->variantConditionCache[$condition]) {
15✔
256
                    return rtrim($baseVariant[$returnField] ?? '', '/');
15✔
257
                }
258
            } catch (SyntaxError $e) {
1✔
259
                $this->logError($e->getMessage());
1✔
260
                // silently fail and do not evaluate
261
                // no logger here, as Site is currently cached and serialized
262
            }
263
        }
264
        return $frontendUrl;
3✔
265
    }
266

267
    private function applySite(Site $site): void
268
    {
269
        $this->conf = $site->getConfiguration();
21✔
270
        $this->variants = $this->conf['baseVariants'] ?? [];
21✔
271
        $this->variantConditionCache = [];
21✔
272
        $this->frontendDomains = [];
21✔
273
        $this->backendDomains = [$site->getBase()->getHost()];
21✔
274

275
        foreach ($this->variants as $variant) {
21✔
276
            $variantBase = trim($variant['base'] ?? '');
18✔
277
            if ($variantBase !== '') {
18✔
278
                $this->backendDomains[] = $this->hostFromBase($variantBase);
18✔
279
            }
280
        }
281

282
        $base = trim($this->conf['frontendBase'] ?? '');
21✔
283
        if ($base !== '') {
21✔
284
            $this->frontendDomains[] = $this->hostFromBase($base);
2✔
285
        }
286
    }
287

288
    private function applyLanguage(SiteLanguage $language): void
289
    {
290
        $langConf = $language->toArray();
2✔
291
        $variants = $langConf['baseVariants'] ?? [];
2✔
292
        $frontendBase = trim($langConf['frontendBase'] ?? '');
2✔
293
        $frontendApiProxy = trim($langConf['frontendApiProxy'] ?? '');
2✔
294
        $frontendFileApi = trim($langConf['frontendFileApi'] ?? '');
2✔
295
        $overrides = [];
2✔
296

297
        if ($language->getBase()->getHost() !== '') {
2✔
NEW
298
            $this->backendDomains[] = $language->getBase()->getHost();
×
299
        }
300

301
        if ($frontendBase !== '') {
2✔
302
            $overrides['frontendBase'] = $frontendBase;
1✔
303
            $this->frontendDomains[] = $this->hostFromBase($frontendBase);
1✔
304
        }
305
        if ($frontendApiProxy !== '') {
2✔
306
            $overrides['frontendApiProxy'] = $frontendApiProxy;
1✔
307
        }
308
        if ($frontendFileApi !== '') {
2✔
309
            $overrides['frontendFileApi'] = $frontendFileApi;
1✔
310
        }
311

312
        $this->conf = array_merge($this->conf, $overrides);
2✔
313

314
        if ($variants !== []) {
2✔
315
            $this->variants = $variants;
2✔
316
            $this->variantConditionCache = [];
2✔
317
        }
318
    }
319

320
    private function applyRequest(ServerRequestInterface $request): void
321
    {
322
        $site = $request->getAttribute('site');
6✔
323
        if ($site instanceof Site) {
6✔
324
            $this->applySite($site);
4✔
325
        }
326

327
        $language = $request->getAttribute('language');
6✔
328
        if ($language instanceof SiteLanguage) {
6✔
329
            $this->applyLanguage($language);
1✔
330
        }
331

332
        $this->headlessMode = $this->headlessMode->withRequest($request);
6✔
333
    }
334

335
    private function handleFrontendAndBackendPaths(string $frontendPath, UriInterface $targetUri, string $baseBackendPath = ''): string
336
    {
337
        $frontendPath = rtrim($frontendPath, '/');
2✔
338
        $targetPath = $targetUri->getPath();
2✔
339
        if ($targetPath === '') {
2✔
340
            return $frontendPath;
1✔
341
        }
342
        return $frontendPath . '/' . ltrim(substr($targetPath, strlen($baseBackendPath)), '/');
1✔
343
    }
344

345
    private function collectLanguageDomainsAndMatch(SiteInterface $site, string $backendUrl): ?SiteLanguage
346
    {
347
        $backendUri = GeneralUtility::makeInstance(Uri::class, $this->sanitizeBaseUrl($backendUrl));
14✔
348
        $matchedLanguage = null;
14✔
349
        foreach ($site->getLanguages() as $language) {
14✔
350
            $base = trim($language->toArray()['frontendBase'] ?? '');
2✔
351
            if ($base === '') {
2✔
352
                continue;
2✔
353
            }
354

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

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

367
        $this->backendDomains = array_unique($this->backendDomains);
14✔
368
        $this->frontendDomains = array_unique($this->frontendDomains);
14✔
369

370
        return $matchedLanguage;
14✔
371
    }
372

373
    private function hostFromBase(string $base): string
374
    {
375
        return (new Uri($this->sanitizeBaseUrl($base)))->getHost();
19✔
376
    }
377

378
    protected function alreadyFrontendLink(string $url): bool
379
    {
380
        return in_array($url, $this->frontendDomains, true);
9✔
381
    }
382

383
    protected function isExternalUrl(string $url): bool
384
    {
385
        return !in_array($url, $this->backendDomains, true)
11✔
386
            && !in_array($url, $this->frontendDomains, true);
11✔
387
    }
388
}
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