Files
nextcloud-server-mirror/lib/private/TemplateLayout.php
Ferdinand Thiessen a0fb896c36 build(core): move the remaining core bundles to the vite build
The unified search, the legacy unified search and the menus of the public
pages are built by the Vue 3 toolchain as well, so core is no longer part
of the legacy build: its entries, lint and test scope are removed
there, and the list that split core between the two toolchains is gone.
The symlink stays, as legacy apps still import from core. All core specs
run with the Vue 3 test setup.

Every place that loads one of these scripts now also loads the stylesheet
of the same name.

Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
Assisted-by: ClaudeCode:claude-opus-5-5
2026-10-06 16:46:25 +02:00

429 lines
15 KiB
PHP

<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-FileCopyrightText: 2016 ownCloud, Inc.
* SPDX-License-Identifier: AGPL-3.0-only
*/
namespace OC;
use bantu\IniGetWrapper\IniGetWrapper;
use OC\App\AppStore\AppStoreLinkVisibility;
use OC\AppFramework\Http\Request;
use OC\Authentication\Token\IProvider;
use OC\Core\AppInfo\Application;
use OC\Core\AppInfo\ConfigLexicon;
use OC\Files\FilenameValidator;
use OC\Search\SearchQuery;
use OC\Security\CSP\ContentSecurityPolicyNonceManager;
use OC\Template\CSSResourceLocator;
use OC\Template\JSConfigHelper;
use OC\Template\JSResourceLocator;
use OCA\Theming\Service\ThemesService;
use OCP\App\IAppManager;
use OCP\AppFramework\Http\TemplateResponse;
use OCP\Defaults;
use OCP\IAppConfig;
use OCP\IConfig;
use OCP\IGroupManager;
use OCP\IInitialStateService;
use OCP\INavigationManager;
use OCP\IRequest;
use OCP\ISession;
use OCP\IURLGenerator;
use OCP\IUserSession;
use OCP\L10N\IFactory;
use OCP\Server;
use OCP\ServerVersion;
use OCP\Support\Subscription\IRegistry;
use OCP\Template\ITemplate;
use OCP\Template\ITemplateManager;
use OCP\Util;
class TemplateLayout {
private string $versionHash = '';
/** @var string[] */
private array $cacheBusterCache = [];
public ?CSSResourceLocator $cssLocator = null;
public ?JSResourceLocator $jsLocator = null;
public function __construct(
private IConfig $config,
private readonly IAppConfig $appConfig,
private IAppManager $appManager,
private InitialStateService $initialState,
private INavigationManager $navigationManager,
private ITemplateManager $templateManager,
private ServerVersion $serverVersion,
private IRequest $request,
) {
}
public function getPageTemplate(string $renderAs, string $appId): ITemplate {
// Add fallback theming variables if not rendered as user
if ($renderAs !== TemplateResponse::RENDER_AS_USER) {
// TODO cache generated default theme if enabled for fallback if server is erroring ?
Util::addStyle('theming', 'default');
}
// Decide which page we show
switch ($renderAs) {
case TemplateResponse::RENDER_AS_USER:
$page = $this->templateManager->getTemplate('core', 'layout.user');
$pathInfo = $this->request->getPathInfo();
if ($pathInfo !== false && str_starts_with($pathInfo, '/settings/')) {
$page->assign('bodyid', 'body-settings');
} else {
$page->assign('bodyid', 'body-user');
}
$this->initialState->provideInitialState('core', 'active-app', $this->navigationManager->getActiveEntry());
$this->initialState->provideInitialState('core', 'apps', array_values($this->navigationManager->getAll()));
$this->initialState->provideInitialState('core', 'navigationActions', array_values($this->navigationManager->getAll(INavigationManager::TYPE_ACTION)));
$this->initialState->provideInitialState('core', 'appStoreLinkShown', Server::get(AppStoreLinkVisibility::class)->isShownToUsers());
$this->initialState->provideInitialState('unified-search', 'min-search-length', $this->appConfig->getValueInt(Application::APP_ID, ConfigLexicon::UNIFIED_SEARCH_MIN_SEARCH_LENGTH));
if ($this->config->getSystemValueBool('unified_search.enabled', false) || !$this->config->getSystemValueBool('enable_non-accessible_features', true)) {
$this->initialState->provideInitialState('unified-search', 'limit-default', (int)$this->config->getAppValue('core', 'unified-search.limit-default', (string)SearchQuery::LIMIT_DEFAULT));
$this->initialState->provideInitialState('unified-search', 'live-search', $this->config->getAppValue('core', 'unified-search.live-search', 'yes') === 'yes');
Util::addScript('core', 'legacy-unified-search', 'core');
Util::addStyle('core', 'legacy-unified-search');
} else {
Util::addScript('core', 'unified-search', 'core');
Util::addStyle('core', 'unified-search');
}
// Set logo link target
$logoUrl = $this->config->getSystemValueString('logo_url', '');
$page->assign('logoUrl', $logoUrl);
// Set default entry name
$defaultEntryId = $this->navigationManager->getDefaultEntryIdForUser();
$defaultEntry = $this->navigationManager->get($defaultEntryId);
$page->assign('defaultAppName', $defaultEntry['name'] ?? '');
// Add navigation entry
$page->assign('application', '');
$page->assign('appid', $appId);
$navigation = $this->navigationManager->getAll();
$page->assign('navigation', $navigation);
$settingsNavigation = $this->navigationManager->getAll('settings');
$this->initialState->provideInitialState('core', 'settingsNavEntries', $settingsNavigation);
foreach ($navigation as $entry) {
if ($entry['active']) {
$page->assign('application', $entry['name']);
break;
}
}
foreach ($settingsNavigation as $entry) {
if ($entry['active']) {
$page->assign('application', $entry['name']);
break;
}
}
$user = Server::get(IUserSession::class)->getUser();
if ($user === null) {
$page->assign('user_uid', false);
$page->assign('user_displayname', false);
$page->assign('userAvatarSet', false);
$page->assign('userStatus', false);
} else {
$page->assign('user_uid', $user->getUID());
$page->assign('user_displayname', $user->getDisplayName());
$page->assign('userAvatarSet', true);
$page->assign('userAvatarVersion', $this->config->getUserValue($user->getUID(), 'avatar', 'version', 0));
}
break;
case TemplateResponse::RENDER_AS_ERROR:
$page = $this->templateManager->getTemplate('core', 'layout.guest', '', false);
$page->assign('bodyid', 'body-login');
$page->assign('user_displayname', '');
$page->assign('user_uid', '');
break;
case TemplateResponse::RENDER_AS_GUEST:
$page = $this->templateManager->getTemplate('core', 'layout.guest');
Util::addStyle('guest');
$page->assign('bodyid', 'body-login');
$userDisplayName = false;
$user = Server::get(IUserSession::class)->getUser();
if ($user) {
$userDisplayName = $user->getDisplayName();
}
$page->assign('user_displayname', $userDisplayName);
$page->assign('user_uid', \OC_User::getUser());
break;
case TemplateResponse::RENDER_AS_PUBLIC:
$page = $this->templateManager->getTemplate('core', 'layout.public');
$page->assign('appid', $appId);
$page->assign('bodyid', 'body-public');
$currentAppData = $this->navigationManager->get($appId);
$this->initialState->provideInitialState('core', 'apps', $currentAppData === null ? [] : [$currentAppData]);
// Set logo link target
$logoUrl = $this->config->getSystemValueString('logo_url', '');
$page->assign('logoUrl', $logoUrl);
$subscription = Server::get(IRegistry::class);
$showSimpleSignup = $this->config->getSystemValueBool('simpleSignUpLink.shown', true);
if ($showSimpleSignup && $subscription->delegateHasValidSubscription()) {
$showSimpleSignup = false;
}
$defaultSignUpLink = 'https://nextcloud.com/signup/';
$signUpLink = $this->config->getSystemValueString('registration_link', $defaultSignUpLink);
if ($signUpLink !== $defaultSignUpLink) {
$showSimpleSignup = true;
}
if ($this->appManager->isEnabledForUser('registration')) {
$urlGenerator = Server::get(IURLGenerator::class);
$signUpLink = $urlGenerator->getAbsoluteURL('/index.php/apps/registration/');
}
$page->assign('showSimpleSignUpLink', $showSimpleSignup);
$page->assign('signUpLink', $signUpLink);
break;
default:
$page = $this->templateManager->getTemplate('core', 'layout.base');
break;
}
// Send the language, locale, and direction to our layouts
$l10nFactory = Server::get(IFactory::class);
$lang = $l10nFactory->findLanguage();
$locale = $l10nFactory->findLocale($lang);
$direction = $l10nFactory->getLanguageDirection($lang);
$lang = str_replace('_', '-', $lang);
$page->assign('language', $lang);
$page->assign('locale', $locale);
$page->assign('direction', $direction);
// Set body data-theme
try {
$themesService = Server::get(ThemesService::class);
} catch (\Exception) {
$themesService = null;
}
$page->assign('enabledThemes', $themesService?->getEnabledThemes() ?? []);
if ($this->config->getSystemValueBool('installed', false)) {
if (empty($this->versionHash)) {
$v = $this->appManager->getAppInstalledVersions(true);
$v['core'] = implode('.', $this->serverVersion->getVersion());
$this->versionHash = substr(md5(implode(',', $v)), 0, 8);
}
} else {
$this->versionHash = md5('not installed');
}
// Add the js files
$jsFiles = $this->findJavascriptFiles(Util::getScripts());
$page->assign('jsfiles', []);
if ($this->config->getSystemValueBool('installed', false) && $renderAs !== TemplateResponse::RENDER_AS_ERROR) {
// this is on purpose outside of the if statement below so that the initial state is prefilled (done in the getConfig() call)
// see https://github.com/nextcloud/server/pull/22636 for details
$jsConfigHelper = new JSConfigHelper(
$this->serverVersion,
Util::getL10N('lib'),
Server::get(Defaults::class),
$this->appManager,
Server::get(ISession::class),
Server::get(IUserSession::class)->getUser(),
$this->config,
$this->appConfig,
Server::get(IGroupManager::class),
Server::get(IniGetWrapper::class),
Server::get(IURLGenerator::class),
Server::get(CapabilitiesManager::class),
Server::get(IInitialStateService::class),
Server::get(IProvider::class),
Server::get(FilenameValidator::class),
);
$config = $jsConfigHelper->getConfig();
if (Server::get(ContentSecurityPolicyNonceManager::class)->browserSupportsCspV3()) {
$page->assign('inline_ocjs', $config);
} else {
$page->append('jsfiles', Server::get(IURLGenerator::class)->linkToRoute('core.OCJS.getConfig', ['v' => $this->versionHash]));
}
}
foreach ($jsFiles as $info) {
$web = $info[1];
$file = $info[2];
$page->append('jsfiles', $web . '/' . $file . $this->getVersionHashSuffix());
}
try {
$pathInfo = $this->request->getPathInfo();
} catch (\Exception $e) {
$pathInfo = '';
}
// Do not initialise scss appdata until we have a fully installed instance
// Do not load scss for update, errors, installation or login page
if ($this->config->getSystemValueBool('installed', false)
&& !Util::needUpgrade()
&& $pathInfo !== ''
&& !preg_match('/^\/login/', $pathInfo)
&& $renderAs !== TemplateResponse::RENDER_AS_ERROR
) {
$cssFiles = $this->findStylesheetFiles(\OC_Util::$styles);
} else {
// If we ignore the scss compiler,
// we need to load the guest css fallback
Util::addStyle('guest');
$cssFiles = $this->findStylesheetFiles(\OC_Util::$styles);
}
$page->assign('cssfiles', []);
$page->assign('printcssfiles', []);
$this->initialState->provideInitialState('core', 'versionHash', $this->versionHash);
foreach ($cssFiles as $info) {
$web = $info[1];
$file = $info[2];
if (str_ends_with($file, 'print.css')) {
$page->append('printcssfiles', $web . '/' . $file . $this->getVersionHashSuffix());
} else {
$suffix = $this->getVersionHashSuffix($web, $file);
if (!str_contains($file, '?v=')) {
$page->append('cssfiles', $web . '/' . $file . $suffix);
} else {
$page->append('cssfiles', $web . '/' . $file . '-' . substr($suffix, 3));
}
}
}
if ($this->request->isUserAgent([Request::USER_AGENT_CLIENT_IOS, Request::USER_AGENT_SAFARI, Request::USER_AGENT_SAFARI_MOBILE])) {
// Prevent auto zoom with iOS but still allow user zoom
// On chrome (and others) this does not work (will also disable user zoom)
$page->assign('viewport_maximum_scale', '1.0');
}
$page->assign('initialStates', $this->initialState->getInitialStates());
$page->assign('id-app-content', $renderAs === TemplateResponse::RENDER_AS_USER ? '#app-content' : '#content');
$page->assign('id-app-navigation', $renderAs === TemplateResponse::RENDER_AS_USER ? '#app-navigation' : null);
return $page;
}
protected function getVersionHashSuffix(string $path = '', string $file = ''): string {
if ($this->config->getSystemValueBool('debug', false)) {
// allows chrome workspace mapping in debug mode
return '';
}
if ($this->config->getSystemValueBool('installed', false) === false) {
// if not installed just return the version hash
return '?v=' . $this->versionHash;
}
$hash = false;
// Try the web-root first
if ($path !== '') {
$hash = $this->getVersionHashByPath($path);
}
// If not found try the file
if ($hash === false && $file !== '') {
$hash = $this->getVersionHashByPath($file);
}
// As a last resort we use the server version hash
if ($hash === false) {
$hash = $this->versionHash;
}
// The theming app is force-enabled thus the cache buster is always available
$themingSuffix = '-' . $this->config->getAppValue('theming', 'cachebuster', '0');
return '?v=' . $hash . $themingSuffix;
}
private function getVersionHashByPath(string $path): string|false {
if (array_key_exists($path, $this->cacheBusterCache) === false) {
// Not yet cached, so lets find the cache buster string
$appId = $this->getAppNamefromPath($path);
if ($appId === false) {
// No app Id could be guessed
return false;
}
if ($appId === 'core') {
// core is not a real app but the server itself
$hash = $this->versionHash;
} else {
$appVersion = $this->appManager->getAppVersion($appId);
// For shipped apps the app version is not a single source of truth, we rather also need to consider the Nextcloud version
if ($this->appManager->isShipped($appId)) {
$appVersion .= '-' . $this->versionHash;
}
$hash = substr(md5($appVersion), 0, 8);
}
$this->cacheBusterCache[$path] = $hash;
}
return $this->cacheBusterCache[$path];
}
private function findStylesheetFiles(array $styles): array {
if ($this->cssLocator === null) {
$this->cssLocator = Server::get(CSSResourceLocator::class);
}
$this->cssLocator->find($styles);
return $this->cssLocator->getResources();
}
public function getAppNamefromPath(string $path): string|false {
if ($path !== '') {
$pathParts = explode('/', $path);
if ($pathParts[0] === 'dist') {
// Return the part before the dash in the file name
return explode('-', \end($pathParts), 2)[0];
} elseif ($pathParts[0] === 'css') {
// This is a scss request
return $pathParts[1];
} elseif ($pathParts[0] === 'core') {
return 'core';
}
return end($pathParts);
}
return false;
}
private function findJavascriptFiles(array $scripts): array {
if ($this->jsLocator === null) {
$this->jsLocator = Server::get(JSResourceLocator::class);
}
$this->jsLocator->find($scripts);
return $this->jsLocator->getResources();
}
/**
* Converts the absolute file path to a relative path from \OC::$SERVERROOT
* @param string $filePath Absolute path
* @return string Relative path
* @throws \Exception If $filePath is not under \OC::$SERVERROOT
*/
public static function convertToRelativePath(string $filePath) {
$relativePath = explode(\OC::$SERVERROOT, $filePath);
if (count($relativePath) !== 2) {
throw new \Exception('$filePath is not under the \OC::$SERVERROOT');
}
return $relativePath[1];
}
}