Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
86.16% |
249 / 289 |
|
50.00% |
11 / 22 |
CRAP | |
0.00% |
0 / 1 |
| BootstrapRouteDispatcher | |
86.11% |
248 / 288 |
|
50.00% |
11 / 22 |
150.43 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| getDomainDispatchers | |
100.00% |
64 / 64 |
|
100.00% |
1 / 1 |
1 | |||
| getUrlMatcher | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| getDashboardApiController | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| dispatch | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
2 | |||
| dispatchDynamicRoutes | |
88.89% |
8 / 9 |
|
0.00% |
0 / 1 |
4.02 | |||
| dispatchDataExchange | |
0.00% |
0 / 18 |
|
0.00% |
0 / 1 |
90 | |||
| dispatchFastAndStatic | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
4 | |||
| dispatchFastRouteMatch | |
66.67% |
8 / 12 |
|
0.00% |
0 / 1 |
8.81 | |||
| dispatchStaticRoutes | |
84.21% |
16 / 19 |
|
0.00% |
0 / 1 |
20.42 | |||
| dispatchHtmxEngineRoutes | |
100.00% |
16 / 16 |
|
100.00% |
1 / 1 |
1 | |||
| dispatchApiV1Routes | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| dispatchWebRoutes | |
87.50% |
7 / 8 |
|
0.00% |
0 / 1 |
5.05 | |||
| tryDispatchModuleBuilderRoutes | |
61.54% |
8 / 13 |
|
0.00% |
0 / 1 |
8.05 | |||
| tryDispatchDomain | |
80.00% |
4 / 5 |
|
0.00% |
0 / 1 |
3.07 | |||
| resolveDomainFallbackPath | |
83.33% |
5 / 6 |
|
0.00% |
0 / 1 |
5.12 | |||
| findDomainResponse | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
4 | |||
| resolveAliasedPath | |
66.67% |
8 / 12 |
|
0.00% |
0 / 1 |
15.48 | |||
| dispatchCentralEngineWebRoute | |
100.00% |
28 / 28 |
|
100.00% |
1 / 1 |
7 | |||
| matchModuleRoute | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
3 | |||
| matchSingleModuleRoute | |
100.00% |
39 / 39 |
|
100.00% |
1 / 1 |
13 | |||
| resolveRemoteRedirect | |
88.89% |
8 / 9 |
|
0.00% |
0 / 1 |
7.07 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | /** @license For full copyright and license information, please see the LICENSE.md file. */ |
| 6 | |
| 7 | namespace App\Core\Bootstrap\Configurator; |
| 8 | |
| 9 | defined('AMMONLY_APP') || exit('Direct script access is forbidden.'); |
| 10 | |
| 11 | use App\Core\Access\Presentation\Web\AccessWebController; |
| 12 | use App\Core\Api\Presentation\ApiDispatcher; |
| 13 | use App\Core\Api\Presentation\Web\OpenApiDocController; |
| 14 | use App\Core\Bootstrap\Dispatcher\CommerceRouteDispatcher; |
| 15 | use App\Core\Bootstrap\Dispatcher\DashboardAndAnalyticsRouteDispatcher; |
| 16 | use App\Core\Bootstrap\Dispatcher\DocumentAndPdfRouteDispatcher; |
| 17 | use App\Core\Bootstrap\Dispatcher\ModuleRouteDispatcherInterface; |
| 18 | use App\Core\Bootstrap\Dispatcher\SpecialPlatformRouteDispatcher; |
| 19 | use App\Core\Bootstrap\Dispatcher\WebmailRouteDispatcher; |
| 20 | use App\Core\DataExchange\Presentation\Htmx\DataExchangeHtmxController; |
| 21 | use App\Core\Engine\Application\Security\PermissionContextFactory; |
| 22 | use App\Core\Engine\Domain\Model\ModuleMetadata; |
| 23 | use App\Core\Engine\Domain\Repository\MetadataRepositoryInterface; |
| 24 | use App\Core\Engine\Presentation\Api\CentralEngineApiController; |
| 25 | use App\Core\Engine\Presentation\Api\MediaUploadApiControllerInterface; |
| 26 | use App\Core\Engine\Presentation\Api\ModuleFieldsApiController; |
| 27 | use App\Core\Engine\Presentation\Api\ModuleRelationsApiController; |
| 28 | use App\Core\Engine\Presentation\Api\PicklistValuesApiController; |
| 29 | use App\Core\Engine\Presentation\Api\RelationMmApiController; |
| 30 | use App\Core\Engine\Presentation\Web\CentralEngineWebController; |
| 31 | use App\Core\Engine\Presentation\Web\HtmxEngineRequestHandler; |
| 32 | use App\Core\Instance\Application\Service\InstanceContextManagerInterface; |
| 33 | use App\Core\ModuleBuilder\Presentation\Web\BuildersHubWebController; |
| 34 | use App\Core\ModuleBuilder\Presentation\Web\ModuleBuilderWebController; |
| 35 | use App\Core\Router\AppRouterFactory; |
| 36 | use App\Core\Security\Middleware\CsrfMiddleware; |
| 37 | use App\Core\Translation\Presentation\Api\LanguageTranslationsApiController; |
| 38 | use App\Modules\Calendar\Presentation\Web\CalendarRsvpWebController; |
| 39 | use App\Modules\Comments\Presentation\Htmx\CommentsHtmxController; |
| 40 | use App\Modules\Comments\Presentation\Htmx\MentionsHtmxController; |
| 41 | use App\Modules\Comments\Presentation\Web\CommentAttachmentDownloadWebController; |
| 42 | use App\Modules\Comments\Presentation\Web\MentionsWebController; |
| 43 | use App\Modules\Currencies\Presentation\Web\CurrenciesWebController; |
| 44 | use App\Modules\Dashboard\Presentation\Api\DashboardApiController; |
| 45 | use App\Modules\Dashboard\Presentation\Htmx\DashboardHtmxController; |
| 46 | use App\Modules\Dashboard\Presentation\Web\DashboardWebController; |
| 47 | use App\Modules\DataMapping\Presentation\Htmx\DataMappingHtmxController; |
| 48 | use App\Modules\DataMapping\Presentation\Web\DataMappingWebController; |
| 49 | use App\Modules\Dav\Presentation\Web\DavSetupWebController; |
| 50 | use App\Modules\Discount\Presentation\Htmx\DiscountHtmxController; |
| 51 | use App\Modules\Discount\Presentation\Web\DiscountSettingsWebController; |
| 52 | use App\Modules\Documents\Presentation\Web\DocumentDownloadWebController; |
| 53 | use App\Modules\Pdf\Presentation\Web\DocumentVerificationWebController; |
| 54 | use App\Modules\Inventory\Presentation\Htmx\InventoryItemsHtmxController; |
| 55 | use App\Modules\Inventory\Presentation\Htmx\InventorySettingsHtmxController; |
| 56 | use App\Modules\Inventory\Presentation\Web\InventorySettingsWebController; |
| 57 | use App\Modules\Logs\Presentation\Api\JsErrorLogApiController; |
| 58 | use App\Modules\Mail\Presentation\Htmx\WebmailHtmxController; |
| 59 | use App\Modules\Mail\Presentation\Web\MailTemplateWebController; |
| 60 | use App\Modules\Mail\Presentation\Web\MailTrackingWebController; |
| 61 | use App\Modules\Mail\Presentation\Web\WebmailWebController; |
| 62 | use App\Modules\Map\Presentation\Htmx\MapHtmxController; |
| 63 | use App\Modules\Map\Presentation\Web\MapWebController; |
| 64 | use App\Modules\Pdf\Presentation\Htmx\PdfHtmxController; |
| 65 | use App\Modules\Pdf\Presentation\Htmx\PdfQueueHtmxController; |
| 66 | use App\Modules\Pdf\Presentation\Htmx\PdfSignatureHtmxController; |
| 67 | use App\Modules\Pdf\Presentation\Web\PdfWebController; |
| 68 | use App\Modules\Quotes\Presentation\Web\QuoteConversionWebController; |
| 69 | use App\Modules\Tax\Presentation\Htmx\TaxHtmxController; |
| 70 | use App\Modules\Tax\Presentation\Web\TaxSettingsWebController; |
| 71 | use App\Modules\User\Infrastructure\Repository\SqlUserRepository; |
| 72 | use App\Modules\User\Presentation\Middleware\AuthenticatedOnlyMiddleware; |
| 73 | use App\Modules\User\Presentation\Middleware\GuestOnlyMiddleware; |
| 74 | use App\Modules\User\Presentation\Web\AuthController; |
| 75 | use App\Modules\User\Presentation\Web\ImpersonationWebController; |
| 76 | use Nyholm\Psr7\Factory\Psr17Factory; |
| 77 | use Psr\Http\Message\ResponseInterface; |
| 78 | use Psr\Http\Message\ServerRequestInterface; |
| 79 | use Psr\Http\Server\RequestHandlerInterface; |
| 80 | use Twig\Environment as TwigEnvironment; |
| 81 | use Yiisoft\Router\UrlMatcherInterface; |
| 82 | use Yiisoft\Session\SessionInterface; |
| 83 | use Yiisoft\User\CurrentUser; |
| 84 | |
| 85 | /** |
| 86 | * BootstrapRouteDispatcher. |
| 87 | * |
| 88 | * Lightweight orchestrator delegating requests to FastRoute, domain sub-dispatchers, API or CentralEngine. |
| 89 | * |
| 90 | * @package App\Core\Bootstrap\Configurator |
| 91 | */ |
| 92 | final readonly class BootstrapRouteDispatcher |
| 93 | { |
| 94 | private const PATH_DASHBOARD = '/dashboard'; |
| 95 | |
| 96 | /** |
| 97 | * BootstrapRouteDispatcher constructor. |
| 98 | */ |
| 99 | public function __construct( |
| 100 | private OpenApiDocController $openApiDocController, |
| 101 | private JsErrorLogApiController $jsErrorLogApiController, |
| 102 | private ApiDispatcher $apiDispatcher, |
| 103 | private AuthController $authController, |
| 104 | private CentralEngineWebController $centralEngineWebController, |
| 105 | private CentralEngineApiController $centralEngineApiController, |
| 106 | private PicklistValuesApiController $picklistValuesApiController, |
| 107 | private ModuleFieldsApiController $moduleFieldsApiController, |
| 108 | private ModuleRelationsApiController $moduleRelationsApiController, |
| 109 | private MetadataRepositoryInterface $metadataRepo, |
| 110 | private SqlUserRepository $userRepository, |
| 111 | private CurrentUser $currentUser, |
| 112 | private SessionInterface $session, |
| 113 | private PermissionContextFactory $contextFactory, |
| 114 | private AuthenticatedOnlyMiddleware $authMiddleware, |
| 115 | private GuestOnlyMiddleware $guestMiddleware, |
| 116 | private CsrfMiddleware $csrfMiddleware, |
| 117 | private Psr17Factory $psr17Factory, |
| 118 | private ?InstanceContextManagerInterface $instanceContextManager = null, |
| 119 | private ?RelationMmApiController $relationMmApiController = null, |
| 120 | private ?DavSetupWebController $davSetupWebController = null, |
| 121 | private ?WebmailWebController $webmailWebController = null, |
| 122 | private ?WebmailHtmxController $webmailHtmxController = null, |
| 123 | private ?TwigEnvironment $twig = null, |
| 124 | private ?MediaUploadApiControllerInterface $mediaUploadApiController = null, |
| 125 | private ?DocumentDownloadWebController $documentDownloadWebController = null, |
| 126 | private ?MapWebController $mapWebController = null, |
| 127 | private ?MapHtmxController $mapHtmxController = null, |
| 128 | private ?PdfHtmxController $pdfHtmxController = null, |
| 129 | private ?PdfWebController $pdfWebController = null, |
| 130 | private ?DocumentVerificationWebController $documentVerificationWebController = null, |
| 131 | private ?PdfSignatureHtmxController $pdfSignatureHtmxController = null, |
| 132 | private ?PdfQueueHtmxController $pdfQueueHtmxController = null, |
| 133 | private ?MailTrackingWebController $mailTrackingWebController = null, |
| 134 | private ?MailTemplateWebController $mailTemplateWebController = null, |
| 135 | private ?AccessWebController $accessWebController = null, |
| 136 | private ?CommentsHtmxController $commentsHtmxController = null, |
| 137 | private ?CommentAttachmentDownloadWebController $commentAttachmentDownloadWebController = null, |
| 138 | private ?MentionsWebController $mentionsWebController = null, |
| 139 | private ?MentionsHtmxController $mentionsHtmxController = null, |
| 140 | private ?ImpersonationWebController $impersonationWebController = null, |
| 141 | private ?DashboardWebController $dashboardWebController = null, |
| 142 | private ?DashboardApiController $dashboardApiController = null, |
| 143 | private ?DashboardHtmxController $dashboardHtmxController = null, |
| 144 | private ?string $appProfile = 'admin', |
| 145 | private ?UrlMatcherInterface $urlMatcher = null, |
| 146 | private ?TaxSettingsWebController $taxSettingsWebController = null, |
| 147 | private ?TaxHtmxController $taxHtmxController = null, |
| 148 | private ?DiscountSettingsWebController $discountSettingsWebController = null, |
| 149 | private ?DiscountHtmxController $discountHtmxController = null, |
| 150 | private ?InventorySettingsWebController $inventorySettingsWebController = null, |
| 151 | private ?InventorySettingsHtmxController $inventorySettingsHtmxController = null, |
| 152 | private ?InventoryItemsHtmxController $inventoryItemsHtmxController = null, |
| 153 | private ?DataMappingWebController $dataMappingWebController = null, |
| 154 | private ?DataMappingHtmxController $dataMappingHtmxController = null, |
| 155 | private ?QuoteConversionWebController $quoteConversionWebController = null, |
| 156 | private ?CurrenciesWebController $currenciesWebController = null, |
| 157 | private ?LanguageTranslationsApiController $languageTranslationsApiController = null, |
| 158 | private ?DataExchangeHtmxController $dataExchangeHtmxController = null, |
| 159 | private ?ModuleBuilderWebController $moduleBuilderWebController = null, |
| 160 | private ?CalendarRsvpWebController $calendarRsvpWebController = null, |
| 161 | private ?BuildersHubWebController $buildersHubWebController = null |
| 162 | ) { |
| 163 | } |
| 164 | |
| 165 | /** |
| 166 | * Returns list of domain sub-dispatchers. |
| 167 | * |
| 168 | * @return list<ModuleRouteDispatcherInterface> |
| 169 | */ |
| 170 | private function getDomainDispatchers(): array |
| 171 | { |
| 172 | return [ |
| 173 | new CommerceRouteDispatcher( |
| 174 | $this->taxSettingsWebController, |
| 175 | $this->taxHtmxController, |
| 176 | $this->discountSettingsWebController, |
| 177 | $this->discountHtmxController, |
| 178 | $this->inventorySettingsWebController, |
| 179 | $this->inventorySettingsHtmxController, |
| 180 | $this->inventoryItemsHtmxController, |
| 181 | $this->dataMappingWebController, |
| 182 | $this->dataMappingHtmxController, |
| 183 | $this->quoteConversionWebController, |
| 184 | $this->authMiddleware, |
| 185 | $this->psr17Factory, |
| 186 | $this->currenciesWebController |
| 187 | ), |
| 188 | new DocumentAndPdfRouteDispatcher( |
| 189 | $this->pdfHtmxController, |
| 190 | $this->pdfSignatureHtmxController, |
| 191 | $this->pdfQueueHtmxController, |
| 192 | $this->pdfWebController, |
| 193 | $this->documentDownloadWebController, |
| 194 | $this->commentAttachmentDownloadWebController, |
| 195 | $this->contextFactory, |
| 196 | $this->authMiddleware, |
| 197 | $this->psr17Factory |
| 198 | ), |
| 199 | new WebmailRouteDispatcher( |
| 200 | $this->webmailWebController, |
| 201 | $this->mailTemplateWebController, |
| 202 | $this->webmailHtmxController, |
| 203 | $this->authMiddleware, |
| 204 | $this->csrfMiddleware, |
| 205 | $this->psr17Factory |
| 206 | ), |
| 207 | new SpecialPlatformRouteDispatcher( |
| 208 | $this->mentionsWebController, |
| 209 | $this->accessWebController, |
| 210 | $this->impersonationWebController, |
| 211 | $this->authController, |
| 212 | $this->davSetupWebController, |
| 213 | $this->userRepository, |
| 214 | $this->currentUser, |
| 215 | $this->session, |
| 216 | $this->contextFactory, |
| 217 | $this->authMiddleware, |
| 218 | $this->guestMiddleware, |
| 219 | $this->csrfMiddleware, |
| 220 | $this->psr17Factory, |
| 221 | $this->appProfile ?? 'admin' |
| 222 | ), |
| 223 | new DashboardAndAnalyticsRouteDispatcher( |
| 224 | $this->dashboardWebController, |
| 225 | $this->dashboardHtmxController, |
| 226 | $this->mapWebController, |
| 227 | $this->mapHtmxController, |
| 228 | $this->commentsHtmxController, |
| 229 | $this->mentionsHtmxController, |
| 230 | $this->contextFactory, |
| 231 | $this->authMiddleware, |
| 232 | $this->csrfMiddleware, |
| 233 | $this->psr17Factory |
| 234 | ), |
| 235 | ]; |
| 236 | } |
| 237 | |
| 238 | public function getUrlMatcher(): UrlMatcherInterface |
| 239 | { |
| 240 | return $this->urlMatcher ?? AppRouterFactory::createMatcher(); |
| 241 | } |
| 242 | |
| 243 | public function getDashboardApiController(): ?DashboardApiController |
| 244 | { |
| 245 | return $this->dashboardApiController; |
| 246 | } |
| 247 | |
| 248 | public function dispatch(ServerRequestInterface $request): ResponseInterface |
| 249 | { |
| 250 | $path = parse_url($request->getUri()->getPath(), PHP_URL_PATH) ?? '/'; |
| 251 | |
| 252 | $earlyResponse = $this->dispatchFastAndStatic($path, $request); |
| 253 | if ($earlyResponse !== null) { |
| 254 | return $earlyResponse; |
| 255 | } |
| 256 | |
| 257 | return $this->dispatchDynamicRoutes($path, $request); |
| 258 | } |
| 259 | |
| 260 | private function dispatchDynamicRoutes(string $path, ServerRequestInterface $request): ResponseInterface |
| 261 | { |
| 262 | $dataExchangePattern = '#^/(?:htmx/)?engine/([a-zA-Z0-9_-]+)/' . |
| 263 | '(export-modal|export-download|import-modal|import-upload|import-start|import-progress(?:/\d+)?)$#'; |
| 264 | if ((bool) preg_match($dataExchangePattern, $path)) { |
| 265 | return $this->dispatchDataExchange($path, $request); |
| 266 | } |
| 267 | |
| 268 | if (str_starts_with($path, '/htmx/engine/')) { |
| 269 | return $this->dispatchHtmxEngineRoutes($path, $request); |
| 270 | } |
| 271 | |
| 272 | return str_starts_with($path, '/api/v1/') |
| 273 | ? $this->dispatchApiV1Routes($path, $request) |
| 274 | : $this->dispatchWebRoutes($path, $request); |
| 275 | } |
| 276 | |
| 277 | private function dispatchDataExchange(string $path, ServerRequestInterface $request): ResponseInterface |
| 278 | { |
| 279 | if ($this->dataExchangeHtmxController === null) { |
| 280 | return $this->psr17Factory->createResponse(404); |
| 281 | } |
| 282 | |
| 283 | $ctx = $this->contextFactory->createFromRequest($request); |
| 284 | |
| 285 | return match (true) { |
| 286 | (bool) preg_match('#^/(?:htmx/)?engine/([a-zA-Z0-9_-]+)/export-modal$#', $path, $m) |
| 287 | => $this->dataExchangeHtmxController->actionExportModal($request, $m[1]), |
| 288 | (bool) preg_match('#^/(?:htmx/)?engine/([a-zA-Z0-9_-]+)/export-download$#', $path, $m) |
| 289 | => $this->dataExchangeHtmxController->actionExportDownload($request, $m[1], $ctx), |
| 290 | (bool) preg_match('#^/(?:htmx/)?engine/([a-zA-Z0-9_-]+)/import-modal$#', $path, $m) |
| 291 | => $this->dataExchangeHtmxController->actionImportModal($m[1]), |
| 292 | (bool) preg_match('#^/(?:htmx/)?engine/([a-zA-Z0-9_-]+)/import-upload$#', $path, $m) |
| 293 | => $this->dataExchangeHtmxController->actionUploadAndAnalyze($request, $m[1]), |
| 294 | (bool) preg_match('#^/(?:htmx/)?engine/([a-zA-Z0-9_-]+)/import-start$#', $path, $m) |
| 295 | => $this->dataExchangeHtmxController->actionStartImport($request, $m[1], $ctx), |
| 296 | (bool) preg_match('#^/(?:htmx/)?engine/([a-zA-Z0-9_-]+)/import-progress/(\d+)$#', $path, $m) |
| 297 | => $this->dataExchangeHtmxController->actionImportProgress($m[1], (int) $m[2]), |
| 298 | default => $this->psr17Factory->createResponse(404), |
| 299 | }; |
| 300 | } |
| 301 | |
| 302 | private function dispatchFastAndStatic(string $path, ServerRequestInterface $request): ?ResponseInterface |
| 303 | { |
| 304 | $matchResult = $this->getUrlMatcher()->match($request); |
| 305 | if ($matchResult->isSuccess()) { |
| 306 | $routeName = (string) $matchResult->route()->getData('name'); |
| 307 | $matched = $this->dispatchFastRouteMatch($routeName, $request); |
| 308 | if ($matched !== null) { |
| 309 | return $matched; |
| 310 | } |
| 311 | } |
| 312 | |
| 313 | $response = $this->dispatchStaticRoutes($path, $request); |
| 314 | if ($response !== null) { |
| 315 | return $response; |
| 316 | } |
| 317 | |
| 318 | return $this->findDomainResponse($path, $request); |
| 319 | } |
| 320 | |
| 321 | private function dispatchFastRouteMatch( |
| 322 | string $routeName, |
| 323 | ServerRequestInterface $request |
| 324 | ): ?ResponseInterface { |
| 325 | return match ($routeName) { |
| 326 | 'api_doc_ui', 'api_doc_ui_slash' => $this->openApiDocController->ui(), |
| 327 | 'openapi_spec' => $this->openApiDocController->jsonSpec(), |
| 328 | 'js_error_log' => $request->getMethod() === 'POST' |
| 329 | ? $this->jsErrorLogApiController->log($request) |
| 330 | : null, |
| 331 | 'locale_translations' => $this->apiDispatcher->dispatch( |
| 332 | '/api/v1/locale/translations', |
| 333 | $request |
| 334 | ), |
| 335 | default => null, |
| 336 | }; |
| 337 | } |
| 338 | |
| 339 | private function dispatchStaticRoutes(string $path, ServerRequestInterface $request): ?ResponseInterface |
| 340 | { |
| 341 | return match (true) { |
| 342 | (bool) preg_match('#^/verify/([a-zA-Z0-9]+)$#', $path, $m) && $request->getMethod() === 'GET' |
| 343 | => $this->documentVerificationWebController?->verify($m[1]), |
| 344 | (bool) preg_match('#^/mail/track/open/([a-zA-Z0-9]+)$#', $path, $m) && $request->getMethod() === 'GET' |
| 345 | => $this->mailTrackingWebController?->trackOpen($request, $m[1]), |
| 346 | (bool) preg_match('#^/mail/track/click/([a-zA-Z0-9]+)$#', $path, $m) && $request->getMethod() === 'GET' |
| 347 | => $this->mailTrackingWebController?->trackClick($request, $m[1]), |
| 348 | (bool) preg_match('#^/pdf/queue/download/(\d+)$#', $path, $m) && $request->getMethod() === 'GET' |
| 349 | => $this->pdfQueueHtmxController?->download((int) $m[1]), |
| 350 | $path === '/calendar/rsvp/download' && $request->getMethod() === 'GET' |
| 351 | => $this->calendarRsvpWebController?->downloadIcs($request), |
| 352 | $path === '/calendar/rsvp' && $request->getMethod() === 'GET' |
| 353 | => $this->calendarRsvpWebController?->handle($request), |
| 354 | $path === '/api' || $path === '/api/' => $this->openApiDocController->ui(), |
| 355 | $path === '/api/v1/openapi.json' => $this->openApiDocController->jsonSpec(), |
| 356 | $path === '/api/v1/logs/js-error' && $request->getMethod() === 'POST' |
| 357 | => $this->jsErrorLogApiController->log($request), |
| 358 | default => null, |
| 359 | }; |
| 360 | } |
| 361 | |
| 362 | private function dispatchHtmxEngineRoutes(string $path, ServerRequestInterface $request): ResponseInterface |
| 363 | { |
| 364 | $handler = new HtmxEngineRequestHandler( |
| 365 | $this->authMiddleware, |
| 366 | $path, |
| 367 | $this->centralEngineApiController, |
| 368 | $this->picklistValuesApiController, |
| 369 | $this->moduleFieldsApiController, |
| 370 | $this->moduleRelationsApiController, |
| 371 | $this->contextFactory, |
| 372 | $this->psr17Factory, |
| 373 | $this->relationMmApiController, |
| 374 | $this->twig, |
| 375 | $this->mediaUploadApiController, |
| 376 | $this->languageTranslationsApiController, |
| 377 | $this->dataExchangeHtmxController, |
| 378 | ); |
| 379 | |
| 380 | return $this->csrfMiddleware->process($request, $handler); |
| 381 | } |
| 382 | |
| 383 | private function dispatchApiV1Routes(string $path, ServerRequestInterface $request): ResponseInterface |
| 384 | { |
| 385 | return $this->apiDispatcher->dispatch($path, $request); |
| 386 | } |
| 387 | |
| 388 | private function dispatchWebRoutes(string $path, ServerRequestInterface $request): ResponseInterface |
| 389 | { |
| 390 | $domainResponse = $this->tryDispatchDomain($path, $request); |
| 391 | if ($domainResponse !== null) { |
| 392 | return $domainResponse; |
| 393 | } |
| 394 | |
| 395 | $mbResponse = $this->tryDispatchModuleBuilderRoutes($path, $request); |
| 396 | if ($mbResponse !== null) { |
| 397 | return $mbResponse; |
| 398 | } |
| 399 | |
| 400 | $effectivePath = ($path === '/' || $path === '') ? self::PATH_DASHBOARD : $path; |
| 401 | |
| 402 | return $this->dispatchCentralEngineWebRoute($effectivePath, $request); |
| 403 | } |
| 404 | |
| 405 | private function tryDispatchModuleBuilderRoutes(string $path, ServerRequestInterface $request): ?ResponseInterface |
| 406 | { |
| 407 | if ($request->getMethod() !== 'GET') { |
| 408 | return null; |
| 409 | } |
| 410 | |
| 411 | $ctx = $this->contextFactory->createFromRequest($request); |
| 412 | return match (true) { |
| 413 | $path === '/system/builders' => $this->buildersHubWebController?->actionIndex() |
| 414 | ?? $this->psr17Factory->createResponse(404), |
| 415 | $path === '/system/modules/builder/new' => $this->moduleBuilderWebController?->actionNew($ctx) |
| 416 | ?? $this->psr17Factory->createResponse(404), |
| 417 | (bool) preg_match('#^/system/modules/(\d+)/builder$#', $path, $m) |
| 418 | => $this->moduleBuilderWebController?->actionEdit((int) $m[1], $ctx) |
| 419 | ?? $this->psr17Factory->createResponse(404), |
| 420 | default => null, |
| 421 | }; |
| 422 | } |
| 423 | |
| 424 | private function tryDispatchDomain(string $path, ServerRequestInterface $request): ?ResponseInterface |
| 425 | { |
| 426 | $response = $this->findDomainResponse($path, $request); |
| 427 | if ($response !== null) { |
| 428 | return $response; |
| 429 | } |
| 430 | |
| 431 | $fallbackPath = $this->resolveDomainFallbackPath($path); |
| 432 | return $fallbackPath !== null ? $this->findDomainResponse($fallbackPath, $request) : null; |
| 433 | } |
| 434 | |
| 435 | private function resolveDomainFallbackPath(string $path): ?string |
| 436 | { |
| 437 | $aliasedPath = $this->resolveAliasedPath($path); |
| 438 | if ($aliasedPath !== null) { |
| 439 | return $aliasedPath; |
| 440 | } |
| 441 | |
| 442 | return (($path === '/' || $path === '') && $this->dashboardWebController !== null) |
| 443 | ? self::PATH_DASHBOARD |
| 444 | : null; |
| 445 | } |
| 446 | |
| 447 | private function findDomainResponse(string $path, ServerRequestInterface $request): ?ResponseInterface |
| 448 | { |
| 449 | foreach ($this->getDomainDispatchers() as $dispatcher) { |
| 450 | if ($dispatcher->canDispatch($path)) { |
| 451 | $subResponse = $dispatcher->dispatch($path, $request); |
| 452 | if ($subResponse !== null) { |
| 453 | return $subResponse; |
| 454 | } |
| 455 | } |
| 456 | } |
| 457 | |
| 458 | return null; |
| 459 | } |
| 460 | |
| 461 | private function resolveAliasedPath(string $path): ?string |
| 462 | { |
| 463 | return match ($path) { |
| 464 | '/settings/taxes', '/settings/taxes/' => '/settings/tax', |
| 465 | '/settings/discounts', '/settings/discounts/' => '/settings/discount', |
| 466 | '/system/access', '/system/access/' => '/settings/access', |
| 467 | '/system/access/admin', '/system/access/admin/' => '/settings/access/admin', |
| 468 | '/system/access/client', '/system/access/client/' => '/settings/access/client', |
| 469 | '/system/access/rules-drawer', '/system/access/rules/drawer' => '/settings/access/rules-drawer', |
| 470 | '/system/impersonation', '/system/impersonation/' => '/impersonation/panel', |
| 471 | '/system/impersonation/search-users' => '/impersonation/search', |
| 472 | '/system/impersonation/save' => '/impersonation/save', |
| 473 | default => null, |
| 474 | }; |
| 475 | } |
| 476 | |
| 477 | private function dispatchCentralEngineWebRoute( |
| 478 | string $path, |
| 479 | ServerRequestInterface $request |
| 480 | ): ResponseInterface { |
| 481 | $activeModules = $this->metadataRepo->findAllActiveModules(); |
| 482 | $trimPath = '/' . trim($path, '/'); |
| 483 | |
| 484 | [$matchedModule, $action, $recordId] = $this->matchModuleRoute($trimPath, $activeModules); |
| 485 | if ($matchedModule === null) { |
| 486 | return $this->psr17Factory->createResponse(404); |
| 487 | } |
| 488 | |
| 489 | $redirectResponse = $this->resolveRemoteRedirect($matchedModule, $activeModules); |
| 490 | if ($redirectResponse !== null) { |
| 491 | return $redirectResponse; |
| 492 | } |
| 493 | |
| 494 | $moduleName = $matchedModule->name; |
| 495 | |
| 496 | return $this->authMiddleware->process( |
| 497 | $request, |
| 498 | new class($this->centralEngineWebController, $moduleName, $action, $recordId, $this->contextFactory) |
| 499 | implements RequestHandlerInterface { |
| 500 | public function __construct( |
| 501 | private CentralEngineWebController $controller, |
| 502 | private string $moduleName, |
| 503 | private string $action, |
| 504 | private ?int $recordId, |
| 505 | private PermissionContextFactory $contextFactory |
| 506 | ) { |
| 507 | } |
| 508 | |
| 509 | public function handle(ServerRequestInterface $req): ResponseInterface |
| 510 | { |
| 511 | $ctx = $this->contextFactory->createFromRequest($req); |
| 512 | return match ($this->action) { |
| 513 | 'create' => $this->controller->actionCreate($req, $this->moduleName, $ctx), |
| 514 | 'detail' => $this->controller->actionDetail( |
| 515 | $req, |
| 516 | $this->moduleName, |
| 517 | $this->recordId ?? 0, |
| 518 | $ctx |
| 519 | ), |
| 520 | 'edit' => $this->controller->actionEdit($req, $this->moduleName, $this->recordId ?? 0, $ctx), |
| 521 | default => $this->controller->actionIndex($req, $this->moduleName, $ctx), |
| 522 | }; |
| 523 | } |
| 524 | } |
| 525 | ); |
| 526 | } |
| 527 | |
| 528 | /** |
| 529 | * Matches request URI path against active module route patterns. |
| 530 | * |
| 531 | * @param string $trimPath Sanitized path. |
| 532 | * @param array<ModuleMetadata> $activeModules List of active module metadata objects. |
| 533 | * @return array{0: ?ModuleMetadata, 1: string, 2: ?int} Matched module, action and optional record ID. |
| 534 | */ |
| 535 | private function matchModuleRoute(string $trimPath, array $activeModules): array |
| 536 | { |
| 537 | foreach ($activeModules as $meta) { |
| 538 | $matched = $this->matchSingleModuleRoute($trimPath, $meta); |
| 539 | if ($matched !== null) { |
| 540 | return [$meta, $matched[0], $matched[1]]; |
| 541 | } |
| 542 | } |
| 543 | |
| 544 | return [null, 'index', null]; |
| 545 | } |
| 546 | |
| 547 | /** |
| 548 | * Matches path against candidate prefixes for a single module. |
| 549 | * |
| 550 | * @param string $trimPath Sanitized request path. |
| 551 | * @param ModuleMetadata $meta Module metadata. |
| 552 | * @return array{0: string, 1: ?int}|null Matched action and record ID or null. |
| 553 | */ |
| 554 | private function matchSingleModuleRoute(string $trimPath, ModuleMetadata $meta): ?array |
| 555 | { |
| 556 | $basePath = '/' . trim($meta->routeUrl, '/'); |
| 557 | $altPath = '/' . trim($meta->name, '/'); |
| 558 | $dashPath = '/' . str_replace('_', '-', trim($meta->name, '/')); |
| 559 | $slashPath = '/' . str_replace('_', '/', trim($meta->name, '/')); |
| 560 | $legacyAliases = match ($meta->name) { |
| 561 | 'user_password_resets' => ['/logs/password-resets'], |
| 562 | 'system_users' => ['/users', '/system/users'], |
| 563 | 'system_profiles' => ['/profiles', '/system/profiles'], |
| 564 | default => [], |
| 565 | }; |
| 566 | $candidates = array_unique([ |
| 567 | $basePath, |
| 568 | $altPath, |
| 569 | $dashPath, |
| 570 | $slashPath, |
| 571 | ...$legacyAliases, |
| 572 | '/system' . $basePath, |
| 573 | '/engine' . $basePath, |
| 574 | '/engine' . $altPath, |
| 575 | '/records' . $basePath, |
| 576 | '/records' . $altPath, |
| 577 | '/app' . $basePath, |
| 578 | '/app' . $altPath, |
| 579 | ]); |
| 580 | |
| 581 | foreach ($candidates as $prefix) { |
| 582 | $match = match (true) { |
| 583 | $trimPath === $prefix => ['index', null], |
| 584 | $trimPath === $prefix . '/create' => ['create', null], |
| 585 | (bool) preg_match('#^' . preg_quote($prefix, '#') . '/(?:detail/)?(\d+)$#', $trimPath, $m) |
| 586 | => ['detail', (int) $m[1]], |
| 587 | (bool) preg_match('#^' . preg_quote($prefix, '#') . '/(\d+)/edit$#', $trimPath, $m) |
| 588 | => ['edit', (int) $m[1]], |
| 589 | (bool) preg_match('#^' . preg_quote($prefix, '#') . '/edit/(\d+)$#', $trimPath, $m) |
| 590 | => ['edit', (int) $m[1]], |
| 591 | default => null, |
| 592 | }; |
| 593 | if ($match !== null) { |
| 594 | return $match; |
| 595 | } |
| 596 | } |
| 597 | |
| 598 | return null; |
| 599 | } |
| 600 | |
| 601 | /** |
| 602 | * Resolves redirect response if current module is not available in remote host context. |
| 603 | * |
| 604 | * @param ModuleMetadata $matchedModule Matched module metadata. |
| 605 | * @param array<ModuleMetadata> $activeModules List of active module metadata objects. |
| 606 | * @return ResponseInterface|null Redirect response or null if accessible. |
| 607 | */ |
| 608 | private function resolveRemoteRedirect(ModuleMetadata $matchedModule, array $activeModules): ?ResponseInterface |
| 609 | { |
| 610 | $isRemote = $this->instanceContextManager !== null && $this->instanceContextManager->isRemote(); |
| 611 | $activeHostId = $isRemote ? 2 : 1; |
| 612 | if (!$isRemote || $matchedModule->isAvailableForHost($activeHostId)) { |
| 613 | return null; |
| 614 | } |
| 615 | |
| 616 | foreach ($activeModules as $meta) { |
| 617 | if ($meta->isAvailableForHost($activeHostId)) { |
| 618 | $targetUrl = '/' . trim($meta->routeUrl, '/'); |
| 619 | return $this->psr17Factory->createResponse(302)->withHeader('Location', $targetUrl); |
| 620 | } |
| 621 | } |
| 622 | |
| 623 | return null; |
| 624 | } |
| 625 | } |