Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
52 / 52
100.00% covered (success)
100.00%
5 / 5
CRAP
100.00% covered (success)
100.00%
1 / 1
DavSetupWebController
100.00% covered (success)
100.00%
51 / 51
100.00% covered (success)
100.00%
5 / 5
9
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setupPage
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
3
 downloadAppleProfile
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
3
 renderSetupHtml
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
1
 renderError
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3declare(strict_types=1);
4
5/** @license For full copyright and license information, please see the LICENSE.md file. */
6
7namespace App\Modules\Dav\Presentation\Web;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Modules\Dav\Domain\Repository\DavDeviceRepositoryInterface;
12use App\Modules\Dav\Infrastructure\Config\AppleMobileConfigGenerator;
13use App\Modules\User\Domain\Repository\UserRepositoryInterface;
14use Nyholm\Psr7\Factory\Psr17Factory;
15use Psr\Http\Message\ResponseInterface;
16
17/**
18 * Web Controller for Mobile DAV Device Onboarding and Apple Profile Download.
19 *
20 * Serves the mobile landing page when scanning QR codes and downloads .mobileconfig profiles.
21 *
22 * @package App\Modules\Dav\Presentation\Web
23 */
24final readonly class DavSetupWebController
25{
26    /**
27     * DavSetupWebController constructor.
28     *
29     * @param DavDeviceRepositoryInterface $deviceRepo Device repository contract.
30     * @param UserRepositoryInterface $userRepo User repository contract.
31     * @param AppleMobileConfigGenerator $configGenerator Apple profile generator.
32     * @param Psr17Factory $psr17Factory PSR-17 factory.
33     */
34    public function __construct(
35        private DavDeviceRepositoryInterface $deviceRepo,
36        private UserRepositoryInterface $userRepo,
37        private AppleMobileConfigGenerator $configGenerator,
38        private Psr17Factory $psr17Factory
39    ) {
40    }
41
42    /**
43     * Serves mobile setup landing page when scanning QR code with smartphone.
44     *
45     * @param string $token Device pairing token.
46     * @return ResponseInterface HTML or error response.
47     */
48    public function setupPage(string $token): ResponseInterface
49    {
50        $device = $this->deviceRepo->findByToken($token);
51        if ($device === null) {
52            return $this->renderError('Invalid or expired QR code / sync token.', 404);
53        }
54
55        $user = $this->userRepo->findById($device->userId);
56        if ($user === null) {
57            return $this->renderError('User account was not found.', 404);
58        }
59
60        $this->deviceRepo->touchSync($device->id);
61
62        $html = $this->renderSetupHtml($device->deviceName, $user->getEmail(), $token);
63
64        $response = $this->psr17Factory->createResponse(200)
65            ->withHeader('Content-Type', 'text/html; charset=utf-8');
66        $response->getBody()->write($html);
67
68        return $response;
69    }
70
71    /**
72     * Downloads Apple .mobileconfig profile for native iOS / iPadOS / macOS configuration.
73     *
74     * @param string $token Device pairing token.
75     * @return ResponseInterface Apple mobile configuration XML profile.
76     */
77    public function downloadAppleProfile(string $token): ResponseInterface
78    {
79        $device = $this->deviceRepo->findByToken($token);
80        if ($device === null) {
81            return $this->renderError('Invalid or expired configuration profile.', 404);
82        }
83
84        $user = $this->userRepo->findById($device->userId);
85        if ($user === null) {
86            return $this->renderError('User account was not found.', 404);
87        }
88
89        $this->deviceRepo->touchSync($device->id);
90
91        $displayName = sprintf('Ammonly (%s)', $device->deviceName);
92        $xml = $this->configGenerator->generate($user->getEmail(), $displayName);
93
94        $response = $this->psr17Factory->createResponse(200)
95            ->withHeader('Content-Type', 'application/x-apple-aspen-config; charset=utf-8')
96            ->withHeader('Content-Disposition', 'attachment; filename="ammonly.mobileconfig"')
97            ->withHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
98        $response->getBody()->write($xml);
99
100        return $response;
101    }
102
103    /**
104     * Renders responsive mobile onboarding HTML.
105     */
106    private function renderSetupHtml(string $deviceName, string $email, string $token): string
107    {
108        $safeName = htmlspecialchars($deviceName, ENT_QUOTES, 'UTF-8');
109        $safeEmail = htmlspecialchars($email, ENT_QUOTES, 'UTF-8');
110        $safeToken = htmlspecialchars($token, ENT_QUOTES, 'UTF-8');
111
112        $profileUrl = '/dav/profile/' . $safeToken . '.mobileconfig';
113        $davx5Url = 'davx5://login?url=' . urlencode('https://mail.ammonly.com/SOGo/dav')
114            . '&user=' . urlencode($email);
115
116        return <<<HTML
117<!DOCTYPE html>
118<html lang="en">
119<head>
120    <meta charset="UTF-8">
121    <meta name="viewport" content="width=device-width, initial-scale=1.0">
122    <title>Mobile Device Synchronization - Ammonly</title>
123    <link rel="stylesheet" href="/assets/vendor/tabler/tabler.min.css">
124    <link rel="stylesheet" href="/assets/vendor/bootstrap-icons/bootstrap-icons.min.css">
125</head>
126<body class="bg-dark text-white min-vh-100 d-flex flex-column justify-content-center py-4">
127    <div class="container-tight px-3">
128        <div class="text-center mb-4">
129            <span class="avatar avatar-lg rounded-circle bg-primary-lt text-primary shadow mb-3">
130                <i class="bi bi-phone-fill fs-1"></i>
131            </span>
132            <h1 class="h2 mb-1">Ammonly Groupware</h1>
133            <p class="text-secondary mb-0">Calendar and Contacts Synchronization</p>
134        </div>
135
136        <div class="card card-stacked shadow-sm bg-dark-subtle border-secondary mb-3">
137            <div class="card-body">
138                <h3 class="card-title text-white mb-2">
139                    <i class="bi bi-check2-circle text-success me-1"></i> Device: {$safeName}
140                </h3>
141                <p class="text-secondary small mb-3">Account: <strong>{$safeEmail}</strong></p>
142
143                <!-- Apple iOS Section -->
144                <div class="mb-4 p-3 rounded bg-dark border border-secondary">
145                    <div class="d-flex align-items-center mb-2">
146                        <i class="bi bi-apple fs-2 text-white me-2"></i>
147                        <div>
148                            <h4 class="mb-0 text-white">Apple iPhone / iPad</h4>
149                            <small class="text-secondary">Automatic configuration profile</small>
150                        </div>
151                    </div>
152                    <a href="{$profileUrl}" class="btn btn-primary w-100 mb-2">
153                        <i class="bi bi-download me-1"></i> Download iOS Configuration Profile
154                    </a>
155                    <ol class="small text-secondary ps-3 mb-0">
156                        <li>Click the button above and select <strong>Allow</strong>.</li>
157                        <li>Go to: <em>Settings &gt; Profile Downloaded &gt; Install</em>.</li>
158                        <li>Enter your device passcode and mail account password.</li>
159                    </ol>
160                </div>
161
162                <!-- Android Section -->
163                <div class="p-3 rounded bg-dark border border-secondary">
164                    <div class="d-flex align-items-center mb-2">
165                        <i class="bi bi-android2 fs-2 text-success me-2"></i>
166                        <div>
167                            <h4 class="mb-0 text-white">Android Smartphone</h4>
168                            <small class="text-secondary">DAVx⁵ or Exchange ActiveSync</small>
169                        </div>
170                    </div>
171                    <a href="{$davx5Url}" class="btn btn-outline-success w-100 mb-2">
172                        <i class="bi bi-box-arrow-up-right me-1"></i> Open in DAVx⁵ App
173                    </a>
174                    <p class="small text-secondary mb-0">
175                        Or add an <strong>Exchange</strong> account in Gmail / Mail app using login:
176                        <code>{$safeEmail}</code>.
177                    </p>
178                </div>
179            </div>
180        </div>
181
182        <div class="text-center text-secondary small">
183            &copy; 2026 Ammonly CRM. Secure SSL/TLS encrypted transmission.
184        </div>
185    </div>
186</body>
187</html>
188HTML;
189    }
190
191    /**
192     * Renders a simple HTML error response.
193     */
194    private function renderError(string $message, int $status): ResponseInterface
195    {
196        $safeMsg = htmlspecialchars($message, ENT_QUOTES, 'UTF-8');
197        $html = <<<HTML
198<!DOCTYPE html>
199<html lang="en">
200<head>
201    <meta charset="UTF-8">
202    <meta name="viewport" content="width=device-width, initial-scale=1.0">
203    <title>Error - Ammonly</title>
204    <link rel="stylesheet" href="/assets/vendor/tabler/tabler.min.css">
205</head>
206<body class="bg-dark text-white min-vh-100 d-flex flex-column justify-content-center py-4">
207    <div class="container-tight px-3 text-center">
208        <h1 class="text-danger mb-2">Not Found</h1>
209        <p class="text-secondary">{$safeMsg}</p>
210        <a href="/dashboard" class="btn btn-secondary">Return to Application</a>
211    </div>
212</body>
213</html>
214HTML;
215
216        $response = $this->psr17Factory->createResponse($status)
217            ->withHeader('Content-Type', 'text/html; charset=utf-8');
218        $response->getBody()->write($html);
219
220        return $response;
221    }
222}