config = $config; $this->themeManager = $themeManager; $this->metadata = $metadata; $this->fileManager = $fileManager; $this->devModeJsFileListProvider = $devModeJsFileListProvider; $this->module = $module; $this->nonce = Util::generateKey(); } public function setBasePath(string $basePath): void { $this->basePath = $basePath; } public function getBasePath(): string { return $this->basePath; } protected function getCacheTimestamp(): int { if (!$this->config->get('useCache')) { return time(); } return $this->config->get('cacheTimestamp', 0); } /** * @todo Move to a separate class. */ public function writeHeaders(Response $response): void { if ($this->config->get('clientSecurityHeadersDisabled')) { return; } $response->setHeader('X-Frame-Options', 'SAMEORIGIN'); $response->setHeader('X-Content-Type-Options', 'nosniff'); $this->writeContentSecurityPolicyHeader($response); $this->writeStrictTransportSecurityHeader($response); } private function writeContentSecurityPolicyHeader(Response $response): void { if ($this->config->get('clientCspDisabled')) { return; } $scriptSrc = "script-src 'self' 'nonce-{$this->nonce}' 'unsafe-eval'"; $scriptSourceList = $this->config->get('clientCspScriptSourceList') ?? []; foreach ($scriptSourceList as $src) { $scriptSrc .= ' ' . $src; } $response->setHeader('Content-Security-Policy', $scriptSrc); } private function writeStrictTransportSecurityHeader(Response $response): void { if ($this->config->get('clientStrictTransportSecurityHeaderDisabled')) { return; } $siteUrl = $this->config->get('siteUrl') ?? ''; if (strpos($siteUrl, 'https://') === 0) { $response->setHeader('Strict-Transport-Security', 'max-age=10368000'); } } /** * @param array $vars */ public function display(?string $runScript = null, ?string $htmlFilePath = null, array $vars = []): void { $body = $this->render($runScript, $htmlFilePath, $vars); $response = new ResponseWrapper(new Psr7Response()); $this->writeHeaders($response); $response->writeBody($body); (new ResponseEmitter())->emit($response->getResponse()); } /** * @param array $vars */ public function render(?string $runScript = null, ?string $htmlFilePath = null, array $vars = []): string { if (is_null($runScript)) { $runScript = $this->runScript; } if (is_null($htmlFilePath)) { $htmlFilePath = $this->mainHtmlFilePath; } $cacheTimestamp = $this->getCacheTimestamp(); $jsFileList = $this->getJsFileList(); if ($this->config->get('isDeveloperMode')) { $useCache = $this->config->get('useCacheInDeveloperMode'); $loaderCacheTimestamp = 'null'; } else { $useCache = $this->config->get('useCache'); $loaderCacheTimestamp = $cacheTimestamp; } $cssFileList = $this->metadata->get(['app', 'client', 'cssList'], []); $linkList = $this->metadata->get(['app', 'client', 'linkList'], []); $scriptsHtml = ''; foreach ($jsFileList as $jsFile) { $src = $this->basePath . $jsFile . '?r=' . $cacheTimestamp; $scriptsHtml .= "\n " . ""; } $additionalStyleSheetsHtml = ''; foreach ($cssFileList as $cssFile) { $src = $this->basePath . $cssFile . '?r=' . $cacheTimestamp; $additionalStyleSheetsHtml .= "\n "; } $linksHtml = ''; foreach ($linkList as $item) { $href = $this->basePath . $item['href']; if (empty($item['noTimestamp'])) { $href .= '?r=' . $cacheTimestamp; } $as = $item['as'] ?? ''; $rel = $item['rel'] ?? ''; $type = $item['type'] ?? ''; $additionalPlaceholder = ''; if (!empty($item['crossorigin'])) { $additionalPlaceholder .= ' crossorigin'; } $linksHtml .= "\n " . ""; } $favicon196Path = $this->metadata->get(['app', 'client', 'favicon196']) ?? 'client/img/favicon196x196.png'; $faviconPath = $this->metadata->get(['app', 'client', 'favicon']) ?? 'client/img/favicon.ico'; $internalModuleList = array_map( function (string $moduleName): string { return Util::fromCamelCase($moduleName, '-'); }, $this->module->getInternalList() ); $data = [ 'applicationId' => 'espocrm-application-id', 'apiUrl' => 'api/v1', 'applicationName' => $this->config->get('applicationName', 'EspoCRM'), 'cacheTimestamp' => $cacheTimestamp, 'loaderCacheTimestamp' => $loaderCacheTimestamp, 'stylesheet' => $this->themeManager->getStylesheet(), 'runScript' => $runScript, 'basePath' => $this->basePath, 'useCache' => $useCache ? 'true' : 'false', 'appClientClassName' => 'app', 'scriptsHtml' => $scriptsHtml, 'additionalStyleSheetsHtml' => $additionalStyleSheetsHtml, 'linksHtml' => $linksHtml, 'favicon196Path' => $favicon196Path, 'faviconPath' => $faviconPath, 'ajaxTimeout' => $this->config->get('ajaxTimeout') ?? 60000, 'libsConfigPath' => $this->libsConfigPath, 'internalModuleList' => Json::encode($internalModuleList), 'applicationDescription' => $this->config->get('applicationDescription') ?? self::APP_DESCRIPTION, 'nonce' => $this->nonce, ]; $html = $this->fileManager->getContents($htmlFilePath); foreach ($vars as $key => $value) { $html = str_replace('{{'.$key.'}}', $value, $html); } foreach ($data as $key => $value) { if (array_key_exists($key, $vars)) { continue; } $html = str_replace('{{'.$key.'}}', $value, $html); } return $html; } /** * @return string[] */ private function getJsFileList(): array { if ($this->config->get('isDeveloperMode')) { return array_merge( $this->getDeveloperModeBundleLibFileList(), $this->metadata->get(['app', 'client', 'developerModeScriptList']) ?? [], ); } return $this->metadata->get(['app', 'client', 'scriptList']) ?? []; } /** * @return string[] */ private function getDeveloperModeBundleLibFileList(): array { return $this->devModeJsFileListProvider->get(); } }