diff --git a/Classes/Controller/RequireJsController.php b/Classes/Controller/RequireJsController.php new file mode 100644 index 0000000000..23b88cf80e --- /dev/null +++ b/Classes/Controller/RequireJsController.php @@ -0,0 +1,109 @@ +pageRenderer = GeneralUtility::makeInstance(PageRenderer::class); + } + + /** + * Retrieves additional requirejs configuration for a given module name or module path. + * + * The JSON result e.g. could look like: + * { + * "shim": { + * "vendor/module": ["exports" => "TheModule"] + * }, + * "paths": { + * "vendor/module": "/public/web/path/" + * }, + * "packages": { + * [ + * "name": "module", + * ... + * ] + * } + * } + * + * Parameter name either could be the module name ("vendor/module") or a + * module path ("vendor/module/component") belonging to a module. + * + * @param ServerRequestInterface $request + * @return ResponseInterface + */ + public function retrieveConfiguration(ServerRequestInterface $request): ResponseInterface + { + $name = $request->getQueryParams()['name'] ?? null; + if (empty($name) || !is_string($name)) { + return new JsonResponse(null, 404); + } + $configuration = $this->findConfiguration($name); + return new JsonResponse($configuration, !empty($configuration) ? 200 : 404); + } + + /** + * @param string $name + * @return array + */ + protected function findConfiguration(string $name): array + { + $relevantConfiguration = []; + $this->pageRenderer->loadRequireJs(); + $configuration = $this->pageRenderer->getRequireJsConfig(PageRenderer::REQUIREJS_SCOPE_RESOLVE); + + $shim = $configuration['shim'] ?? []; + foreach ($shim as $baseModuleName => $baseModuleConfiguration) { + if (strpos($name . '/', $baseModuleName . '/') === 0) { + $relevantConfiguration['shim'][$baseModuleName] = $baseModuleConfiguration; + } + } + + $paths = $configuration['paths'] ?? []; + foreach ($paths as $baseModuleName => $baseModulePath) { + if (strpos($name . '/', $baseModuleName . '/') === 0) { + $relevantConfiguration['paths'][$baseModuleName] = $baseModulePath; + } + } + + $packages = $configuration['packages'] ?? []; + foreach ($packages as $package) { + if (!empty($package['name']) + && strpos($name . '/', $package['name'] . '/') === 0 + ) { + $relevantConfiguration['packages'][] = $package; + } + } + + return $relevantConfiguration; + } +} diff --git a/Classes/Page/PageRenderer.php b/Classes/Page/PageRenderer.php index 303ac76463..5947a717a6 100644 --- a/Classes/Page/PageRenderer.php +++ b/Classes/Page/PageRenderer.php @@ -45,6 +45,9 @@ class PageRenderer implements \TYPO3\CMS\Core\SingletonInterface // @deprecated will be removed in TYPO3 v10.0. const JQUERY_NAMESPACE_NONE = 'none'; + const REQUIREJS_SCOPE_CONFIG = 'config'; + const REQUIREJS_SCOPE_RESOLVE = 'resolve'; + /** * @var bool */ @@ -332,11 +335,23 @@ class PageRenderer implements \TYPO3\CMS\Core\SingletonInterface protected $addRequireJs = false; /** - * inline configuration for requireJS + * Inline configuration for requireJS (internal) * @var array */ protected $requireJsConfig = []; + /** + * Module names of internal requireJS 'paths' + * @var array + */ + protected $internalRequireJsPathModuleNames = []; + + /** + * Inline configuration for requireJS (public) + * @var array + */ + protected $publicRequireJsConfig = []; + /** * @var bool */ @@ -599,6 +614,34 @@ public function setRequireJsPath($path) $this->requireJsPath = $path; } + /** + * @param string $scope + * @return array + */ + public function getRequireJsConfig(string $scope = null): array + { + // return basic RequireJS configuration without shim, paths and packages + if ($scope === static::REQUIREJS_SCOPE_CONFIG) { + return array_replace_recursive( + $this->publicRequireJsConfig, + $this->filterArrayKeys( + $this->requireJsConfig, + ['shim', 'paths', 'packages'], + false + ) + ); + } + // return RequireJS configuration for resolving only shim, paths and packages + if ($scope === static::REQUIREJS_SCOPE_RESOLVE) { + return $this->filterArrayKeys( + $this->requireJsConfig, + ['shim', 'paths', 'packages'], + true + ); + } + return []; + } + /*****************************************************/ /* */ /* Public Enablers / Disablers */ @@ -1399,7 +1442,7 @@ public function loadJquery($version = null, $source = null, $namespace = self::J public function loadRequireJs() { $this->addRequireJs = true; - if (!empty($this->requireJsConfig)) { + if (!empty($this->requireJsConfig) && !empty($this->publicRequireJsConfig)) { return; } @@ -1408,13 +1451,17 @@ public function loadRequireJs() $cacheIdentifier = 'requireJS_' . md5(implode(',', $loadedExtensions) . ($isDevelopment ? ':dev' : '') . GeneralUtility::getIndpEnv('TYPO3_REQUEST_SCRIPT')); /** @var FrontendInterface $cache */ $cache = static::$cache ?? GeneralUtility::makeInstance(CacheManager::class)->getCache('assets'); - $this->requireJsConfig = $cache->get($cacheIdentifier); + $requireJsConfig = $cache->get($cacheIdentifier); // if we did not get a configuration from the cache, compute and store it in the cache - if (empty($this->requireJsConfig)) { - $this->requireJsConfig = $this->computeRequireJsConfig($isDevelopment, $loadedExtensions); - $cache->set($cacheIdentifier, $this->requireJsConfig); + if (!isset($requireJsConfig['internal']) || !isset($requireJsConfig['public']) || true) { + $requireJsConfig = $this->computeRequireJsConfig($isDevelopment, $loadedExtensions); + $cache->set($cacheIdentifier, $requireJsConfig); } + + $this->requireJsConfig = $requireJsConfig['internal']; + $this->publicRequireJsConfig = $requireJsConfig['public']; + $this->internalRequireJsPathModuleNames = $requireJsConfig['internalNames']; } /** @@ -1428,18 +1475,22 @@ public function loadRequireJs() protected function computeRequireJsConfig($isDevelopment, array $loadedExtensions) { // load all paths to map to package names / namespaces - $requireJsConfig = []; + $requireJsConfig = [ + 'public' => [], + 'internal' => [], + 'internalNames' => [], + ]; // In order to avoid browser caching of JS files, adding a GET parameter to the files loaded via requireJS if ($isDevelopment) { - $requireJsConfig['urlArgs'] = 'bust=' . $GLOBALS['EXEC_TIME']; + $requireJsConfig['public']['urlArgs'] = 'bust=' . $GLOBALS['EXEC_TIME']; } else { - $requireJsConfig['urlArgs'] = 'bust=' . GeneralUtility::hmac(TYPO3_version . Environment::getProjectPath()); + $requireJsConfig['public']['urlArgs'] = 'bust=' . GeneralUtility::hmac(TYPO3_version . Environment::getProjectPath()); } $corePath = ExtensionManagementUtility::extPath('core', 'Resources/Public/JavaScript/Contrib/'); $corePath = PathUtility::getAbsoluteWebPath($corePath); // first, load all paths for the namespaces, and configure contrib libs. - $requireJsConfig['paths'] = [ + $requireJsConfig['public']['paths'] = [ 'jquery' => $corePath . '/jquery/jquery', 'jquery-ui' => $corePath . 'jquery-ui', 'datatables' => $corePath . 'jquery.dataTables', @@ -1455,16 +1506,32 @@ protected function computeRequireJsConfig($isDevelopment, array $loadedExtension 'jquery/autocomplete' => $corePath . 'jquery.autocomplete', 'd3' => $corePath . 'd3/d3' ]; - $requireJsConfig['waitSeconds'] = 30; + $requireJsConfig['public']['waitSeconds'] = 30; + $requireJsConfig['public']['typo3BaseUrl'] = false; + $publicPackageNames = ['core', 'frontend', 'backend']; foreach ($loadedExtensions as $packageName) { - $fullJsPath = 'EXT:' . $packageName . '/Resources/Public/JavaScript/'; - $fullJsPath = GeneralUtility::getFileAbsFileName($fullJsPath); - $fullJsPath = PathUtility::getAbsoluteWebPath($fullJsPath); + $jsPath = 'EXT:' . $packageName . '/Resources/Public/JavaScript/'; + $absoluteJsPath = GeneralUtility::getFileAbsFileName($jsPath); + $fullJsPath = PathUtility::getAbsoluteWebPath($absoluteJsPath); $fullJsPath = rtrim($fullJsPath, '/'); - if ($fullJsPath) { - $requireJsConfig['paths']['TYPO3/CMS/' . GeneralUtility::underscoredToUpperCamelCase($packageName)] = $fullJsPath; + if (!empty($fullJsPath) && file_exists($absoluteJsPath)) { + $type = in_array($packageName, $publicPackageNames, true) ? 'public' : 'internal'; + $requireJsConfig[$type]['paths']['TYPO3/CMS/' . GeneralUtility::underscoredToUpperCamelCase($packageName)] = $fullJsPath; } } + // sanitize module names in internal 'paths' + $internalPathModuleNames = array_keys($requireJsConfig['internal']['paths'] ?? []); + $sanitizedInternalPathModuleNames = array_map( + function ($moduleName) { + // trim spaces and slashes & add ending slash + return trim($moduleName, ' /') . '/'; + }, + $internalPathModuleNames + ); + $requireJsConfig['internalNames'] = array_combine( + $sanitizedInternalPathModuleNames, + $internalPathModuleNames + ); // check if additional AMD modules need to be loaded if a single AMD module is initialized if (is_array($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['RequireJS']['postInitializationModules'] ?? false)) { @@ -1498,6 +1565,72 @@ public function addRequireJsConfiguration(array $configuration) \TYPO3\CMS\Core\Utility\ArrayUtility::mergeRecursiveWithOverrule($this->requireJsConfig, $configuration); } + /** + * Generates RequireJS loader HTML markup. + * + * @return string + * @throws \TYPO3\CMS\Backend\Routing\Exception\RouteNotFoundException + */ + protected function getRequireJsLoader(): string + { + $html = ''; + $backendRequest = TYPO3_REQUESTTYPE & TYPO3_REQUESTTYPE_BE; + $backendUserLoggedIn = !empty($GLOBALS['BE_USER']->user['uid']); + + // no backend request - basically frontend + if (!$backendRequest) { + $requireJsConfig = $this->getRequireJsConfig(static::REQUIREJS_SCOPE_CONFIG); + $requireJsConfig['typo3BaseUrl'] = GeneralUtility::getIndpEnv('TYPO3_SITE_PATH') . '?eID=requirejs'; + // backend request, but no backend user logged in + } elseif (!$backendUserLoggedIn) { + $uriBuilder = GeneralUtility::makeInstance(UriBuilder::class); + $requireJsConfig = $this->getRequireJsConfig(static::REQUIREJS_SCOPE_CONFIG); + $requireJsConfig['typo3BaseUrl'] = (string)$uriBuilder->buildUriFromRoute('ajax_core_requirejs'); + // backend request, having backend user logged in + } else { + $requireJsConfig = array_replace_recursive( + $this->publicRequireJsConfig, + $this->requireJsConfig + ); + } + + // add (probably filtered) RequireJS configuration + $html .= GeneralUtility::wrapJS('var require = ' . json_encode($requireJsConfig)) . LF; + // directly after that, include the require.js file + $html .= '' . LF; + + if (!empty($requireJsConfig['typo3BaseUrl'])) { + $html .= '' . LF; + } + + return $html; + } + + /** + * @param array $array + * @param string[] $keys + * @param bool $keep + * @return array + */ + protected function filterArrayKeys(array $array, array $keys, bool $keep = true): array + { + return array_filter( + $array, + function (string $key) use ($keys, $keep) { + return in_array($key, $keys, true) === $keep; + }, + ARRAY_FILTER_USE_KEY + ); + } + /** * includes an AMD-compatible JS file by resolving the ModuleName, and then requires the file via a requireJS request, * additionally allowing to execute JavaScript code afterwards @@ -1519,7 +1652,13 @@ public function loadRequireJsModule($mainModuleName, $callBackFunction = null) $inlineCodeKey = $mainModuleName; // make sure requireJS is initialized $this->loadRequireJs(); - + // move internal module path definition to public module definition + // (since loading a module ends up disclosing the existence anyway) + $baseModuleName = $this->findRequireJsBaseModuleName($mainModuleName); + if ($baseModuleName !== null && isset($this->requireJsConfig['paths'][$baseModuleName])) { + $this->publicRequireJsConfig['paths'][$baseModuleName] = $this->requireJsConfig['paths'][$baseModuleName]; + unset($this->requireJsConfig['paths'][$baseModuleName]); + } // execute the main module, and load a possible callback function $javaScriptCode = 'require(["' . $mainModuleName . '"]'; if ($callBackFunction !== null) { @@ -1530,6 +1669,24 @@ public function loadRequireJsModule($mainModuleName, $callBackFunction = null) $this->addJsInlineCode('RequireJS-Module-' . $inlineCodeKey, $javaScriptCode); } + /** + * Determines requireJS base module name (if defined). + * + * @param string $moduleName + * @return string|null + */ + protected function findRequireJsBaseModuleName(string $moduleName) + { + // trim spaces and slashes & add ending slash + $sanitizedModuleName = trim($moduleName, ' /') . '/'; + foreach ($this->internalRequireJsPathModuleNames as $sanitizedBaseModuleName => $baseModuleName) { + if (strpos($sanitizedModuleName, $sanitizedBaseModuleName) === 0) { + return $baseModuleName; + } + } + return null; + } + /** * Adds Javascript Inline Label. This will occur in TYPO3.lang - object * The label can be used in scripts with TYPO3.lang. @@ -1938,10 +2095,7 @@ protected function renderMainJavaScriptLibraries() // Include RequireJS if ($this->addRequireJs) { - // load the paths of the requireJS configuration - $out .= GeneralUtility::wrapJS('var require = ' . json_encode($this->requireJsConfig)) . LF; - // directly after that, include the require.js file - $out .= '' . LF; + $out .= $this->getRequireJsLoader(); } // Include jQuery Core for each namespace, depending on the version and source @@ -1953,7 +2107,8 @@ protected function renderMainJavaScriptLibraries() $this->loadJavaScriptLanguageStrings(); if (TYPO3_MODE === 'BE') { - $this->addAjaxUrlsToInlineSettings(); + $noBackendUserLoggedIn = empty($GLOBALS['BE_USER']->user['uid']); + $this->addAjaxUrlsToInlineSettings($noBackendUserLoggedIn); } $inlineSettings = ''; $languageLabels = $this->parseLanguageLabelsForJavaScript(); @@ -2031,8 +2186,10 @@ protected function convertCharsetRecursivelyToUtf8(&$data, string $fromCharset) /** * Make URLs to all backend ajax handlers available as inline setting. + * + * @param bool $publicRoutesOnly */ - protected function addAjaxUrlsToInlineSettings() + protected function addAjaxUrlsToInlineSettings(bool $publicRoutesOnly = false) { $ajaxUrls = []; // Add the ajax-based routes @@ -2042,6 +2199,9 @@ protected function addAjaxUrlsToInlineSettings() $router = GeneralUtility::makeInstance(Router::class); $routes = $router->getRoutes(); foreach ($routes as $routeIdentifier => $route) { + if ($publicRoutesOnly && $route->getOption('access') !== 'public') { + continue; + } if ($route->getOption('ajax')) { $uri = (string)$uriBuilder->buildUriFromRoute($routeIdentifier); // use the shortened value in order to use this in JavaScript diff --git a/Configuration/Backend/AjaxRoutes.php b/Configuration/Backend/AjaxRoutes.php new file mode 100644 index 0000000000..7a8faaecea --- /dev/null +++ b/Configuration/Backend/AjaxRoutes.php @@ -0,0 +1,14 @@ + [ + 'path' => '/core/requirejs', + 'access' => 'public', + 'target' => RequireJsController::class . '::retrieveConfiguration', + ], +]; diff --git a/Resources/Public/JavaScript/requirejs-loader.js b/Resources/Public/JavaScript/requirejs-loader.js new file mode 100644 index 0000000000..e0a000af30 --- /dev/null +++ b/Resources/Public/JavaScript/requirejs-loader.js @@ -0,0 +1,134 @@ +(function(req) { + /** + * Determines whether moduleName is configured in requirejs paths + * (this code was taken from RequireJS context.nameToUrl). + * + * @see context.nameToUrl + * @see https://github.com/requirejs/requirejs/blob/2.3.3/require.js#L1650-L1670 + * + * @param {Object} config the require context to find state. + * @param {String} moduleName the name of the module. + * @return {boolean} + */ + var inPath = function(config, moduleName) { + var i, parentModule, parentPath; + var paths = config.paths; + var syms = moduleName.split('/'); + //For each module name segment, see if there is a path + //registered for it. Start with most specific name + //and work up from it. + for (i = syms.length; i > 0; i -= 1) { + parentModule = syms.slice(0, i).join('/'); + parentPath = paths[parentModule]; + if (parentPath) { + return true; + } + } + return false; + }; + + /** + * @return {XMLHttpRequest} + */ + var createXhr = function() { + if (typeof XMLHttpRequest !== 'undefined') { + return new XMLHttpRequest(); + } else { + return new ActiveXObject('Microsoft.XMLHTTP'); + } + }; + + /** + * Fetches RequireJS configuration from server via XHR call. + * + * @param {object} config + * @param {string} name + * @param {function} success + * @param {function} error + */ + var fetchConfiguration = function(config, name, success, error) { + // cannot use jQuery here which would be loaded via RequireJS... + var xhr = createXhr(); + xhr.onreadystatechange = function() { + if (this.readyState !== 4) { + return; + } + try { + if (this.status === 200) { + success(JSON.parse(xhr.responseText)); + } else { + error(this.status, xhr.statusText); + } + } catch (error) { + error(this.status, error); + } + }; + xhr.open('GET', config.typo3BaseUrl + '&name=' + encodeURIComponent(name)); + xhr.send(); + }; + + /** + * Adds aspects to RequireJS configuration keys paths and packages. + * + * @param {object} config + * @param {string} data + * @param {object} context + */ + var addToConfiguration = function(config, data, context) { + if (data.shim && data.shim instanceof Object) { + if (typeof config.shim === 'undefined') { + config.shim = {}; + } + Object.keys(data.shim).forEach(function(moduleName) { + config.shim[moduleName] = data.shim[moduleName]; + }); + } + if (data.paths && data.paths instanceof Object) { + if (typeof config.paths === 'undefined') { + config.paths = {}; + } + Object.keys(data.paths).forEach(function(moduleName) { + config.paths[moduleName] = data.paths[moduleName]; + }); + } + if (data.packages && data.packages instanceof Array) { + if (typeof config.packages === 'undefined') { + config.packages = []; + } + data.packages.forEach(function (packageName) { + config.packages.push(packageName); + }); + } + context.configure(config); + }; + + // keep reference to RequireJS default loader + var originalLoad = req.load; + + /** + * Does the request to load a module for the browser case. + * Make this a separate function to allow other environments + * to override it. + * + * @param {Object} context the require context to find state. + * @param {String} name the name of the module. + * @param {Object} url the URL to the module. + */ + req.load = function(context, name, url) { + if (inPath(context.config, name)) { + return originalLoad.call(req, context, name, url); + } + + fetchConfiguration( + context.config, + name, + function(data) { + addToConfiguration(context.config, data, context); + url = context.nameToUrl(name); + // result cannot be returned since nested in two asynchronous calls + originalLoad.call(req, context, name, url); + }, + function() {} + ); + }; +})(requirejs); diff --git a/ext_localconf.php b/ext_localconf.php index d441fc77f8..3227779f19 100644 --- a/ext_localconf.php +++ b/ext_localconf.php @@ -89,6 +89,7 @@ unset($signalSlotDispatcher); $GLOBALS['TYPO3_CONF_VARS']['FE']['eID_include']['dumpFile'] = \TYPO3\CMS\Core\Controller\FileDumpController::class . '::dumpAction'; +$GLOBALS['TYPO3_CONF_VARS']['FE']['eID_include']['requirejs'] = \TYPO3\CMS\Core\Controller\RequireJsController::class . '::retrievePath'; /** @var \TYPO3\CMS\Core\Resource\Rendering\RendererRegistry $rendererRegistry */ $rendererRegistry = \TYPO3\CMS\Core\Resource\Rendering\RendererRegistry::getInstance();