Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
94.12% covered (success)
94.12%
16 / 17
0.00% covered (danger)
0.00%
0 / 1
CRAP
0.00% covered (danger)
0.00%
0 / 1
AppUrlResolver
94.12% covered (success)
94.12%
16 / 17
0.00% covered (danger)
0.00%
0 / 1
10.02
0.00% covered (danger)
0.00%
0 / 1
 resolve
94.12% covered (success)
94.12%
16 / 17
0.00% covered (danger)
0.00%
0 / 1
10.02
1<?php
2
3declare(strict_types=1);
4
5namespace App\Support;
6
7use Psr\Http\Message\ServerRequestInterface;
8
9use function explode;
10use function rtrim;
11use function trim;
12
13/**
14 * Resolves the base URL used for links in outbound emails.
15 *
16 * An explicit `app_url` always wins — set it per env when the SPA lives on a
17 * different origin than the API (e.g. local dev, where Vite runs on :3000).
18 * Otherwise the URL is derived from the incoming request, so deployed
19 * environments — where nginx serves the SPA and proxies /api on the same
20 * origin — produce correct links without any per-env config (FSC-136). The
21 * localhost default only applies when there is no usable request host (CLI).
22 *
23 * Extracted from ForgotPasswordAction so the email-change flow (FSC-86) builds
24 * verification links the same way.
25 */
26final class AppUrlResolver
27{
28    public static function resolve(ServerRequestInterface $request, ?string $configuredAppUrl): string
29    {
30        $configured = $configuredAppUrl ?? '';
31
32        if ($configured !== '') {
33            return rtrim($configured, '/');
34        }
35
36        $uri = $request->getUri();
37        $host = $uri->getHost();
38
39        if ($host === '') {
40            return 'http://localhost:3000';
41        }
42
43        // Honor the proxy's forwarded scheme so TLS-terminated requests
44        // produce https links even when PHP sees http internally. The header
45        // may carry a comma-separated chain; the first hop is the client.
46        $forwardedProto = trim(explode(',', $request->getHeaderLine('X-Forwarded-Proto'))[0]);
47        $scheme = $forwardedProto !== '' ? $forwardedProto : $uri->getScheme();
48
49        if ($scheme === '') {
50            $scheme = 'https';
51        }
52
53        $port = $uri->getPort();
54        $isDefaultPort = $port === null
55            || ($scheme === 'http' && $port === 80)
56            || ($scheme === 'https' && $port === 443);
57
58        $authority = $isDefaultPort ? $host : $host . ':' . $port;
59
60        return $scheme . '://' . $authority;
61    }
62}