Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
97.01% |
162 / 167 |
|
63.64% |
7 / 11 |
CRAP | |
0.00% |
0 / 1 |
| ApiDispatcher | |
96.99% |
161 / 166 |
|
63.64% |
7 / 11 |
44 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
55 / 55 |
|
100.00% |
1 / 1 |
1 | |||
| dispatch | |
94.74% |
18 / 19 |
|
0.00% |
0 / 1 |
2.00 | |||
| runPipeline | |
100.00% |
16 / 16 |
|
100.00% |
1 / 1 |
2 | |||
| routePublicEndpoint | |
90.91% |
10 / 11 |
|
0.00% |
0 / 1 |
4.01 | |||
| resolveAuthPublicHandler | |
100.00% |
19 / 19 |
|
100.00% |
1 / 1 |
15 | |||
| routeAuthenticatedEndpoint | |
100.00% |
16 / 16 |
|
100.00% |
1 / 1 |
4 | |||
| dispatchAuthenticatedRoute | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
1 | |||
| isAllowedInCurrentProfile | |
80.00% |
4 / 5 |
|
0.00% |
0 / 1 |
4.13 | |||
| isAdminOnlyEndpoint | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
7 | |||
| normalizeApiPath | |
60.00% |
3 / 5 |
|
0.00% |
0 / 1 |
3.58 | |||
| jsonError | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
1 | |||
| 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\Api\Presentation; |
| 8 | |
| 9 | defined('AMMONLY_APP') || exit('Direct script access is forbidden.'); |
| 10 | |
| 11 | use App\Core\Api\Middleware\ApiLoggingMiddleware; |
| 12 | use App\Core\Api\Middleware\ApiPayloadGuardMiddleware; |
| 13 | use App\Core\Api\Middleware\ApiTokenMiddleware; |
| 14 | use App\Core\Api\Middleware\CorsMiddleware; |
| 15 | use App\Core\Api\Middleware\IdempotencyMiddleware; |
| 16 | use App\Core\Api\Middleware\RequestIdMiddleware; |
| 17 | use App\Core\Api\Presentation\Group\CommerceApiRouteGroup; |
| 18 | use App\Core\Api\Presentation\Group\EngineApiRouteGroup; |
| 19 | use App\Core\Api\Presentation\Group\SystemApiRouteGroup; |
| 20 | use App\Core\DataExchange\Presentation\Api\DataExchangeApiController; |
| 21 | use App\Core\Access\Presentation\Api\AccessApiController; |
| 22 | use App\Core\Audit\Presentation\Api\AuditApiController; |
| 23 | use App\Core\Cron\Presentation\Api\CronApiController; |
| 24 | use App\Core\Engine\Application\Security\PermissionContextFactory; |
| 25 | use App\Core\Engine\Domain\Model\PermissionContext; |
| 26 | use App\Core\Engine\Presentation\Api\CentralEngineApiController; |
| 27 | use App\Core\Engine\Presentation\Api\GridWidgetApiController; |
| 28 | use App\Core\Engine\Presentation\Api\GridWidgetManagementApiController; |
| 29 | use App\Core\Engine\Presentation\Api\PicklistValuesApiController; |
| 30 | use App\Core\Engine\Presentation\Api\KanbanApiController; |
| 31 | use App\Core\Engine\Presentation\Api\MediaUploadApiControllerInterface; |
| 32 | use App\Core\Engine\Presentation\Api\ModuleFieldsApiController; |
| 33 | use App\Core\Instance\Presentation\Api\InstanceContextApiController; |
| 34 | use App\Modules\Documents\Presentation\Api\DocumentApiController; |
| 35 | use App\Core\Layout\Presentation\Api\LayoutApiController; |
| 36 | use App\Core\ModuleBuilder\Presentation\Api\ModuleBuilderApiController; |
| 37 | use App\Modules\Comments\Presentation\Api\CommentsApiController; |
| 38 | use App\Core\Preference\Presentation\Api\UserPreferencesApiController; |
| 39 | use App\Core\Security\Middleware\CsrfMiddleware; |
| 40 | use App\Core\Security\Middleware\RateLimiterMiddleware; |
| 41 | use App\Core\Settings\Presentation\Api\SettingsApiController; |
| 42 | use App\Core\Translation\Presentation\Api\LanguageTranslationsApiController; |
| 43 | use App\Core\Translation\Presentation\Api\TranslationApiController; |
| 44 | use App\Modules\About\Presentation\Api\AboutLicensesApiController; |
| 45 | use App\Modules\About\Presentation\Api\AboutRequirementsApiController; |
| 46 | use App\Modules\Mail\Presentation\Api\MailApiController; |
| 47 | use App\Modules\Map\Presentation\Api\MapApiController; |
| 48 | use App\Modules\Automation\Presentation\Api\WorkflowApiController; |
| 49 | use App\Modules\Dav\Presentation\Api\DavDeviceApiController; |
| 50 | use App\Modules\Mail\Presentation\Api\WebmailApiController; |
| 51 | use App\Modules\Mail\Presentation\Api\WebmailEventsApiController; |
| 52 | use App\Modules\Menu\Presentation\Api\MenuApiController; |
| 53 | use App\Modules\Projects\Presentation\Api\ProjectGanttApiController; |
| 54 | use App\Modules\User\Presentation\Api\AuthApiController; |
| 55 | use App\Modules\User\Presentation\Api\LocaleApiController; |
| 56 | use App\Modules\User\Presentation\Api\MfaApiController; |
| 57 | use App\Modules\User\Presentation\Api\UserProfileApiController; |
| 58 | use App\Modules\Pdf\Presentation\Api\PdfApiController; |
| 59 | use App\Modules\Profiles\Presentation\Api\ProfilePermissionApiController; |
| 60 | use App\Modules\Structure\Presentation\Api\OwnershipApiController; |
| 61 | use App\Modules\Structure\Presentation\Api\StructureApiController; |
| 62 | use App\Core\Search\Presentation\Api\GlobalSearchApiController; |
| 63 | use App\Core\Search\Presentation\Api\SearchSettingsApiController; |
| 64 | use App\Modules\Dashboard\Presentation\Api\DashboardApiControllerInterface; |
| 65 | use App\Modules\Tax\Presentation\Api\TaxCalculateApiController; |
| 66 | use App\Modules\Tax\Presentation\Api\TaxGroupsApiController; |
| 67 | use App\Modules\Tax\Presentation\Api\TaxRatesApiController; |
| 68 | use App\Modules\Tax\Presentation\Api\TaxRulesApiController; |
| 69 | use App\Modules\Discount\Presentation\Api\DiscountRatesApiController; |
| 70 | use App\Modules\Currencies\Presentation\Api\CurrenciesApiController; |
| 71 | use App\Core\Engine\Presentation\Api\InventoryFieldsApiController; |
| 72 | use App\Modules\Inventory\Presentation\Api\InventoryItemsApiController; |
| 73 | use Psr\Http\Message\ResponseFactoryInterface; |
| 74 | use Psr\Http\Message\ResponseInterface; |
| 75 | use Psr\Http\Message\ServerRequestInterface; |
| 76 | use Psr\Http\Server\RequestHandlerInterface; |
| 77 | |
| 78 | /** |
| 79 | * Dispatches versioned REST API v1 routes through security and logging middleware pipeline. |
| 80 | * |
| 81 | * @package App\Core\Api\Presentation |
| 82 | */ |
| 83 | final readonly class ApiDispatcher |
| 84 | { |
| 85 | private const string PATH_SETTINGS = '/api/v1/settings'; |
| 86 | |
| 87 | private EngineApiRouteGroup $engineRouteGroup; |
| 88 | private CommerceApiRouteGroup $commerceRouteGroup; |
| 89 | private SystemApiRouteGroup $systemRouteGroup; |
| 90 | |
| 91 | /** |
| 92 | * ApiDispatcher constructor. |
| 93 | */ |
| 94 | public function __construct( |
| 95 | private AuthApiController $authApi, |
| 96 | private LocaleApiController $localeApi, |
| 97 | private MenuApiController $menuApi, |
| 98 | private AboutLicensesApiController $aboutApi, |
| 99 | private AboutRequirementsApiController $aboutRequirementsApi, |
| 100 | private CentralEngineApiController $engineApi, |
| 101 | private GridWidgetApiController $gridWidgetApi, |
| 102 | private PicklistValuesApiController $picklistValuesApi, |
| 103 | private TranslationApiController $transApi, |
| 104 | private SettingsApiController $settingsApi, |
| 105 | private LayoutApiController $layoutApi, |
| 106 | private CronApiController $cronApi, |
| 107 | private MailApiController $mailApi, |
| 108 | private AuditApiController $auditApi, |
| 109 | private PermissionContextFactory $contextFactory, |
| 110 | private RequestIdMiddleware $requestIdMiddleware, |
| 111 | private CorsMiddleware $corsMiddleware, |
| 112 | private ApiPayloadGuardMiddleware $apiPayloadGuardMiddleware, |
| 113 | private RateLimiterMiddleware $loginLimiter, |
| 114 | private RateLimiterMiddleware $apiLimiter, |
| 115 | private IdempotencyMiddleware $idempotency, |
| 116 | private CsrfMiddleware $csrf, |
| 117 | private ApiTokenMiddleware $tokenMiddleware, |
| 118 | private ResponseFactoryInterface $factory, |
| 119 | private ?InstanceContextApiController $instanceContextApi = null, |
| 120 | private ?ApiLoggingMiddleware $apiLoggingMiddleware = null, |
| 121 | private ?UserPreferencesApiController $preferencesApi = null, |
| 122 | private ?UserProfileApiController $userProfileApi = null, |
| 123 | private ?DavDeviceApiController $davDeviceApi = null, |
| 124 | private ?WebmailApiController $webmailApi = null, |
| 125 | private ?WebmailEventsApiController $webmailEventsApi = null, |
| 126 | private ?WorkflowApiController $workflowApi = null, |
| 127 | private ?MfaApiController $mfaApi = null, |
| 128 | private ?ProjectGanttApiController $ganttApi = null, |
| 129 | private ?KanbanApiController $kanbanApi = null, |
| 130 | private ?DocumentApiController $documentApi = null, |
| 131 | private ?MapApiController $mapApi = null, |
| 132 | private ?PdfApiController $pdfApi = null, |
| 133 | private string $profile = 'admin', |
| 134 | private ?OwnershipApiController $ownershipApi = null, |
| 135 | private ?StructureApiController $structureApi = null, |
| 136 | private ?AccessApiController $accessApi = null, |
| 137 | private ?ProfilePermissionApiController $profileApi = null, |
| 138 | private ?MediaUploadApiControllerInterface $mediaUploadApi = null, |
| 139 | private ?GlobalSearchApiController $searchApi = null, |
| 140 | private ?SearchSettingsApiController $searchSettingsApi = null, |
| 141 | private ?CommentsApiController $commentsApi = null, |
| 142 | private ?DashboardApiControllerInterface $dashboardApi = null, |
| 143 | private ?TaxRatesApiController $taxRatesApi = null, |
| 144 | private ?TaxGroupsApiController $taxGroupsApi = null, |
| 145 | private ?TaxRulesApiController $taxRulesApi = null, |
| 146 | private ?TaxCalculateApiController $taxCalculateApi = null, |
| 147 | private ?DiscountRatesApiController $discountsApi = null, |
| 148 | private ?InventoryFieldsApiController $inventoryFieldsApi = null, |
| 149 | private ?CurrenciesApiController $currenciesApi = null, |
| 150 | private ?InventoryItemsApiController $inventoryItemsApi = null, |
| 151 | private ?LanguageTranslationsApiController $languageTranslationsApi = null, |
| 152 | private ?DataExchangeApiController $dataExchangeApi = null, |
| 153 | private ?ModuleBuilderApiController $moduleBuilderApi = null, |
| 154 | private ?GridWidgetManagementApiController $gridWidgetManagementApi = null, |
| 155 | ) { |
| 156 | $this->engineRouteGroup = new EngineApiRouteGroup( |
| 157 | $this->engineApi, |
| 158 | $this->gridWidgetApi, |
| 159 | $this->picklistValuesApi, |
| 160 | $this->factory, |
| 161 | $this->ownershipApi, |
| 162 | $this->mediaUploadApi, |
| 163 | $this->kanbanApi, |
| 164 | $this->ganttApi, |
| 165 | $this->documentApi, |
| 166 | $this->inventoryFieldsApi, |
| 167 | $this->inventoryItemsApi, |
| 168 | $this->languageTranslationsApi, |
| 169 | $this->dataExchangeApi, |
| 170 | $this->gridWidgetManagementApi |
| 171 | ); |
| 172 | |
| 173 | $this->commerceRouteGroup = new CommerceApiRouteGroup( |
| 174 | $this->taxRatesApi, |
| 175 | $this->taxGroupsApi, |
| 176 | $this->taxRulesApi, |
| 177 | $this->taxCalculateApi, |
| 178 | $this->discountsApi, |
| 179 | $this->currenciesApi |
| 180 | ); |
| 181 | |
| 182 | $this->systemRouteGroup = new SystemApiRouteGroup( |
| 183 | $this->authApi, |
| 184 | $this->menuApi, |
| 185 | $this->aboutApi, |
| 186 | $this->aboutRequirementsApi, |
| 187 | $this->transApi, |
| 188 | $this->settingsApi, |
| 189 | $this->layoutApi, |
| 190 | $this->cronApi, |
| 191 | $this->mailApi, |
| 192 | $this->auditApi, |
| 193 | $this->factory, |
| 194 | $this->instanceContextApi, |
| 195 | $this->preferencesApi, |
| 196 | $this->userProfileApi, |
| 197 | $this->davDeviceApi, |
| 198 | $this->webmailApi, |
| 199 | $this->webmailEventsApi, |
| 200 | $this->workflowApi, |
| 201 | $this->mfaApi, |
| 202 | $this->mapApi, |
| 203 | $this->pdfApi, |
| 204 | $this->structureApi, |
| 205 | $this->accessApi, |
| 206 | $this->profileApi, |
| 207 | $this->searchApi, |
| 208 | $this->searchSettingsApi, |
| 209 | $this->commentsApi, |
| 210 | $this->dashboardApi, |
| 211 | $this->moduleBuilderApi |
| 212 | ); |
| 213 | } |
| 214 | |
| 215 | /** |
| 216 | * Executes API security middleware chain and dispatches requested route. |
| 217 | * |
| 218 | * @param string $path Request path. |
| 219 | * @param ServerRequestInterface $request PSR-7 Server request. |
| 220 | * @return ResponseInterface PSR-7 response. |
| 221 | */ |
| 222 | public function dispatch(string $path, ServerRequestInterface $request): ResponseInterface |
| 223 | { |
| 224 | $guardHandler = new class($this, $path) implements RequestHandlerInterface { |
| 225 | public function __construct( |
| 226 | private ApiDispatcher $dispatcher, |
| 227 | private string $path |
| 228 | ) { |
| 229 | } |
| 230 | |
| 231 | public function handle(ServerRequestInterface $r): ResponseInterface |
| 232 | { |
| 233 | return $this->dispatcher->runPipeline($this->path, $r); |
| 234 | } |
| 235 | }; |
| 236 | |
| 237 | $loggingHandler = $this->apiLoggingMiddleware !== null |
| 238 | ? new class($this->apiLoggingMiddleware, $guardHandler) implements RequestHandlerInterface { |
| 239 | public function __construct( |
| 240 | private ApiLoggingMiddleware $logger, |
| 241 | private RequestHandlerInterface $next |
| 242 | ) { |
| 243 | } |
| 244 | |
| 245 | public function handle(ServerRequestInterface $r): ResponseInterface |
| 246 | { |
| 247 | return $this->logger->process($r, $this->next); |
| 248 | } |
| 249 | } |
| 250 | : $guardHandler; |
| 251 | |
| 252 | $corsHandler = new class($this->apiPayloadGuardMiddleware, $loggingHandler) implements RequestHandlerInterface { |
| 253 | public function __construct( |
| 254 | private ApiPayloadGuardMiddleware $guard, |
| 255 | private RequestHandlerInterface $next |
| 256 | ) { |
| 257 | } |
| 258 | |
| 259 | public function handle(ServerRequestInterface $r): ResponseInterface |
| 260 | { |
| 261 | return $this->guard->process($r, $this->next); |
| 262 | } |
| 263 | }; |
| 264 | |
| 265 | $requestIdHandler = new class($this->corsMiddleware, $corsHandler) implements RequestHandlerInterface { |
| 266 | public function __construct( |
| 267 | private CorsMiddleware $cors, |
| 268 | private RequestHandlerInterface $next |
| 269 | ) { |
| 270 | } |
| 271 | |
| 272 | public function handle(ServerRequestInterface $r): ResponseInterface |
| 273 | { |
| 274 | return $this->cors->process($r, $this->next); |
| 275 | } |
| 276 | }; |
| 277 | |
| 278 | return $this->requestIdMiddleware->process($request, $requestIdHandler); |
| 279 | } |
| 280 | |
| 281 | /** |
| 282 | * Runs rate limiter, CSRF or token authentication pipeline before reaching route handler. |
| 283 | * |
| 284 | * @param string $path Request path. |
| 285 | * @param ServerRequestInterface $req Incoming request. |
| 286 | * @return ResponseInterface Handled response. |
| 287 | */ |
| 288 | public function runPipeline(string $path, ServerRequestInterface $req): ResponseInterface |
| 289 | { |
| 290 | $publicRes = $this->routePublicEndpoint($path, $req); |
| 291 | if ($publicRes !== null) { |
| 292 | return $publicRes; |
| 293 | } |
| 294 | |
| 295 | $innerHandler = new class($this, $path) implements RequestHandlerInterface { |
| 296 | public function __construct( |
| 297 | private ApiDispatcher $dispatcher, |
| 298 | private string $path |
| 299 | ) { |
| 300 | } |
| 301 | |
| 302 | public function handle(ServerRequestInterface $r): ResponseInterface |
| 303 | { |
| 304 | return $this->dispatcher->routeAuthenticatedEndpoint($this->path, $r); |
| 305 | } |
| 306 | }; |
| 307 | |
| 308 | $tokenHandler = new class($this->tokenMiddleware, $innerHandler) implements RequestHandlerInterface { |
| 309 | public function __construct( |
| 310 | private ApiTokenMiddleware $token, |
| 311 | private RequestHandlerInterface $next |
| 312 | ) { |
| 313 | } |
| 314 | |
| 315 | public function handle(ServerRequestInterface $r): ResponseInterface |
| 316 | { |
| 317 | return $this->token->process($r, $this->next); |
| 318 | } |
| 319 | }; |
| 320 | |
| 321 | $idempotencyHandler = new class($this->idempotency, $tokenHandler) implements RequestHandlerInterface { |
| 322 | public function __construct( |
| 323 | private IdempotencyMiddleware $idempotency, |
| 324 | private RequestHandlerInterface $next |
| 325 | ) { |
| 326 | } |
| 327 | |
| 328 | public function handle(ServerRequestInterface $r): ResponseInterface |
| 329 | { |
| 330 | return $this->idempotency->process($r, $this->next); |
| 331 | } |
| 332 | }; |
| 333 | |
| 334 | return $this->apiLimiter->process($req, $idempotencyHandler); |
| 335 | } |
| 336 | |
| 337 | /** |
| 338 | * Routes public/unauthenticated endpoints with dedicated rate limiters or CSRF. |
| 339 | * |
| 340 | * @param string $path Request path. |
| 341 | * @param ServerRequestInterface $req Incoming request. |
| 342 | * @return ResponseInterface|null Handled response or null if not a public endpoint. |
| 343 | */ |
| 344 | private function routePublicEndpoint(string $path, ServerRequestInterface $req): ?ResponseInterface |
| 345 | { |
| 346 | $normPath = $this->normalizeApiPath($path); |
| 347 | $authHandler = $this->resolveAuthPublicHandler($normPath, $req); |
| 348 | if ($authHandler !== null) { |
| 349 | return $this->loginLimiter->process($req, $authHandler); |
| 350 | } |
| 351 | |
| 352 | if ($normPath === '/api/v1/user/locale' && $req->getMethod() === 'POST') { |
| 353 | $localeHandler = new class($this->localeApi) implements RequestHandlerInterface { |
| 354 | public function __construct(private LocaleApiController $api) |
| 355 | { |
| 356 | } |
| 357 | |
| 358 | public function handle(ServerRequestInterface $r): ResponseInterface |
| 359 | { |
| 360 | return $this->api->setLocale($r); |
| 361 | } |
| 362 | }; |
| 363 | return $this->csrf->process($req, $localeHandler); |
| 364 | } |
| 365 | |
| 366 | return null; |
| 367 | } |
| 368 | |
| 369 | /** |
| 370 | * Resolves authentication endpoint handler if matching path and method. |
| 371 | * |
| 372 | * @param string $path Endpoint path. |
| 373 | * @param ServerRequestInterface $req Incoming request. |
| 374 | * @return RequestHandlerInterface|null Handled request or null if non-matching. |
| 375 | */ |
| 376 | private function resolveAuthPublicHandler(string $path, ServerRequestInterface $req): ?RequestHandlerInterface |
| 377 | { |
| 378 | $action = match ($path) { |
| 379 | '/api/v1/auth/login' => 'login', |
| 380 | '/api/v1/auth/mfa-verify' => $req->getMethod() === 'POST' ? 'mfaVerify' : null, |
| 381 | '/api/v1/auth/forgot-password' => $req->getMethod() === 'POST' ? 'forgotPassword' : null, |
| 382 | '/api/v1/auth/reset-password' => $req->getMethod() === 'POST' ? 'resetPassword' : null, |
| 383 | default => null, |
| 384 | }; |
| 385 | |
| 386 | if ($action === null) { |
| 387 | return null; |
| 388 | } |
| 389 | |
| 390 | return new class($this->authApi, $action) implements RequestHandlerInterface { |
| 391 | public function __construct( |
| 392 | private AuthApiController $api, |
| 393 | private string $action |
| 394 | ) { |
| 395 | } |
| 396 | |
| 397 | public function handle(ServerRequestInterface $r): ResponseInterface |
| 398 | { |
| 399 | return match ($this->action) { |
| 400 | 'login' => $this->api->login($r), |
| 401 | 'mfaVerify' => $this->api->mfaVerify($r), |
| 402 | 'forgotPassword' => $this->api->forgotPassword($r), |
| 403 | 'resetPassword' => $this->api->resetPassword($r), |
| 404 | default => throw new \LogicException('Unknown action: ' . $this->action), |
| 405 | }; |
| 406 | } |
| 407 | }; |
| 408 | } |
| 409 | |
| 410 | /** |
| 411 | * Routes authenticated endpoints to appropriate domain controllers. |
| 412 | * |
| 413 | * @param string $path Request path. |
| 414 | * @param ServerRequestInterface $req Incoming request. |
| 415 | * @param PermissionContext $ctx Authenticated permission context. |
| 416 | * @return ResponseInterface Dispatched response. |
| 417 | */ |
| 418 | public function routeAuthenticatedEndpoint( |
| 419 | string $path, |
| 420 | ServerRequestInterface $req, |
| 421 | ?PermissionContext $ctx = null |
| 422 | ): ResponseInterface { |
| 423 | if (!$this->isAllowedInCurrentProfile($path)) { |
| 424 | return $this->jsonError( |
| 425 | $this->factory, |
| 426 | 'This API endpoint is restricted to the administrative application profile.', |
| 427 | 403 |
| 428 | ); |
| 429 | } |
| 430 | |
| 431 | $context = $ctx ?? $this->contextFactory->createFromRequest($req); |
| 432 | |
| 433 | if ($req->getAttribute('auth_type') === 'session') { |
| 434 | $method = strtoupper($req->getMethod()); |
| 435 | if (!in_array($method, ['GET', 'HEAD', 'OPTIONS'], true)) { |
| 436 | $csrfHandler = new class($this, $path, $context) implements RequestHandlerInterface { |
| 437 | public function __construct( |
| 438 | private ApiDispatcher $dispatcher, |
| 439 | private string $path, |
| 440 | private PermissionContext $context |
| 441 | ) { |
| 442 | } |
| 443 | |
| 444 | public function handle(ServerRequestInterface $r): ResponseInterface |
| 445 | { |
| 446 | return $this->dispatcher->dispatchAuthenticatedRoute($this->path, $r, $this->context); |
| 447 | } |
| 448 | }; |
| 449 | return $this->csrf->process($req, $csrfHandler); |
| 450 | } |
| 451 | } |
| 452 | |
| 453 | return $this->dispatchAuthenticatedRoute($path, $req, $context); |
| 454 | } |
| 455 | |
| 456 | /** |
| 457 | * Dispatches authenticated request to matching domain endpoints. |
| 458 | * |
| 459 | * @param string $path Request path. |
| 460 | * @param ServerRequestInterface $req Incoming request. |
| 461 | * @param PermissionContext $context Authenticated permission context. |
| 462 | * @return ResponseInterface Dispatched response. |
| 463 | */ |
| 464 | public function dispatchAuthenticatedRoute( |
| 465 | string $path, |
| 466 | ServerRequestInterface $req, |
| 467 | PermissionContext $context |
| 468 | ): ResponseInterface { |
| 469 | $normPath = $this->normalizeApiPath($path); |
| 470 | $res = $this->engineRouteGroup->route($normPath, $req, $context) |
| 471 | ?? $this->commerceRouteGroup->route($normPath, $req) |
| 472 | ?? $this->systemRouteGroup->route($normPath, $req, $context); |
| 473 | |
| 474 | return $res ?? $this->factory->createResponse(404); |
| 475 | } |
| 476 | |
| 477 | /** |
| 478 | * Determines whether the given path is allowed in the current application profile. |
| 479 | * |
| 480 | * @param string $path Endpoint URI path. |
| 481 | * @return bool True if accessible in the current profile. |
| 482 | */ |
| 483 | public function isAllowedInCurrentProfile(string $path): bool |
| 484 | { |
| 485 | if (str_starts_with($path, '/api/v1/admin/') && $this->profile === 'client') { |
| 486 | return false; |
| 487 | } |
| 488 | |
| 489 | if ($this->profile !== 'client') { |
| 490 | return true; |
| 491 | } |
| 492 | |
| 493 | return !$this->isAdminOnlyEndpoint($path); |
| 494 | } |
| 495 | |
| 496 | /** |
| 497 | * Checks if the endpoint path is strictly restricted to administrative consoles. |
| 498 | * |
| 499 | * @param string $path Endpoint URI path. |
| 500 | * @return bool True if admin-only. |
| 501 | */ |
| 502 | public function isAdminOnlyEndpoint(string $path): bool |
| 503 | { |
| 504 | $norm = $this->normalizeApiPath($path); |
| 505 | |
| 506 | return str_starts_with($norm, '/api/v1/system/context') |
| 507 | || str_starts_with($norm, '/api/v1/instance') |
| 508 | || str_starts_with($norm, '/api/v1/about') |
| 509 | || str_starts_with($norm, '/api/v1/audit') |
| 510 | || str_starts_with($norm, '/api/v1/cron') |
| 511 | || str_starts_with($norm, '/api/v1/layout') |
| 512 | || str_starts_with($norm, self::PATH_SETTINGS); |
| 513 | } |
| 514 | |
| 515 | /** |
| 516 | * Normalizes versioned API sub-namespace prefixes to standard /api/v1 routes. |
| 517 | * |
| 518 | * @param string $path Input URI path. |
| 519 | * @return string Normalized path. |
| 520 | */ |
| 521 | private function normalizeApiPath(string $path): string |
| 522 | { |
| 523 | if (str_starts_with($path, '/api/v1/admin/')) { |
| 524 | return '/api/v1/' . substr($path, 14); |
| 525 | } |
| 526 | if (str_starts_with($path, '/api/v1/client/')) { |
| 527 | return '/api/v1/' . substr($path, 15); |
| 528 | } |
| 529 | |
| 530 | return $path; |
| 531 | } |
| 532 | |
| 533 | |
| 534 | |
| 535 | /** |
| 536 | * Helper to render standardized JSON error responses. |
| 537 | * |
| 538 | * @param ResponseFactoryInterface $factory Response factory. |
| 539 | * @param string $message Error message. |
| 540 | * @param int $status HTTP status code. |
| 541 | * @return ResponseInterface |
| 542 | */ |
| 543 | private function jsonError(ResponseFactoryInterface $factory, string $message, int $status): ResponseInterface |
| 544 | { |
| 545 | $response = $factory->createResponse($status); |
| 546 | $payload = json_encode([ |
| 547 | 'success' => false, |
| 548 | 'error' => $message, |
| 549 | ], JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES); |
| 550 | $response->getBody()->write($payload); |
| 551 | |
| 552 | return $response->withHeader('Content-Type', 'application/json'); |
| 553 | } |
| 554 | } |