diff options
Diffstat (limited to 'lib/private/Template')
-rw-r--r-- | lib/private/Template/Base.php | 151 | ||||
-rw-r--r-- | lib/private/Template/CSSResourceLocator.php | 83 | ||||
-rw-r--r-- | lib/private/Template/JSCombiner.php | 238 | ||||
-rw-r--r-- | lib/private/Template/JSConfigHelper.php | 310 | ||||
-rw-r--r-- | lib/private/Template/JSResourceLocator.php | 133 | ||||
-rwxr-xr-x | lib/private/Template/ResourceLocator.php | 174 | ||||
-rw-r--r-- | lib/private/Template/ResourceNotFoundException.php | 30 | ||||
-rw-r--r-- | lib/private/Template/Template.php | 159 | ||||
-rw-r--r-- | lib/private/Template/TemplateFileLocator.php | 41 | ||||
-rw-r--r-- | lib/private/Template/TemplateManager.php | 169 | ||||
-rw-r--r-- | lib/private/Template/functions.php | 299 |
11 files changed, 1787 insertions, 0 deletions
diff --git a/lib/private/Template/Base.php b/lib/private/Template/Base.php new file mode 100644 index 00000000000..a13e6703960 --- /dev/null +++ b/lib/private/Template/Base.php @@ -0,0 +1,151 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OC\Template; + +use OCP\Defaults; + +class Base { + private $template; // The template + private array $vars = []; + + /** @var \OCP\IL10N */ + private $l10n; + + /** @var Defaults */ + private $theme; + + /** + * @param string $template + * @param string $requestToken + * @param \OCP\IL10N $l10n + * @param string $cspNonce + * @param Defaults $theme + */ + public function __construct($template, $requestToken, $l10n, $theme, $cspNonce) { + $this->vars = [ + 'cspNonce' => $cspNonce, + 'requesttoken' => $requestToken, + ]; + $this->l10n = $l10n; + $this->template = $template; + $this->theme = $theme; + } + + /** + * @param string $serverRoot + * @param string|false $app_dir + * @param string $theme + * @param string $app + * @return string[] + */ + protected function getAppTemplateDirs($theme, $app, $serverRoot, $app_dir) { + // Check if the app is in the app folder or in the root + if ($app_dir !== false && file_exists($app_dir . '/templates/')) { + return [ + $serverRoot . '/themes/' . $theme . '/apps/' . $app . '/templates/', + $app_dir . '/templates/', + ]; + } + return [ + $serverRoot . '/themes/' . $theme . '/' . $app . '/templates/', + $serverRoot . '/' . $app . '/templates/', + ]; + } + + /** + * @return string[] + */ + protected function getCoreTemplateDirs(string $theme, string $serverRoot): array { + return [ + $serverRoot . '/themes/' . $theme . '/core/templates/', + $serverRoot . '/core/templates/', + ]; + } + + /** + * Assign variables + * + * This function assigns a variable. It can be accessed via $_[$key] in + * the template. + * + * If the key existed before, it will be overwritten + */ + public function assign(string $key, mixed $value): void { + $this->vars[$key] = $value; + } + + /** + * Appends a variable + * + * This function assigns a variable in an array context. If the key already + * exists, the value will be appended. It can be accessed via + * $_[$key][$position] in the template. + */ + public function append(string $key, mixed $value): void { + if (array_key_exists($key, $this->vars)) { + $this->vars[$key][] = $value; + } else { + $this->vars[$key] = [ $value ]; + } + } + + /** + * Prints the proceeded template + * + * This function proceeds the template and prints its output. + */ + public function printPage(): void { + $data = $this->fetchPage(); + print $data; + } + + /** + * Process the template + * + * This function processes the template. + */ + public function fetchPage(?array $additionalParams = null): string { + return $this->load($this->template, $additionalParams); + } + + /** + * doing the actual work + * + * Includes the template file, fetches its output + */ + protected function load(string $file, ?array $additionalParams = null): string { + // Register the variables + $_ = $this->vars; + $l = $this->l10n; + $theme = $this->theme; + + if (!is_null($additionalParams)) { + $_ = array_merge($additionalParams, $this->vars); + foreach ($_ as $var => $value) { + if (!isset(${$var})) { + ${$var} = $value; + } + } + } + + // Include + ob_start(); + try { + require_once __DIR__ . '/functions.php'; + include $file; + $data = ob_get_contents(); + } catch (\Exception $e) { + @ob_end_clean(); + throw $e; + } + @ob_end_clean(); + + // Return data + return $data; + } +} diff --git a/lib/private/Template/CSSResourceLocator.php b/lib/private/Template/CSSResourceLocator.php new file mode 100644 index 00000000000..b501fd69874 --- /dev/null +++ b/lib/private/Template/CSSResourceLocator.php @@ -0,0 +1,83 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OC\Template; + +use Psr\Log\LoggerInterface; + +class CSSResourceLocator extends ResourceLocator { + public function __construct(LoggerInterface $logger) { + parent::__construct($logger); + } + + /** + * @param string $style + */ + public function doFind($style) { + $app = substr($style, 0, strpos($style, '/')); + if ($this->appendIfExist($this->serverroot, $style . '.css') + || $this->appendIfExist($this->serverroot, 'core/' . $style . '.css') + ) { + return; + } + $style = substr($style, strpos($style, '/') + 1); + $app_path = \OC_App::getAppPath($app); + $app_url = \OC_App::getAppWebPath($app); + + if ($app_path === false && $app_url === false) { + $this->logger->error('Could not find resource {resource} to load', [ + 'resource' => $app . '/' . $style . '.css', + 'app' => 'cssresourceloader', + ]); + return; + } + + // Account for the possibility of having symlinks in app path. Doing + // this here instead of above as an empty argument to realpath gets + // turned into cwd. + $app_path = realpath($app_path); + + $this->append($app_path, $style . '.css', $app_url); + } + + /** + * @param string $style + */ + public function doFindTheme($style) { + $theme_dir = 'themes/' . $this->theme . '/'; + $this->appendIfExist($this->serverroot, $theme_dir . 'apps/' . $style . '.css') + || $this->appendIfExist($this->serverroot, $theme_dir . $style . '.css') + || $this->appendIfExist($this->serverroot, $theme_dir . 'core/' . $style . '.css'); + } + + public function append($root, $file, $webRoot = null, $throw = true, $scss = false) { + if (!$scss) { + parent::append($root, $file, $webRoot, $throw); + } else { + if (!$webRoot) { + $webRoot = $this->findWebRoot($root); + + if ($webRoot === null) { + $webRoot = ''; + $this->logger->error('ResourceLocator can not find a web root (root: {root}, file: {file}, webRoot: {webRoot}, throw: {throw})', [ + 'app' => 'lib', + 'root' => $root, + 'file' => $file, + 'webRoot' => $webRoot, + 'throw' => $throw ? 'true' : 'false' + ]); + + if ($throw && $root === '/') { + throw new ResourceNotFoundException($file, $webRoot); + } + } + } + + $this->resources[] = [$webRoot ?: \OC::$WEBROOT, $webRoot, $file]; + } + } +} diff --git a/lib/private/Template/JSCombiner.php b/lib/private/Template/JSCombiner.php new file mode 100644 index 00000000000..a94f822a448 --- /dev/null +++ b/lib/private/Template/JSCombiner.php @@ -0,0 +1,238 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Template; + +use OC\SystemConfig; +use OCP\Files\IAppData; +use OCP\Files\NotFoundException; +use OCP\Files\NotPermittedException; +use OCP\Files\SimpleFS\ISimpleFolder; +use OCP\ICache; +use OCP\ICacheFactory; +use OCP\IURLGenerator; +use Psr\Log\LoggerInterface; + +class JSCombiner { + /** @var IAppData */ + protected $appData; + + /** @var IURLGenerator */ + protected $urlGenerator; + + /** @var ICache */ + protected $depsCache; + + /** @var SystemConfig */ + protected $config; + + protected LoggerInterface $logger; + + /** @var ICacheFactory */ + private $cacheFactory; + + public function __construct(IAppData $appData, + IURLGenerator $urlGenerator, + ICacheFactory $cacheFactory, + SystemConfig $config, + LoggerInterface $logger) { + $this->appData = $appData; + $this->urlGenerator = $urlGenerator; + $this->cacheFactory = $cacheFactory; + $this->depsCache = $this->cacheFactory->createDistributed('JS-' . md5($this->urlGenerator->getBaseUrl())); + $this->config = $config; + $this->logger = $logger; + } + + /** + * @param string $root + * @param string $file + * @param string $app + * @return bool + */ + public function process($root, $file, $app) { + if ($this->config->getValue('debug') || !$this->config->getValue('installed')) { + return false; + } + + $path = explode('/', $root . '/' . $file); + + $fileName = array_pop($path); + $path = implode('/', $path); + + try { + $folder = $this->appData->getFolder($app); + } catch (NotFoundException $e) { + // creating css appdata folder + $folder = $this->appData->newFolder($app); + } + + if ($this->isCached($fileName, $folder)) { + return true; + } + return $this->cache($path, $fileName, $folder); + } + + /** + * @param string $fileName + * @param ISimpleFolder $folder + * @return bool + */ + protected function isCached($fileName, ISimpleFolder $folder) { + $fileName = str_replace('.json', '.js', $fileName); + + if (!$folder->fileExists($fileName)) { + return false; + } + + $fileName = $fileName . '.deps'; + try { + $deps = $this->depsCache->get($folder->getName() . '-' . $fileName); + $fromCache = true; + if ($deps === null || $deps === '') { + $fromCache = false; + $depFile = $folder->getFile($fileName); + $deps = $depFile->getContent(); + } + + // check again + if ($deps === null || $deps === '') { + $this->logger->info('JSCombiner: deps file empty: ' . $fileName); + return false; + } + + $deps = json_decode($deps, true); + + if ($deps === null) { + return false; + } + + foreach ($deps as $file => $mtime) { + if (!file_exists($file) || filemtime($file) > $mtime) { + return false; + } + } + + if ($fromCache === false) { + $this->depsCache->set($folder->getName() . '-' . $fileName, json_encode($deps)); + } + + return true; + } catch (NotFoundException $e) { + return false; + } + } + + /** + * @param string $path + * @param string $fileName + * @param ISimpleFolder $folder + * @return bool + */ + protected function cache($path, $fileName, ISimpleFolder $folder) { + $deps = []; + $fullPath = $path . '/' . $fileName; + $data = json_decode(file_get_contents($fullPath)); + $deps[$fullPath] = filemtime($fullPath); + + $res = ''; + foreach ($data as $file) { + $filePath = $path . '/' . $file; + + if (is_file($filePath)) { + $res .= file_get_contents($filePath); + $res .= PHP_EOL . PHP_EOL; + $deps[$filePath] = filemtime($filePath); + } + } + + $fileName = str_replace('.json', '.js', $fileName); + try { + $cachedfile = $folder->getFile($fileName); + } catch (NotFoundException $e) { + $cachedfile = $folder->newFile($fileName); + } + + $depFileName = $fileName . '.deps'; + try { + $depFile = $folder->getFile($depFileName); + } catch (NotFoundException $e) { + $depFile = $folder->newFile($depFileName); + } + + try { + $gzipFile = $folder->getFile($fileName . '.gzip'); # Safari doesn't like .gz + } catch (NotFoundException $e) { + $gzipFile = $folder->newFile($fileName . '.gzip'); # Safari doesn't like .gz + } + + try { + $cachedfile->putContent($res); + $deps = json_encode($deps); + $depFile->putContent($deps); + $this->depsCache->set($folder->getName() . '-' . $depFileName, $deps); + $gzipFile->putContent(gzencode($res, 9)); + $this->logger->debug('JSCombiner: successfully cached: ' . $fileName); + return true; + } catch (NotPermittedException|NotFoundException $e) { + $this->logger->error('JSCombiner: unable to cache: ' . $fileName); + return false; + } + } + + /** + * @param string $appName + * @param string $fileName + * @return string + */ + public function getCachedJS($appName, $fileName) { + $tmpfileLoc = explode('/', $fileName); + $fileName = array_pop($tmpfileLoc); + $fileName = str_replace('.json', '.js', $fileName); + + return substr($this->urlGenerator->linkToRoute('core.Js.getJs', ['fileName' => $fileName, 'appName' => $appName]), strlen(\OC::$WEBROOT) + 1); + } + + /** + * @param string $root + * @param string $file + * @return string[] + */ + public function getContent($root, $file) { + /** @var array $data */ + $data = json_decode(file_get_contents($root . '/' . $file)); + if (!is_array($data)) { + return []; + } + + $path = explode('/', $file); + array_pop($path); + $path = implode('/', $path); + + $result = []; + foreach ($data as $f) { + $result[] = $path . '/' . $f; + } + + return $result; + } + + + /** + * Clear cache with combined javascript files + * + * @throws NotFoundException + */ + public function resetCache() { + $this->cacheFactory->createDistributed('JS-')->clear(); + $appDirectory = $this->appData->getDirectoryListing(); + foreach ($appDirectory as $folder) { + foreach ($folder->getDirectoryListing() as $file) { + $file->delete(); + } + } + } +} diff --git a/lib/private/Template/JSConfigHelper.php b/lib/private/Template/JSConfigHelper.php new file mode 100644 index 00000000000..044fa8147a0 --- /dev/null +++ b/lib/private/Template/JSConfigHelper.php @@ -0,0 +1,310 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Template; + +use bantu\IniGetWrapper\IniGetWrapper; +use OC\Authentication\Token\IProvider; +use OC\CapabilitiesManager; +use OC\Core\AppInfo\ConfigLexicon; +use OC\Files\FilenameValidator; +use OC\Share\Share; +use OCA\Provisioning_API\Controller\AUserDataOCSController; +use OCP\App\AppPathNotFoundException; +use OCP\App\IAppManager; +use OCP\Authentication\Exceptions\ExpiredTokenException; +use OCP\Authentication\Exceptions\InvalidTokenException; +use OCP\Authentication\Exceptions\WipeTokenException; +use OCP\Authentication\Token\IToken; +use OCP\Constants; +use OCP\Defaults; +use OCP\Files\FileInfo; +use OCP\IAppConfig; +use OCP\IConfig; +use OCP\IGroupManager; +use OCP\IInitialStateService; +use OCP\IL10N; +use OCP\ILogger; +use OCP\ISession; +use OCP\IURLGenerator; +use OCP\IUser; +use OCP\Server; +use OCP\ServerVersion; +use OCP\Session\Exceptions\SessionNotAvailableException; +use OCP\Share\IManager as IShareManager; +use OCP\User\Backend\IPasswordConfirmationBackend; +use OCP\Util; + +class JSConfigHelper { + + /** @var array user back-ends excluded from password verification */ + private $excludedUserBackEnds = ['user_saml' => true, 'user_globalsiteselector' => true]; + + public function __construct( + protected ServerVersion $serverVersion, + protected IL10N $l, + protected Defaults $defaults, + protected IAppManager $appManager, + protected ISession $session, + protected ?IUser $currentUser, + protected IConfig $config, + protected readonly IAppConfig $appConfig, + protected IGroupManager $groupManager, + protected IniGetWrapper $iniWrapper, + protected IURLGenerator $urlGenerator, + protected CapabilitiesManager $capabilitiesManager, + protected IInitialStateService $initialStateService, + protected IProvider $tokenProvider, + protected FilenameValidator $filenameValidator, + ) { + } + + public function getConfig(): string { + $userBackendAllowsPasswordConfirmation = true; + if ($this->currentUser !== null) { + $uid = $this->currentUser->getUID(); + + $backend = $this->currentUser->getBackend(); + if ($backend instanceof IPasswordConfirmationBackend) { + $userBackendAllowsPasswordConfirmation = $backend->canConfirmPassword($uid) && $this->canUserValidatePassword(); + } elseif (isset($this->excludedUserBackEnds[$this->currentUser->getBackendClassName()])) { + $userBackendAllowsPasswordConfirmation = false; + } else { + $userBackendAllowsPasswordConfirmation = $this->canUserValidatePassword(); + } + } else { + $uid = null; + } + + // Get the config + $apps_paths = []; + + if ($this->currentUser === null) { + $apps = $this->appManager->getEnabledApps(); + } else { + $apps = $this->appManager->getEnabledAppsForUser($this->currentUser); + } + + foreach ($apps as $app) { + try { + $apps_paths[$app] = $this->appManager->getAppWebPath($app); + } catch (AppPathNotFoundException $e) { + $apps_paths[$app] = false; + } + } + + $enableLinkPasswordByDefault = $this->appConfig->getValueBool('core', ConfigLexicon::SHARE_LINK_PASSWORD_DEFAULT); + $defaultExpireDateEnabled = $this->config->getAppValue('core', 'shareapi_default_expire_date', 'no') === 'yes'; + $defaultExpireDate = $enforceDefaultExpireDate = null; + if ($defaultExpireDateEnabled) { + $defaultExpireDate = (int)$this->config->getAppValue('core', 'shareapi_expire_after_n_days', '7'); + $enforceDefaultExpireDate = $this->config->getAppValue('core', 'shareapi_enforce_expire_date', 'no') === 'yes'; + } + $outgoingServer2serverShareEnabled = $this->config->getAppValue('files_sharing', 'outgoing_server2server_share_enabled', 'yes') === 'yes'; + + $defaultInternalExpireDateEnabled = $this->config->getAppValue('core', 'shareapi_default_internal_expire_date', 'no') === 'yes'; + $defaultInternalExpireDate = $defaultInternalExpireDateEnforced = null; + if ($defaultInternalExpireDateEnabled) { + $defaultInternalExpireDate = (int)$this->config->getAppValue('core', 'shareapi_internal_expire_after_n_days', '7'); + $defaultInternalExpireDateEnforced = $this->config->getAppValue('core', 'shareapi_enforce_internal_expire_date', 'no') === 'yes'; + } + + $defaultRemoteExpireDateEnabled = $this->config->getAppValue('core', 'shareapi_default_remote_expire_date', 'no') === 'yes'; + $defaultRemoteExpireDate = $defaultRemoteExpireDateEnforced = null; + if ($defaultRemoteExpireDateEnabled) { + $defaultRemoteExpireDate = (int)$this->config->getAppValue('core', 'shareapi_remote_expire_after_n_days', '7'); + $defaultRemoteExpireDateEnforced = $this->config->getAppValue('core', 'shareapi_enforce_remote_expire_date', 'no') === 'yes'; + } + + $countOfDataLocation = 0; + $dataLocation = str_replace(\OC::$SERVERROOT . '/', '', $this->config->getSystemValue('datadirectory', ''), $countOfDataLocation); + if ($countOfDataLocation !== 1 || $uid === null || !$this->groupManager->isAdmin($uid)) { + $dataLocation = false; + } + + if ($this->currentUser instanceof IUser) { + if ($this->canUserValidatePassword()) { + $lastConfirmTimestamp = $this->session->get('last-password-confirm'); + if (!is_int($lastConfirmTimestamp)) { + $lastConfirmTimestamp = 0; + } + } else { + $lastConfirmTimestamp = PHP_INT_MAX; + } + } else { + $lastConfirmTimestamp = 0; + } + + $capabilities = $this->capabilitiesManager->getCapabilities(false, true); + + $userFirstDay = $this->config->getUserValue($uid, 'core', AUserDataOCSController::USER_FIELD_FIRST_DAY_OF_WEEK, null); + $firstDay = (int)($userFirstDay ?? $this->l->l('firstday', null)); + + $config = [ + /** @deprecated 30.0.0 - use files capabilities instead */ + 'blacklist_files_regex' => FileInfo::BLACKLIST_FILES_REGEX, + /** @deprecated 30.0.0 - use files capabilities instead */ + 'forbidden_filename_characters' => $this->filenameValidator->getForbiddenCharacters(), + + 'auto_logout' => $this->config->getSystemValue('auto_logout', false), + 'loglevel' => $this->config->getSystemValue('loglevel_frontend', + $this->config->getSystemValue('loglevel', ILogger::WARN) + ), + 'lost_password_link' => $this->config->getSystemValue('lost_password_link', null), + 'modRewriteWorking' => $this->config->getSystemValue('htaccess.IgnoreFrontController', false) === true || getenv('front_controller_active') === 'true', + 'no_unsupported_browser_warning' => $this->config->getSystemValue('no_unsupported_browser_warning', false), + 'session_keepalive' => $this->config->getSystemValue('session_keepalive', true), + 'session_lifetime' => min($this->config->getSystemValue('session_lifetime', $this->iniWrapper->getNumeric('session.gc_maxlifetime')), $this->iniWrapper->getNumeric('session.gc_maxlifetime')), + 'sharing.maxAutocompleteResults' => max(0, $this->config->getSystemValueInt('sharing.maxAutocompleteResults', Constants::SHARING_MAX_AUTOCOMPLETE_RESULTS_DEFAULT)), + 'sharing.minSearchStringLength' => $this->config->getSystemValueInt('sharing.minSearchStringLength', 0), + 'version' => implode('.', $this->serverVersion->getVersion()), + 'versionstring' => $this->serverVersion->getVersionString(), + 'enable_non-accessible_features' => $this->config->getSystemValueBool('enable_non-accessible_features', true), + ]; + + $shareManager = Server::get(IShareManager::class); + + $array = [ + '_oc_debug' => $this->config->getSystemValue('debug', false) ? 'true' : 'false', + '_oc_isadmin' => $uid !== null && $this->groupManager->isAdmin($uid) ? 'true' : 'false', + 'backendAllowsPasswordConfirmation' => $userBackendAllowsPasswordConfirmation ? 'true' : 'false', + 'oc_dataURL' => is_string($dataLocation) ? '"' . $dataLocation . '"' : 'false', + '_oc_webroot' => '"' . \OC::$WEBROOT . '"', + '_oc_appswebroots' => str_replace('\\/', '/', json_encode($apps_paths)), // Ugly unescape slashes waiting for better solution + 'datepickerFormatDate' => json_encode($this->l->l('jsdate', null)), + 'nc_lastLogin' => $lastConfirmTimestamp, + 'nc_pageLoad' => time(), + 'dayNames' => json_encode([ + $this->l->t('Sunday'), + $this->l->t('Monday'), + $this->l->t('Tuesday'), + $this->l->t('Wednesday'), + $this->l->t('Thursday'), + $this->l->t('Friday'), + $this->l->t('Saturday') + ]), + 'dayNamesShort' => json_encode([ + $this->l->t('Sun.'), + $this->l->t('Mon.'), + $this->l->t('Tue.'), + $this->l->t('Wed.'), + $this->l->t('Thu.'), + $this->l->t('Fri.'), + $this->l->t('Sat.') + ]), + 'dayNamesMin' => json_encode([ + $this->l->t('Su'), + $this->l->t('Mo'), + $this->l->t('Tu'), + $this->l->t('We'), + $this->l->t('Th'), + $this->l->t('Fr'), + $this->l->t('Sa') + ]), + 'monthNames' => json_encode([ + $this->l->t('January'), + $this->l->t('February'), + $this->l->t('March'), + $this->l->t('April'), + $this->l->t('May'), + $this->l->t('June'), + $this->l->t('July'), + $this->l->t('August'), + $this->l->t('September'), + $this->l->t('October'), + $this->l->t('November'), + $this->l->t('December') + ]), + 'monthNamesShort' => json_encode([ + $this->l->t('Jan.'), + $this->l->t('Feb.'), + $this->l->t('Mar.'), + $this->l->t('Apr.'), + $this->l->t('May.'), + $this->l->t('Jun.'), + $this->l->t('Jul.'), + $this->l->t('Aug.'), + $this->l->t('Sep.'), + $this->l->t('Oct.'), + $this->l->t('Nov.'), + $this->l->t('Dec.') + ]), + 'firstDay' => json_encode($firstDay), + '_oc_config' => json_encode($config), + 'oc_appconfig' => json_encode([ + 'core' => [ + 'defaultExpireDateEnabled' => $defaultExpireDateEnabled, + 'defaultExpireDate' => $defaultExpireDate, + 'defaultExpireDateEnforced' => $enforceDefaultExpireDate, + 'enforcePasswordForPublicLink' => Util::isPublicLinkPasswordRequired(), + 'enableLinkPasswordByDefault' => $enableLinkPasswordByDefault, + 'sharingDisabledForUser' => $shareManager->sharingDisabledForUser($uid), + 'resharingAllowed' => Share::isResharingAllowed(), + 'remoteShareAllowed' => $outgoingServer2serverShareEnabled, + 'federatedCloudShareDoc' => $this->urlGenerator->linkToDocs('user-sharing-federated'), + 'allowGroupSharing' => $shareManager->allowGroupSharing(), + 'defaultInternalExpireDateEnabled' => $defaultInternalExpireDateEnabled, + 'defaultInternalExpireDate' => $defaultInternalExpireDate, + 'defaultInternalExpireDateEnforced' => $defaultInternalExpireDateEnforced, + 'defaultRemoteExpireDateEnabled' => $defaultRemoteExpireDateEnabled, + 'defaultRemoteExpireDate' => $defaultRemoteExpireDate, + 'defaultRemoteExpireDateEnforced' => $defaultRemoteExpireDateEnforced, + ] + ]), + '_theme' => json_encode([ + 'entity' => $this->defaults->getEntity(), + 'name' => $this->defaults->getName(), + 'productName' => $this->defaults->getProductName(), + 'title' => $this->defaults->getTitle(), + 'baseUrl' => $this->defaults->getBaseUrl(), + 'syncClientUrl' => $this->defaults->getSyncClientUrl(), + 'docBaseUrl' => $this->defaults->getDocBaseUrl(), + 'docPlaceholderUrl' => $this->defaults->buildDocLinkToKey('PLACEHOLDER'), + 'slogan' => $this->defaults->getSlogan(), + 'logoClaim' => '', + 'folder' => \OC_Util::getTheme(), + ]), + ]; + + if ($this->currentUser !== null) { + $array['oc_userconfig'] = json_encode([ + 'avatar' => [ + 'version' => (int)$this->config->getUserValue($uid, 'avatar', 'version', 0), + 'generated' => $this->config->getUserValue($uid, 'avatar', 'generated', 'true') === 'true', + ] + ]); + } + + $this->initialStateService->provideInitialState('core', 'projects_enabled', $this->config->getSystemValueBool('projects.enabled', false)); + + $this->initialStateService->provideInitialState('core', 'config', $config); + $this->initialStateService->provideInitialState('core', 'capabilities', $capabilities); + + // Allow hooks to modify the output values + \OC_Hook::emit('\OCP\Config', 'js', ['array' => &$array]); + + $result = ''; + + // Echo it + foreach ($array as $setting => $value) { + $result .= 'var ' . $setting . '=' . $value . ';' . PHP_EOL; + } + + return $result; + } + + protected function canUserValidatePassword(): bool { + try { + $token = $this->tokenProvider->getToken($this->session->getId()); + } catch (ExpiredTokenException|WipeTokenException|InvalidTokenException|SessionNotAvailableException) { + // actually we do not know, so we fall back to this statement + return true; + } + $scope = $token->getScopeAsArray(); + return !isset($scope[IToken::SCOPE_SKIP_PASSWORD_VALIDATION]) || $scope[IToken::SCOPE_SKIP_PASSWORD_VALIDATION] === false; + } +} diff --git a/lib/private/Template/JSResourceLocator.php b/lib/private/Template/JSResourceLocator.php new file mode 100644 index 00000000000..a6d2d13a2ad --- /dev/null +++ b/lib/private/Template/JSResourceLocator.php @@ -0,0 +1,133 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OC\Template; + +use OCP\App\AppPathNotFoundException; +use OCP\App\IAppManager; +use Psr\Log\LoggerInterface; + +class JSResourceLocator extends ResourceLocator { + protected JSCombiner $jsCombiner; + protected IAppManager $appManager; + + public function __construct(LoggerInterface $logger, JSCombiner $JSCombiner, IAppManager $appManager) { + parent::__construct($logger); + + $this->jsCombiner = $JSCombiner; + $this->appManager = $appManager; + } + + /** + * @param string $script + */ + public function doFind($script) { + $theme_dir = 'themes/' . $this->theme . '/'; + + // Extracting the appId and the script file name + $app = substr($script, 0, strpos($script, '/')); + $scriptName = basename($script); + // Get the app root path + $appRoot = $this->serverroot . '/apps/'; + $appWebRoot = null; + try { + // We need the dir name as getAppPath appends the appid + $appRoot = dirname($this->appManager->getAppPath($app)); + // Only do this if $app_path is set, because an empty argument to realpath gets turned into cwd. + if ($appRoot) { + // Handle symlinks + $appRoot = realpath($appRoot); + } + // Get the app webroot + $appWebRoot = dirname($this->appManager->getAppWebPath($app)); + } catch (AppPathNotFoundException $e) { + // ignore + } + + if (str_contains($script, '/l10n/')) { + // For language files we try to load them all, so themes can overwrite + // single l10n strings without having to translate all of them. + $found = 0; + $found += $this->appendScriptIfExist($this->serverroot, 'core/' . $script); + $found += $this->appendScriptIfExist($this->serverroot, $theme_dir . 'core/' . $script); + $found += $this->appendScriptIfExist($this->serverroot, $script); + $found += $this->appendScriptIfExist($this->serverroot, $theme_dir . $script); + $found += $this->appendScriptIfExist($appRoot, $script, $appWebRoot); + $found += $this->appendScriptIfExist($this->serverroot, $theme_dir . 'apps/' . $script); + + if ($found) { + return; + } + } elseif ($this->appendScriptIfExist($this->serverroot, $theme_dir . 'apps/' . $script) + || $this->appendScriptIfExist($this->serverroot, $theme_dir . $script) + || $this->appendScriptIfExist($this->serverroot, $script) + || $this->appendScriptIfExist($this->serverroot, $theme_dir . "dist/$app-$scriptName") + || $this->appendScriptIfExist($this->serverroot, "dist/$app-$scriptName") + || $this->appendScriptIfExist($appRoot, $script, $appWebRoot) + || $this->cacheAndAppendCombineJsonIfExist($this->serverroot, $script . '.json') + || $this->cacheAndAppendCombineJsonIfExist($appRoot, $script . '.json', $app) + || $this->appendScriptIfExist($this->serverroot, $theme_dir . 'core/' . $script) + || $this->appendScriptIfExist($this->serverroot, 'core/' . $script) + || (strpos($scriptName, '/') === -1 && ($this->appendScriptIfExist($this->serverroot, $theme_dir . "dist/core-$scriptName") + || $this->appendScriptIfExist($this->serverroot, "dist/core-$scriptName"))) + || $this->cacheAndAppendCombineJsonIfExist($this->serverroot, 'core/' . $script . '.json') + ) { + return; + } + + // missing translations files will be ignored + if (str_contains($script, '/l10n/')) { + return; + } + + $this->logger->error('Could not find resource {resource} to load', [ + 'resource' => $script . '.js', + 'app' => 'jsresourceloader', + ]); + } + + /** + * @param string $script + */ + public function doFindTheme($script) { + } + + /** + * Try to find ES6 script file (`.mjs`) with fallback to plain javascript (`.js`) + * @see appendIfExist() + */ + protected function appendScriptIfExist(string $root, string $file, ?string $webRoot = null) { + if (!$this->appendIfExist($root, $file . '.mjs', $webRoot)) { + return $this->appendIfExist($root, $file . '.js', $webRoot); + } + return true; + } + + protected function cacheAndAppendCombineJsonIfExist($root, $file, $app = 'core') { + if (is_file($root . '/' . $file)) { + if ($this->jsCombiner->process($root, $file, $app)) { + $this->append($this->serverroot, $this->jsCombiner->getCachedJS($app, $file), false, false); + } else { + // Add all the files from the json + $files = $this->jsCombiner->getContent($root, $file); + $app_url = null; + try { + $app_url = $this->appManager->getAppWebPath($app); + } catch (AppPathNotFoundException) { + // pass + } + + foreach ($files as $jsFile) { + $this->append($root, $jsFile, $app_url); + } + } + return true; + } + + return false; + } +} diff --git a/lib/private/Template/ResourceLocator.php b/lib/private/Template/ResourceLocator.php new file mode 100755 index 00000000000..fa52f8e5c0d --- /dev/null +++ b/lib/private/Template/ResourceLocator.php @@ -0,0 +1,174 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OC\Template; + +use Psr\Log\LoggerInterface; + +abstract class ResourceLocator { + protected $theme; + + protected $mapping; + protected $serverroot; + protected $webroot; + + protected $resources = []; + + protected LoggerInterface $logger; + + public function __construct(LoggerInterface $logger) { + $this->logger = $logger; + $this->mapping = [ + \OC::$SERVERROOT => \OC::$WEBROOT + ]; + $this->serverroot = \OC::$SERVERROOT; + $this->webroot = \OC::$WEBROOT; + $this->theme = \OC_Util::getTheme(); + } + + /** + * @param string $resource + */ + abstract public function doFind($resource); + + /** + * @param string $resource + */ + abstract public function doFindTheme($resource); + + /** + * Finds the resources and adds them to the list + * + * @param array $resources + */ + public function find($resources) { + foreach ($resources as $resource) { + try { + $this->doFind($resource); + } catch (ResourceNotFoundException $e) { + $resourceApp = substr($resource, 0, strpos($resource, '/')); + $this->logger->debug('Could not find resource file "' . $e->getResourcePath() . '"', ['app' => $resourceApp]); + } + } + if (!empty($this->theme)) { + foreach ($resources as $resource) { + try { + $this->doFindTheme($resource); + } catch (ResourceNotFoundException $e) { + $resourceApp = substr($resource, 0, strpos($resource, '/')); + $this->logger->debug('Could not find resource file in theme "' . $e->getResourcePath() . '"', ['app' => $resourceApp]); + } + } + } + } + + /** + * append the $file resource if exist at $root + * + * @param string $root path to check + * @param string $file the filename + * @param string|null $webRoot base for path, default map $root to $webRoot + * @return bool True if the resource was found, false otherwise + */ + protected function appendIfExist($root, $file, $webRoot = null) { + if ($root !== false && is_file($root . '/' . $file)) { + $this->append($root, $file, $webRoot, false); + return true; + } + return false; + } + + /** + * Attempt to find the webRoot + * + * traverse the potential web roots upwards in the path + * + * example: + * - root: /srv/www/apps/myapp + * - available mappings: ['/srv/www'] + * + * First we check if a mapping for /srv/www/apps/myapp is available, + * then /srv/www/apps, /srv/www/apps, /srv/www, ... until we find a + * valid web root + * + * @param string $root + * @return string|null The web root or null on failure + */ + protected function findWebRoot($root) { + $webRoot = null; + $tmpRoot = $root; + + while ($webRoot === null) { + if (isset($this->mapping[$tmpRoot])) { + $webRoot = $this->mapping[$tmpRoot]; + break; + } + + if ($tmpRoot === '/') { + break; + } + + $tmpRoot = dirname($tmpRoot); + } + + if ($webRoot === null) { + $realpath = realpath($root); + + if ($realpath && ($realpath !== $root)) { + return $this->findWebRoot($realpath); + } + } + + return $webRoot; + } + + /** + * append the $file resource at $root + * + * @param string $root path to check + * @param string $file the filename + * @param string|null $webRoot base for path, default map $root to $webRoot + * @param bool $throw Throw an exception, when the route does not exist + * @throws ResourceNotFoundException Only thrown when $throw is true and the resource is missing + */ + protected function append($root, $file, $webRoot = null, $throw = true) { + if (!is_string($root)) { + if ($throw) { + throw new ResourceNotFoundException($file, $webRoot); + } + return; + } + + if (!$webRoot) { + $webRoot = $this->findWebRoot($root); + + if ($webRoot === null) { + $webRoot = ''; + $this->logger->error('ResourceLocator can not find a web root (root: {root}, file: {file}, webRoot: {webRoot}, throw: {throw})', [ + 'app' => 'lib', + 'root' => $root, + 'file' => $file, + 'webRoot' => $webRoot, + 'throw' => $throw ? 'true' : 'false' + ]); + } + } + $this->resources[] = [$root, $webRoot, $file]; + + if ($throw && !is_file($root . '/' . $file)) { + throw new ResourceNotFoundException($file, $webRoot); + } + } + + /** + * Returns the list of all resources that should be loaded + * @return array + */ + public function getResources() { + return $this->resources; + } +} diff --git a/lib/private/Template/ResourceNotFoundException.php b/lib/private/Template/ResourceNotFoundException.php new file mode 100644 index 00000000000..e51dfb5cb89 --- /dev/null +++ b/lib/private/Template/ResourceNotFoundException.php @@ -0,0 +1,30 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OC\Template; + +class ResourceNotFoundException extends \LogicException { + protected $resource; + protected $webPath; + + /** + * @param string $resource + * @param string $webPath + */ + public function __construct($resource, $webPath) { + parent::__construct('Resource not found'); + $this->resource = $resource; + $this->webPath = $webPath; + } + + /** + * @return string + */ + public function getResourcePath() { + return $this->webPath . '/' . $this->resource; + } +} diff --git a/lib/private/Template/Template.php b/lib/private/Template/Template.php new file mode 100644 index 00000000000..ee85562091f --- /dev/null +++ b/lib/private/Template/Template.php @@ -0,0 +1,159 @@ +<?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\Template; + +use OC\Security\CSP\ContentSecurityPolicyNonceManager; +use OC\TemplateLayout; +use OCP\App\AppPathNotFoundException; +use OCP\App\IAppManager; +use OCP\AppFramework\Http\TemplateResponse; +use OCP\Defaults; +use OCP\Server; +use OCP\Template\ITemplate; +use OCP\Template\TemplateNotFoundException; +use OCP\Util; + +class Template extends Base implements ITemplate { + private string $path; + private array $headers = []; + + /** + * @param string $app app providing the template + * @param string $name of the template file (without suffix) + * @param TemplateResponse::RENDER_AS_* $renderAs If $renderAs is set, will try to + * produce a full page in the according layout. + * @throws TemplateNotFoundException + */ + public function __construct( + protected string $app, + string $name, + private string $renderAs = TemplateResponse::RENDER_AS_BLANK, + bool $registerCall = true, + ) { + $theme = \OC_Util::getTheme(); + + $requestToken = ($registerCall ? Util::callRegister() : ''); + $cspNonce = Server::get(ContentSecurityPolicyNonceManager::class)->getNonce(); + + // fix translation when app is something like core/lostpassword + $parts = explode('/', $app); + $l10n = Util::getL10N($parts[0]); + + [$path, $template] = $this->findTemplate($theme, $app, $name); + + $this->path = $path; + + parent::__construct( + $template, + $requestToken, + $l10n, + Server::get(Defaults::class), + $cspNonce, + ); + } + + + /** + * find the template with the given name + * + * Will select the template file for the selected theme. + * Checking all the possible locations. + * + * @param string $name of the template file (without suffix) + * @return array{string,string} Directory path and filename + * @throws TemplateNotFoundException + */ + protected function findTemplate(string $theme, string $app, string $name): array { + // Check if it is a app template or not. + if ($app !== '') { + try { + $appDir = Server::get(IAppManager::class)->getAppPath($app); + } catch (AppPathNotFoundException) { + $appDir = false; + } + $dirs = $this->getAppTemplateDirs($theme, $app, \OC::$SERVERROOT, $appDir); + } else { + $dirs = $this->getCoreTemplateDirs($theme, \OC::$SERVERROOT); + } + $locator = new TemplateFileLocator($dirs); + return $locator->find($name); + } + + /** + * Add a custom element to the header + * @param string $tag tag name of the element + * @param array $attributes array of attributes for the element + * @param string $text the text content for the element. If $text is null then the + * element will be written as empty element. So use "" to get a closing tag. + */ + public function addHeader(string $tag, array $attributes, ?string $text = null): void { + $this->headers[] = [ + 'tag' => $tag, + 'attributes' => $attributes, + 'text' => $text + ]; + } + + /** + * Process the template + * + * This function process the template. If $this->renderAs is set, it + * will produce a full page. + */ + public function fetchPage(?array $additionalParams = null): string { + $data = parent::fetchPage($additionalParams); + + if ($this->renderAs) { + $page = Server::get(TemplateLayout::class)->getPageTemplate($this->renderAs, $this->app); + + if (is_array($additionalParams)) { + foreach ($additionalParams as $key => $value) { + $page->assign($key, $value); + } + } + + // Add custom headers + $headers = ''; + foreach (\OC_Util::$headers as $header) { + $headers .= '<' . Util::sanitizeHTML($header['tag']); + if (strcasecmp($header['tag'], 'script') === 0 && in_array('src', array_map('strtolower', array_keys($header['attributes'])))) { + $headers .= ' defer'; + } + foreach ($header['attributes'] as $name => $value) { + $headers .= ' ' . Util::sanitizeHTML($name) . '="' . Util::sanitizeHTML($value) . '"'; + } + if ($header['text'] !== null) { + $headers .= '>' . Util::sanitizeHTML($header['text']) . '</' . Util::sanitizeHTML($header['tag']) . '>'; + } else { + $headers .= '/>'; + } + } + + $page->assign('headers', $headers); + $page->assign('content', $data); + return $page->fetchPage($additionalParams); + } + + return $data; + } + + /** + * Include template + * + * @return string returns content of included template + * + * Includes another template. use <?php echo $this->inc('template'); ?> to + * do this. + */ + public function inc(string $file, ?array $additionalParams = null): string { + return $this->load($this->path . $file . '.php', $additionalParams); + } +} diff --git a/lib/private/Template/TemplateFileLocator.php b/lib/private/Template/TemplateFileLocator.php new file mode 100644 index 00000000000..11a568b5b21 --- /dev/null +++ b/lib/private/Template/TemplateFileLocator.php @@ -0,0 +1,41 @@ +<?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\Template; + +use OCP\Template\TemplateNotFoundException; + +class TemplateFileLocator { + /** + * @param string[] $dirs + */ + public function __construct( + private array $dirs, + ) { + } + + /** + * @return array{string,string} Directory path and filename + * @throws TemplateNotFoundException + */ + public function find(string $template): array { + if ($template === '') { + throw new \InvalidArgumentException('Empty template name'); + } + + foreach ($this->dirs as $dir) { + $file = $dir . $template . '.php'; + if (is_file($file)) { + return [$dir,$file]; + } + } + throw new TemplateNotFoundException('template file not found: template:' . $template); + } +} diff --git a/lib/private/Template/TemplateManager.php b/lib/private/Template/TemplateManager.php new file mode 100644 index 00000000000..34da4deac72 --- /dev/null +++ b/lib/private/Template/TemplateManager.php @@ -0,0 +1,169 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OC\Template; + +use OCP\App\IAppManager; +use OCP\AppFramework\Http\Events\BeforeTemplateRenderedEvent; +use OCP\AppFramework\Http\TemplateResponse; +use OCP\EventDispatcher\IEventDispatcher; +use OCP\IRequest; +use OCP\Server; +use OCP\Template\ITemplate; +use OCP\Template\ITemplateManager; +use OCP\Template\TemplateNotFoundException; +use Psr\Log\LoggerInterface; + +class TemplateManager implements ITemplateManager { + public function __construct( + private IAppManager $appManager, + private IEventDispatcher $eventDispatcher, + ) { + } + + /** + * @param TemplateResponse::RENDER_AS_* $renderAs + * @throws TemplateNotFoundException if the template cannot be found + */ + public function getTemplate(string $app, string $name, string $renderAs = TemplateResponse::RENDER_AS_BLANK, bool $registerCall = true): ITemplate { + return new Template($app, $name, $renderAs, $registerCall); + } + + /** + * Shortcut to print a simple page for guests + * @param string $application The application we render the template for + * @param string $name Name of the template + * @param array $parameters Parameters for the template + */ + public function printGuestPage(string $application, string $name, array $parameters = []): void { + $content = $this->getTemplate($application, $name, $name === 'error' ? $name : 'guest'); + foreach ($parameters as $key => $value) { + $content->assign($key, $value); + } + $content->printPage(); + } + + /** + * Print a fatal error page and terminates the script + * @param string $error_msg The error message to show + * @param string $hint An optional hint message - needs to be properly escape + */ + public function printErrorPage(string $error_msg, string $hint = '', int $statusCode = 500): never { + if ($this->appManager->isEnabledForUser('theming') && !$this->appManager->isAppLoaded('theming')) { + $this->appManager->loadApp('theming'); + } + + if ($error_msg === $hint) { + // If the hint is the same as the message there is no need to display it twice. + $hint = ''; + } + $errors = [['error' => $error_msg, 'hint' => $hint]]; + + http_response_code($statusCode); + try { + // Try rendering themed html error page + $response = new TemplateResponse( + '', + 'error', + ['errors' => $errors], + TemplateResponse::RENDER_AS_ERROR, + $statusCode, + ); + $event = new BeforeTemplateRenderedEvent(false, $response); + $this->eventDispatcher->dispatchTyped($event); + print($response->render()); + } catch (\Throwable $e1) { + $logger = \OCP\Server::get(LoggerInterface::class); + $logger->error('Rendering themed error page failed. Falling back to un-themed error page.', [ + 'app' => 'core', + 'exception' => $e1, + ]); + + try { + // Try rendering unthemed html error page + $content = $this->getTemplate('', 'error', 'error', false); + $content->assign('errors', $errors); + $content->printPage(); + } catch (\Exception $e2) { + // If nothing else works, fall back to plain text error page + $logger->error("$error_msg $hint", ['app' => 'core']); + $logger->error('Rendering un-themed error page failed. Falling back to plain text error page.', [ + 'app' => 'core', + 'exception' => $e2, + ]); + + header('Content-Type: text/plain; charset=utf-8'); + print("$error_msg $hint"); + } + } + die(); + } + + /** + * print error page using Exception details + */ + public function printExceptionErrorPage(\Throwable $exception, int $statusCode = 503): never { + $debug = false; + http_response_code($statusCode); + try { + $debug = (bool)Server::get(\OC\SystemConfig::class)->getValue('debug', false); + $serverLogsDocumentation = Server::get(\OC\SystemConfig::class)->getValue('documentation_url.server_logs', ''); + $request = Server::get(IRequest::class); + $content = $this->getTemplate('', 'exception', 'error', false); + $content->assign('errorClass', get_class($exception)); + $content->assign('errorMsg', $exception->getMessage()); + $content->assign('errorCode', $exception->getCode()); + $content->assign('file', $exception->getFile()); + $content->assign('line', $exception->getLine()); + $content->assign('exception', $exception); + $content->assign('debugMode', $debug); + $content->assign('serverLogsDocumentation', $serverLogsDocumentation); + $content->assign('remoteAddr', $request->getRemoteAddress()); + $content->assign('requestID', $request->getId()); + $content->printPage(); + } catch (\Exception $e) { + try { + $logger = Server::get(LoggerInterface::class); + $logger->error($exception->getMessage(), ['app' => 'core', 'exception' => $exception]); + $logger->error($e->getMessage(), ['app' => 'core', 'exception' => $e]); + } catch (\Throwable $e) { + // no way to log it properly - but to avoid a white page of death we send some output + $this->printPlainErrorPage($e, $debug); + + // and then throw it again to log it at least to the web server error log + throw $e; + } + + $this->printPlainErrorPage($e, $debug); + } + die(); + } + + /** + * @psalm-taint-escape has_quotes + * @psalm-taint-escape html + */ + private function fakeEscapeForPlainText(string $str): string { + return $str; + } + + private function printPlainErrorPage(\Throwable $exception, bool $debug = false): void { + header('Content-Type: text/plain; charset=utf-8'); + print("Internal Server Error\n\n"); + print("The server encountered an internal error and was unable to complete your request.\n"); + print("Please contact the server administrator if this error reappears multiple times, please include the technical details below in your report.\n"); + print("More details can be found in the server log.\n"); + + if ($debug) { + print("\n"); + print($exception->getMessage() . ' ' . $exception->getFile() . ' at ' . $exception->getLine() . "\n"); + print($this->fakeEscapeForPlainText($exception->getTraceAsString())); + } + } +} diff --git a/lib/private/Template/functions.php b/lib/private/Template/functions.php new file mode 100644 index 00000000000..402a7491e03 --- /dev/null +++ b/lib/private/Template/functions.php @@ -0,0 +1,299 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ + +use OC\Security\CSP\ContentSecurityPolicyNonceManager; +use OCP\Files\IMimeTypeDetector; +use OCP\IDateTimeFormatter; +use OCP\IURLGenerator; +use OCP\Server; +use OCP\Util; + +/** + * @param string $string + */ +function p($string): void { + print(Util::sanitizeHTML($string)); +} + +/** + * Prints a <link> tag for loading css + * @param string $href the source URL, ignored when empty + * @param string $opts, additional optional options + */ +function emit_css_tag($href, $opts = ''): void { + $s = '<link rel="stylesheet"'; + if (!empty($href)) { + $s .= ' href="' . $href . '"'; + } + if (!empty($opts)) { + $s .= ' ' . $opts; + } + print_unescaped($s . ">\n"); +} + +/** + * Prints all tags for CSS loading + * @param array $obj all the script information from template + */ +function emit_css_loading_tags($obj): void { + foreach ($obj['cssfiles'] as $css) { + emit_css_tag($css); + } + foreach ($obj['printcssfiles'] as $css) { + emit_css_tag($css, 'media="print"'); + } +} + +/** + * Prints a <script> tag with nonce and defer depending on config + * @param string $src the source URL, ignored when empty + * @param string $script_content the inline script content, ignored when empty + * @param string $content_type the type of the source (e.g. 'module') + */ +function emit_script_tag(string $src, string $script_content = '', string $content_type = ''): void { + $nonceManager = Server::get(ContentSecurityPolicyNonceManager::class); + + $defer_str = ' defer'; + $type = $content_type !== '' ? ' type="' . $content_type . '"' : ''; + + $s = '<script nonce="' . $nonceManager->getNonce() . '"'; + if (!empty($src)) { + // emit script tag for deferred loading from $src + $s .= $defer_str . ' src="' . $src . '"' . $type . '>'; + } elseif ($script_content !== '') { + // emit script tag for inline script from $script_content without defer (see MDN) + $s .= ">\n" . $script_content . "\n"; + } else { + // no $src nor $src_content, really useless empty tag + $s .= '>'; + } + $s .= '</script>'; + print_unescaped($s . "\n"); +} + +/** + * Print all <script> tags for loading JS + * @param array $obj all the script information from template + */ +function emit_script_loading_tags($obj): void { + foreach ($obj['jsfiles'] as $jsfile) { + $fileName = explode('?', $jsfile, 2)[0]; + $type = str_ends_with($fileName, '.mjs') ? 'module' : ''; + emit_script_tag($jsfile, '', $type); + } + if (!empty($obj['inline_ocjs'])) { + emit_script_tag('', $obj['inline_ocjs']); + } +} + +/** + * Prints an unsanitized string - usage of this function may result into XSS. + * Consider using p() instead. + * @param string $string the string which will be printed as it is + */ +function print_unescaped($string): void { + print($string); +} + +/** + * Shortcut for adding scripts to a page + * All scripts are forced to be loaded after core since + * they are coming from a template registration. + * Please consider moving them into the relevant controller + * + * @deprecated 24.0.0 - Use \OCP\Util::addScript + * + * @param string $app the appname + * @param string|string[] $file the filename, + * if an array is given it will add all scripts + */ +function script($app, $file = null): void { + if (is_array($file)) { + foreach ($file as $script) { + Util::addScript($app, $script, 'core'); + } + } else { + Util::addScript($app, $file, 'core'); + } +} + +/** + * Shortcut for adding styles to a page + * @param string $app the appname + * @param string|string[] $file the filename, + * if an array is given it will add all styles + */ +function style($app, $file = null): void { + if (is_array($file)) { + foreach ($file as $f) { + Util::addStyle($app, $f); + } + } else { + Util::addStyle($app, $file); + } +} + +/** + * Shortcut for adding vendor styles to a page + * @param string $app the appname + * @param string|string[] $file the filename, + * if an array is given it will add all styles + * @deprecated 32.0.0 + */ +function vendor_style($app, $file = null): void { + if (is_array($file)) { + foreach ($file as $f) { + OC_Util::addVendorStyle($app, $f); + } + } else { + OC_Util::addVendorStyle($app, $file); + } +} + +/** + * Shortcut for adding translations to a page + * @param string $app the appname + * if an array is given it will add all styles + */ +function translation($app): void { + Util::addTranslations($app); +} + +/** + * make \OCP\IURLGenerator::linkTo available as a simple function + * @param string $app app + * @param string $file file + * @param array $args array with param=>value, will be appended to the returned url + * @return string link to the file + * + * For further information have a look at \OCP\IURLGenerator::linkTo + */ +function link_to($app, $file, $args = []) { + return Server::get(IURLGenerator::class)->linkTo($app, $file, $args); +} + +/** + * @param string $key + * @return string url to the online documentation + */ +function link_to_docs($key) { + return Server::get(IURLGenerator::class)->linkToDocs($key); +} + +/** + * make \OCP\IURLGenerator::imagePath available as a simple function + * @param string $app app + * @param string $image image + * @return string link to the image + * + * For further information have a look at \OCP\IURLGenerator::imagePath + */ +function image_path($app, $image) { + return Server::get(IURLGenerator::class)->imagePath($app, $image); +} + +/** + * make mimetypeIcon available as a simple function + * @param string $mimetype mimetype + * @return string link to the image + */ +function mimetype_icon($mimetype) { + return Server::get(IMimeTypeDetector::class)->mimeTypeIcon($mimetype); +} + +/** + * make preview_icon available as a simple function + * Returns the path to the preview of the image. + * @param string $path path of file + * @return string link to the preview + */ +function preview_icon($path) { + return Server::get(IURLGenerator::class)->linkToRoute('core.Preview.getPreview', ['x' => 32, 'y' => 32, 'file' => $path]); +} + +/** + * @param string $path + * @param string $token + * @return string + */ +function publicPreview_icon($path, $token) { + return Server::get(IURLGenerator::class)->linkToRoute('files_sharing.PublicPreview.getPreview', ['x' => 32, 'y' => 32, 'file' => $path, 'token' => $token]); +} + +/** + * make Util::humanFileSize available as a simple function + * @param int $bytes size in bytes + * @return string size as string + * @deprecated use Util::humanFileSize instead + * + * For further information have a look at Util::humanFileSize + */ +function human_file_size($bytes) { + return Util::humanFileSize($bytes); +} + +/** + * Strips the timestamp of its time value + * @param int $timestamp UNIX timestamp to strip + * @return int timestamp without time value + */ +function strip_time($timestamp) { + $date = new \DateTime("@{$timestamp}"); + $date->setTime(0, 0, 0); + return (int)$date->format('U'); +} + +/** + * Formats timestamp relatively to the current time using + * a human-friendly format like "x minutes ago" or "yesterday" + * @param int $timestamp timestamp to format + * @param int|null $fromTime timestamp to compare from, defaults to current time + * @param bool|null $dateOnly whether to strip time information + * @return string timestamp + */ +function relative_modified_date($timestamp, $fromTime = null, $dateOnly = false): string { + $formatter = Server::get(IDateTimeFormatter::class); + + if ($dateOnly) { + return $formatter->formatDateSpan($timestamp, $fromTime); + } + return $formatter->formatTimeSpan($timestamp, $fromTime); +} + +/** + * @param array $options + * @param string[]|string $selected + * @param array $params + */ +function html_select_options($options, $selected, $params = []): string { + if (!is_array($selected)) { + $selected = [$selected]; + } + if (isset($params['combine']) && $params['combine']) { + $options = array_combine($options, $options); + } + $value_name = $label_name = false; + if (isset($params['value'])) { + $value_name = $params['value']; + } + if (isset($params['label'])) { + $label_name = $params['label']; + } + $html = ''; + foreach ($options as $value => $label) { + if ($value_name && is_array($label)) { + $value = $label[$value_name]; + } + if ($label_name && is_array($label)) { + $label = $label[$label_name]; + } + $select = in_array($value, $selected) ? ' selected="selected"' : ''; + $html .= '<option value="' . Util::sanitizeHTML($value) . '"' . $select . '>' . Util::sanitizeHTML($label) . '</option>' . "\n"; + } + return $html; +} |