-
Tim van Dijen authoredf7104c8b
Code owners
Assign users and groups as approvers for specific file changes. Learn more.
<?php
/**
* A minimalistic XHTML PHP based template system implemented for SimpleSAMLphp.
*
* @package SimpleSAMLphp
*/
declare(strict_types=1);
namespace SimpleSAML\XHTML;
use Exception;
use InvalidArgumentException;
use SimpleSAML\Assert\Assert;
use SimpleSAML\Configuration;
use SimpleSAML\Error;
use SimpleSAML\Locale\Language;
use SimpleSAML\Locale\Localization;
use SimpleSAML\Locale\Translate;
use SimpleSAML\Locale\TwigTranslator;
use SimpleSAML\Logger;
use SimpleSAML\Module;
use SimpleSAML\Utils;
use Symfony\Bridge\Twig\Extension\TranslationExtension;
use Symfony\Component\Filesystem\Filesystem;
use Symfony\Component\Finder\Finder;
use Symfony\Component\HttpFoundation\File\File;
use Symfony\Component\HttpFoundation\Response;
use Twig\Environment;
use Twig\Error\RuntimeError;
use Twig\Extra\Intl\IntlExtension;
use Twig\Loader\FilesystemLoader;
use Twig\TwigFilter;
use Twig\TwigFunction;
use function class_exists;
use function count;
use function date;
use function explode;
use function hash;
use function in_array;
use function is_null;
use function key;
use function ksort;
use function strripos;
use function strtolower;
use function strval;
use function substr;
/**
* The content-property is set upstream, but this is not recognized by Psalm
* @psalm-suppress PropertyNotSetInConstructor
*/
class Template extends Response
{
/**
* The data associated with this template, accessible within the template itself.
*
* @var array
*/
public array $data = [];
/**
* A translator instance configured to work with this template.
*
* @var \SimpleSAML\Locale\Translate
*/
private Translate $translator;
/**
* The localization backend
*
* @var \SimpleSAML\Locale\Localization
*/
private Localization $localization;
/**
* The configuration to use in this template.
*
* @var \SimpleSAML\Configuration
*/
private Configuration $configuration;
/**
* The file to load in this template.
*
* @var string
*/
private string $template = 'default.php';
/**
* The twig environment.
*
* @var \Twig\Environment
*/
private \Twig\Environment $twig;
/**
* The template name.
*
* @var string
*/
private string $twig_template;
/**
* Current module, if any.
*
* @var string|null
*/
private ?string $module = null;
/**
* A template controller, if any.
*
* Used to intercept certain parts of the template handling, while keeping away unwanted/unexpected hooks. Set
* the 'theme.controller' configuration option to a class that implements the
* \SimpleSAML\XHTML\TemplateControllerInterface interface to use it.
*
* @var \SimpleSAML\XHTML\TemplateControllerInterface|null
*/
private ?TemplateControllerInterface $controller = null;
/**
* Whether we are using a non-default theme or not.
*
* If we are using a theme, this variable holds an array with two keys: "module" and "name", those being the name
* of the module and the name of the theme, respectively. If we are using the default theme, the variable has
* the 'default' string in the "name" key, and 'null' in the "module" key.
*
* @var array
*/
private array $theme = ['module' => null, 'name' => 'default'];
/**
* @var \Symfony\Component\Filesystem\Filesystem
*/
private Filesystem $fileSystem;
/**
* Constructor
*
* @param \SimpleSAML\Configuration $configuration Configuration object
* @param string $template Which template file to load
*/
public function __construct(Configuration $configuration, string $template)
{
$this->configuration = $configuration;
$this->template = $template;
// TODO: do not remove the slash from the beginning, change the templates instead!
$this->data['baseurlpath'] = ltrim($this->configuration->getBasePath(), '/');
// parse module and template name
list($this->module) = $this->findModuleAndTemplateName($template);
// parse config to find theme and module theme is in, if any
list($this->theme['module'], $this->theme['name']) = $this->findModuleAndTemplateName(
$this->configuration->getOptionalString('theme.use', 'default')
);
// initialize internationalization system
$this->translator = new Translate($configuration);
$this->localization = new Localization($configuration);
// check if we need to attach a theme controller
$controller = $this->configuration->getOptionalString('theme.controller', null);
if ($controller !== null) {
if (
class_exists($controller)
&& in_array(TemplateControllerInterface::class, class_implements($controller))
) {
/** @var \SimpleSAML\XHTML\TemplateControllerInterface $this->controller */
$this->controller = new $controller();
} else {
throw new Error\ConfigurationError(
'Invalid controller was configured in `theme.controller`. ' .
' Make sure the class exists and implements the TemplateControllerInterface.'
);
}
}
$this->fileSystem = new Filesystem();
$this->twig = $this->setupTwig();
$this->charset = 'UTF-8';
parent::__construct();
}
/**
* Return the URL of an asset, including a cache-buster parameter that depends on the last modification time of
* the original file.
* @param string $asset
* @param string|null $module
* @return string
*/
public function asset(string $asset, string $module = null): string
{
$baseDir = $this->configuration->getBaseDir();
if (is_null($module)) {
$file = $baseDir . 'www/assets/' . $asset;
$basePath = $this->configuration->getBasePath();
$path = $basePath . 'assets/' . $asset;
} else {
$file = $baseDir . 'modules/' . $module . '/www/assets/' . $asset;
$path = Module::getModuleUrl($module . '/assets/' . $asset);
}
if (!$this->fileSystem->exists($file)) {
// don't be too harsh if an asset is missing, just pretend it's there...
return $path;
}
$file = new File($file);
$tag = $this->configuration->getVersion();
if ($tag === 'master') {
$tag = strval($file->getMtime());
}
$tag = substr(hash('md5', $tag), 0, 5);
return $path . '?tag=' . $tag;
}
/**
* Get the normalized template name.
*
* @return string The name of the template to use.
*/
public function getTemplateName(): string
{
return $this->normalizeTemplateName($this->template);
}
/**
* Normalize the name of the template to one of the possible alternatives.
*
* @param string $templateName The template name to normalize.
* @return string The filename we need to look for.
*/
private function normalizeTemplateName(string $templateName): string
{
if (strripos($templateName, '.twig')) {
return $templateName;
}
return $templateName . '.twig';
}
/**
* Set up the places where twig can look for templates.
*
* @return TemplateLoader The twig template loader or false if the template does not exist.
* @throws \Twig\Error\LoaderError In case a failure occurs.
*/
private function setupTwigTemplatepaths(): TemplateLoader
{
$filename = $this->normalizeTemplateName($this->template);
// get namespace if any
list($namespace, $filename) = $this->findModuleAndTemplateName($filename);
$this->twig_template = ($namespace !== null) ? '@' . $namespace . '/' . $filename : $filename;
$loader = new TemplateLoader();
$templateDirs = $this->findThemeTemplateDirs();
if ($this->module && $this->module != 'core') {
$modDir = TemplateLoader::getModuleTemplateDir($this->module);
$templateDirs[] = [$this->module => $modDir];
$templateDirs[] = ['__parent__' => $modDir];
}
if ($this->theme['module']) {
try {
$templateDirs[] = [
$this->theme['module'] => TemplateLoader::getModuleTemplateDir($this->theme['module'])
];
} catch (InvalidArgumentException $e) {
// either the module is not enabled or it has no "templates" directory, ignore
}
}
$templateDirs[] = ['core' => TemplateLoader::getModuleTemplateDir('core')];
// default, themeless templates are checked last
$templateDirs[] = [
FilesystemLoader::MAIN_NAMESPACE => $this->configuration->resolvePath('templates')
];
foreach ($templateDirs as $entry) {
$loader->addPath($entry[key($entry)], key($entry));
}
return $loader;
}
/**
* Setup twig.
* @return \Twig\Environment
* @throws \Exception if the template does not exist
*/
private function setupTwig(): Environment
{
$auto_reload = $this->configuration->getOptionalBoolean('template.auto_reload', true);
$cache = $this->configuration->getOptionalString('template.cache', null);
// set up template paths
$loader = $this->setupTwigTemplatepaths();
// abort if twig template does not exist
if (!$loader->exists($this->twig_template)) {
throw new Exception('Template-file \"' . $this->getTemplateName() . '\" does not exist.');
}
// load extra i18n domains
if ($this->module) {
$this->localization->addModuleDomain($this->module);
}
if ($this->theme['module'] !== null && $this->theme['module'] !== $this->module) {
$this->localization->addModuleDomain($this->theme['module']);
}
// set up translation
$options = [
'auto_reload' => $auto_reload,
'cache' => $cache ?? false,
'strict_variables' => true,
];
$twig = new Environment($loader, $options);
$twigTranslator = new TwigTranslator([Translate::class, 'translateSingularGettext']);
$twig->addExtension(new TranslationExtension($twigTranslator));
$twig->addExtension(new IntlExtension());
$twig->addFunction(new TwigFunction('moduleURL', [Module::class, 'getModuleURL']));
// initialize some basic context
$langParam = $this->configuration->getOptionalString('language.parameter.name', 'language');
$twig->addGlobal('languageParameterName', $langParam);
$twig->addGlobal('currentLanguage', $this->translator->getLanguage()->getLanguage());
$twig->addGlobal('isRTL', false); // language RTL configuration
if ($this->translator->getLanguage()->isLanguageRTL()) {
$twig->addGlobal('isRTL', true);
}
$queryParams = $_GET; // add query parameters, in case we need them in the template
if (isset($queryParams[$langParam])) {
unset($queryParams[$langParam]);
}
$twig->addGlobal('queryParams', $queryParams);
$twig->addGlobal('templateId', str_replace('.twig', '', $this->normalizeTemplateName($this->template)));
$twig->addGlobal('isProduction', $this->configuration->getOptionalBoolean('production', true));
$twig->addGlobal('baseurlpath', ltrim($this->configuration->getBasePath(), '/'));
// add a filter for translations out of arrays
$twig->addFilter(
new TwigFilter(
'translateFromArray',
[Translate::class, 'translateFromArray'],
['needs_context' => true]
)
);
// add a filter for preferred entity name
$twig->addFilter(
new TwigFilter(
'entityDisplayName',
[$this, 'getEntityDisplayName'],
)
);
// add an asset() function
$twig->addFunction(new TwigFunction('asset', [$this, 'asset']));
if ($this->controller !== null) {
$this->controller->setUpTwig($twig);
}
return $twig;
}
/**
* Add overriding templates from the configured theme.
*
* @return array An array of module => templatedir lookups.
*/
private function findThemeTemplateDirs(): array
{
if (!isset($this->theme['module'])) {
// no module involved
return [];
}
// setup directories & namespaces
$themeDir = Module::getModuleDir($this->theme['module']) . '/themes/' . $this->theme['name'];
if (!$this->fileSystem->exists($themeDir)) {
Logger::warning(
sprintf('Theme directory for theme "%s" (%s) does not exist.', $this->theme['name'], $themeDir),
);
return [];
}
$finder = new Finder();
$finder->directories()->in($themeDir)->depth(0);
if (!$finder->hasResults()) {
Logger::warning(sprintf(
'Theme directory for theme "%s" (%s) is not readable or is empty.',
$this->theme['name'],
$themeDir,
));
return [];
}
$themeTemplateDirs = [];
foreach ($finder as $entry) {
// set correct name for the default namespace
$ns = ($entry->getFileName() === 'default') ? FilesystemLoader::MAIN_NAMESPACE : $entry->getFileName();
$themeTemplateDirs[] = [$ns => strval($entry)];
}
return $themeTemplateDirs;
}
/**
* Get the template directory of a module, if it exists.
*
* @param string $module
* @return string The templates directory of a module
*
* @throws \InvalidArgumentException If the module is not enabled or it has no templates directory.
*/
private function getModuleTemplateDir(string $module): string
{
if (!Module::isModuleEnabled($module)) {
throw new InvalidArgumentException('The module \'' . $module . '\' is not enabled.');
}
$moduledir = Module::getModuleDir($module);
// check if module has a /templates dir, if so, append
$templateDir = $moduledir . '/templates';
$file = new File($templateDir);
if (!$file->isDir()) {
throw new InvalidArgumentException('The module \'' . $module . '\' has no templates directory.');
}
return $templateDir;
}
/**
* Add the templates from a given module.
*
* Note that the module must be installed, enabled, and contain a "templates" directory.
*
* @param string $module The module where we need to search for templates.
* @throws \InvalidArgumentException If the module is not enabled or it has no templates directory.
*/
public function addTemplatesFromModule(string $module): void
{
$dir = TemplateLoader::getModuleTemplateDir($module);
/** @var \Twig\Loader\FilesystemLoader $loader */
$loader = $this->twig->getLoader();
$loader->addPath($dir, $module);
}
/**
* Generate an array for its use in the language bar, indexed by the ISO 639-2 codes of the languages available,
* containing their localized names and the URL that should be used in order to change to that language.
*
* @return array|null The array containing information of all available languages.
*/
private function generateLanguageBar(): ?array
{
$languages = $this->translator->getLanguage()->getLanguageList();
ksort($languages);
$langmap = null;
if (count($languages) > 1) {
$parameterName = $this->getTranslator()->getLanguage()->getLanguageParameterName();
$langmap = [];
foreach ($languages as $lang => $current) {
$lang = strtolower($lang);
$langname = $this->translator->getLanguage()->getLanguageLocalizedName($lang);
$url = false;
if (!$current) {
$httpUtils = new Utils\HTTP();
$url = $httpUtils->addURLParameters(
'',
[$parameterName => $lang]
);
}
$langmap[$lang] = [
'name' => $langname,
'url' => $url,
];
}
}
return $langmap;
}
/**
* Set some default context
*/
private function twigDefaultContext(): void
{
// show language bar by default
if (!isset($this->data['hideLanguageBar'])) {
$this->data['hideLanguageBar'] = false;
}
// get languagebar
$this->data['languageBar'] = null;
if ($this->data['hideLanguageBar'] === false) {
$languageBar = $this->generateLanguageBar();
if (is_null($languageBar)) {
$this->data['hideLanguageBar'] = true;
} else {
$this->data['languageBar'] = $languageBar;
}
}
// assure that there is a <title> and <h1>
if (isset($this->data['header']) && !isset($this->data['pagetitle'])) {
$this->data['pagetitle'] = $this->data['header'];
}
if (!isset($this->data['pagetitle'])) {
$this->data['pagetitle'] = 'SimpleSAMLphp';
}
$this->data['year'] = date('Y');
$this->data['header'] = $this->configuration->getOptionalString('theme.header', 'SimpleSAMLphp');
}
/**
* Helper function for locale extraction: just compile but not display
* this template. This is not generally useful, getContents() will normally
* compile and display the template in one step.
*/
public function compile(): void
{
$this->twig->load($this->twig_template);
}
/**
* Get the contents produced by this template.
*
* @return string The HTML rendered by this template, as a string.
* @throws \Exception if the template cannot be found.
*/
public function getContents(): string
{
$this->twigDefaultContext();
if ($this->controller) {
$this->controller->display($this->data);
}
try {
return $this->twig->render($this->twig_template, $this->data);
} catch (RuntimeError $e) {
throw new Error\Exception(substr($e->getMessage(), 0, -1) . ' in ' . $this->template, 0, $e);
}
}
/**
* Send this template as a response.
*
* @return $this This response.
* @throws \Exception if the template cannot be found.
*
* Note: No return type possible due to upstream limitations
*/
public function send(): static
{
$this->content = $this->getContents();
return parent::send();
}
/**
* Find module the template is in, if any
*
* @param string $template The relative path from the theme directory to the template file.
*
* @return array An array with the name of the module and template
*/
private function findModuleAndTemplateName(string $template): array
{
$tmp = explode(':', $template, 2);
return (count($tmp) === 2) ? [$tmp[0], $tmp[1]] : [null, $tmp[0]];
}
/**
* Return the internal translator object used by this template.
*
* @return \SimpleSAML\Locale\Translate The translator that will be used with this template.
*/
public function getTranslator(): Translate
{
return $this->translator;
}
/**
* Return the internal localization object used by this template.
*
* @return \SimpleSAML\Locale\Localization The localization object that will be used with this template.
*/
public function getLocalization(): Localization
{
return $this->localization;
}
/**
* Get the current instance of Twig in use.
*
* @return \Twig\Environment The Twig instance in use.
*/
public function getTwig(): \Twig\Environment
{
return $this->twig;
}
/**
* Wraps Language->getLanguageList
*
* @return string[]
*/
private function getLanguageList(): array
{
return $this->translator->getLanguage()->getLanguageList();
}
/**
* Wrap Language->isLanguageRTL
*
* @return bool
*/
private function isLanguageRTL(): bool
{
return $this->translator->getLanguage()->isLanguageRTL();
}
/**
* Search through entity metadata to find the best display name for this
* entity. It will search in order for the current language, default
* language and fallback language for the DisplayName, name, OrganizationDisplayName
* and OrganizationName; the first one found is considered the best match.
* If nothing found, will return the entityId.
*/
public function getEntityDisplayName(array $data): string
{
$tryLanguages = $this->translator->getLanguage()->getPreferredLanguages();
foreach ($tryLanguages as $language) {
if (isset($data['UIInfo']['DisplayName'][$language])) {
return $data['UIInfo']['DisplayName'][$language];
} elseif (isset($data['name'][$language])) {
return $data['name'][$language];
} elseif (isset($data['OrganizationDisplayName'][$language])) {
return $data['OrganizationDisplayName'][$language];
} elseif (isset($data['OrganizationName'][$language])) {
return $data['OrganizationName'][$language];
}
}
return $data['entityid'];
}
/**
* Search through entity metadata to find the best value for a
* specific property. It will search in order for the current language, default
* language and fallback language; it will return the property value (which
* can be a string, array or other type allowed in metadata, if not found it
* returns null.
*/
public function getEntityPropertyTranslation(string $property, array $data)
{
$tryLanguages = $this->translator->getLanguage()->getPreferredLanguages();
foreach ($tryLanguages as $language) {
if (isset($data[$property][$language])) {
return $data[$property][$language];
}
}
return null;
}
}