File: /var/www/apklausos/application/helpers/SurveyThemeHelper.php
<?php
/*
* LimeSurvey
* Copyright (C) 2007-2021 The LimeSurvey Project Team / Carsten Schmitz
* All rights reserved.
* License: GNU/GPL License v2 or later, see LICENSE.php
* LimeSurvey is free software. This version may have been modified pursuant
* to the GNU General Public License, and as distributed it includes or
* is derivative of works licensed under the GNU General Public License or
* other free or open source software licenses.
* See COPYRIGHT.php for copyright notices and details.
*
*/
/**
* About Theme Options Path Treatment - Theme Options Path Prefix
* =========================
*
* Path sanitization is applied to all Theme Options ('options' attribute) that match an existing path or a "virtual" path.
*
* The paths allowed in Theme Options are restricted to three categories:
*
* - General Files: Files under <userthemerootdir>/generalfiles
* - Theme Files: Files under the theme folder
* - Survey Files: Files under <uploaddir>/surveys/<sid>/images
*
* Please note that the paths must point to files inside those folders, so path traversal is not allowed.
*
* To be clear about which of those categories the path belongs to, a prefix is added, making it a "virtual" path.
* - General Files: image::generalfiles::
* - Theme Files: image::theme::
* - Survey Files: image::survey::
*
* Paths are considered invalid if:
* - The path starts with one of the prefixes mentioned above but the file doesn't exist inside the category's folder.
* - The path matches a real path to an existing file
* (either relative to the root of LS installation, to the current working dir or absolute),
* but the file is not inside one of the categories folders.
*
* After sanitization, valid paths are converted to virtual paths, and invalid paths are prefixed with "invalid:".
*
* NOTE: Paths that don't have one of the category prefixes but don't match an existing file are left untouched,
* because there is no way to be 100% * sure that they are actual paths.
*/
use LimeSurvey\Datavalueobjects\ThemeFileCategory;
use LimeSurvey\Datavalueobjects\ThemeFileInfo;
/**
* General helper class for survey themes
*/
class SurveyThemeHelper
{
/**
* Returns the virtual path prefix of $virtualPath.
*
* @param string $virtualPath
* @return string|null the virtual path prefix, or null if $virtualPath doesn't match the format
*/
public static function getVirtualPathPrefix($virtualPath)
{
if (preg_match('/(image::\w+::)/', $virtualPath, $m)) {
return $m[1];
} else {
return null;
}
}
/**
* Returns true if $value matches the virtual path format.
* It doesn't check the path validity.
*
* @param string $value
* @return boolean
*/
public static function isVirtualPath($value)
{
return !empty(self::getVirtualPathPrefix($value));
}
/**
* Returns a list of themes found in the $folder, in the form
* of an array where the key is the theme name, and value is
* the theme's path.
* @param string $folder
* @return array<string,string>
*/
public static function getTemplateInFolder($folder)
{
/** @var array<string,string> */
$templateList = [];
if ($folder && $handle = opendir($folder)) {
while (false !== ($fileName = readdir($handle))) {
if (
!is_file("$folder/$fileName")
&& $fileName != "."
&& $fileName != ".."
&& $fileName != ".svn"
&& $fileName != "generalfiles"
//&& (file_exists("{$folder}/{$fileName}/config.xml"))
) {
$templateList[$fileName] = $folder . DIRECTORY_SEPARATOR . $fileName;
}
}
closedir($handle);
}
ksort($templateList);
return $templateList;
}
public static function getNestedThemeConfigPath($templateName) {
$directory = Yii::app()->getConfig("userthemerootdir") . DIRECTORY_SEPARATOR . $templateName;
$paths = CFileHelper::findFiles($directory, ['level' => 200]);
foreach ($paths as $path) {
if (str_contains($path, 'config.xml')) {
return substr($path, 0, strrpos($path, '/') + 1);
}
}
return null;
}
/**
* Returns a list of user themes, in the form of an array where
* the key is the folder name, and value is the theme's path.
* @return array<string,string>
*/
public static function getTemplateInUpload()
{
/** @var array<string,string> used for caching */
static $templatesInUploadDir = null;
if (empty($templatesInUploadDir)) {
$userTemplateRootDir = Yii::app()->getConfig("userthemerootdir");
$templatesInUploadDir = self::getTemplateInFolder($userTemplateRootDir);
}
return $templatesInUploadDir;
}
/**
* Returns a list of standard themes, in the form of an array where
* the key is the folder name, and value is the theme's path.
* @return array<string,string>
*/
public static function getTemplateInStandard()
{
/** @var array<string,string> used for caching */
static $templatesInStandardDir = null;
if (empty($templatesInStandardDir)) {
$standardTemplateRootDir = Yii::app()->getConfig("standardthemerootdir");
$templatesInStandardDir = self::getTemplateInFolder($standardTemplateRootDir);
}
return $templatesInStandardDir;
}
/**
* Return the standard template list
* @return string[]
* @throws Exception
*/
public static function getStandardTemplateList()
{
return array_keys(self::getTemplateInStandard());
}
/**
* isStandardTemplate returns true if a template is a standard template.
* This function does not check if a template actually exists.
* Scans standard themes folder and looks for folder matching the $themeName.
* Important: here is asumed that theme name = folder name
*
* @param mixed $themeName template name to look for
* @return bool True if standard template, otherwise false
*/
public static function isStandardTemplate($themeName)
{
$standardTemplates = self::getStandardTemplateList();
return in_array($themeName, $standardTemplates);
}
/**
* Returns a path's ThemeFileInfo if it's an absolute path or relative to the root dir.
* The function returns false if the path is not found, and null if it's found but doesn't
* match a category.
* @param string $path
* @param LimeSurvey\Datavalueobjects\ThemeFileCategory[] $categoryList
* @return LimeSurvey\Datavalueobjects\ThemeFileInfo|null|false
*/
public static function getThemeFileInfoFromAbsolutePath($path, $categoryList)
{
// Check if the path is relative to the root dir or an absolute path
$absolutePath = realpath(Yii::app()->getConfig('rootdir') . '/' . $path);
if ($absolutePath === false && strpos($path, '/') === 0) {
$absolutePath = realpath($path);
}
// If the path was absolute (or relative to the root dir), we check if it's within
// a category's bounds.
if (!empty($absolutePath)) {
foreach ($categoryList as $category) {
// Get real path for the category
$categoryPath = realpath($category->path);
if (empty($categoryPath)) {
continue;
}
$categoryPath = $categoryPath . DIRECTORY_SEPARATOR;
if (strpos($absolutePath, $categoryPath) === 0) {
$virtualPath = str_replace($categoryPath, $category->pathPrefix, $absolutePath);
return new ThemeFileInfo($absolutePath, $virtualPath, $category);
}
}
// The path didn't belong to any category
return null;
}
return false;
}
/**
* Returns a path's ThemeFileInfo if it's relative to a category.
* The function returns false if the path is not relative to any category.
* @param string $path
* @param LimeSurvey\Datavalueobjects\ThemeFileCategory[] $categoryList
* @return LimeSurvey\Datavalueobjects\ThemeFileInfo|false
*/
public static function getThemeFileInfoFromRelativePath($path, $categoryList)
{
foreach ($categoryList as $category) {
// Get real path for the category
$categoryPath = realpath($category->path);
if (empty($categoryPath)) {
continue;
}
$categoryPath = $categoryPath . DIRECTORY_SEPARATOR;
// Get the realpath for the file.
$realPath = realpath($categoryPath . $path);
// If the path is not found try with next category
if ($realPath === false) {
continue;
}
// Ok, now we know the path exists and is relative to the category, but
// it could be traversing out.
// Now let's check if it's within this category's bounds.
// If the real path starts with category's path, we return the file info.
if (strpos($realPath, $categoryPath) === 0) {
$virtualPath = str_replace($categoryPath, $category->pathPrefix, $realPath);
return new ThemeFileInfo($realPath, $virtualPath, $category);
}
}
return false;
}
/**
* Returns a list of file categories for the theme.
* Each category is related to a directory which holds files for the theme.
* This files are usually listed to be selected as values for options.
*
* @param string $themeName
* @param mixed $sid
* @return LimeSurvey\Datavalueobjects\ThemeFileCategory[]
*/
public static function getFileCategories($themeName, $sid = null)
{
// We need to determine the paths first. They may already be set, but if they're not, we need to get them from the template.
// Note that the template cannot be accessed as relation until the model is saved.
$path = self::getThemePath($themeName);
$generalFilesPath = Yii::app()->getConfig("userthemerootdir") . DIRECTORY_SEPARATOR . 'generalfiles' . DIRECTORY_SEPARATOR;
/** @var LimeSurvey\Datavalueobjects\ThemeFileCategory[] */
$categoryList = [];
$categoryList[] = new ThemeFileCategory('generalfiles', gT("Global"), $generalFilesPath, 'image::generalfiles::');
$categoryList[] = new ThemeFileCategory('theme', gT("Theme"), $path, 'image::theme::');
if (!empty($sid)) {
$categoryList[] = new ThemeFileCategory('survey', gT("Survey"), Yii::app()->getConfig('uploaddir') . '/surveys/' . $sid . '/images/', 'image::survey::');
}
return $categoryList;
}
/**
* Returns the path to the theme specified by $themeName.
* @param string $themeName
* @return string
*/
public static function getThemePath($themeName)
{
$basePath = self::isStandardTemplate($themeName) ? Yii::app()->getConfig("standardthemerootdir") : Yii::app()->getConfig("userthemerootdir");
// Technically the theme's folder name is saved in the model ($template->folder),
// but we use the theme name here, to avoid using the model and/or database calls.
// Throughout the code, it is asumed that the theme's folder matches the theme name.
// Seems that asumption has it's root source in the isStandardTemplate() method.
$path = $basePath . DIRECTORY_SEPARATOR . $themeName . DIRECTORY_SEPARATOR;
return $path;
}
/**
* Validates $path and returns its file info
* Rules:
* - valid virtual path
* - real path for an available category
*
* @param string|null $path the path to check. Can be a "virtual" path (eg. 'image::theme::logo.png'), or a normal path.
* @param string $themeName
* @param mixed $sid
* @return LimeSurvey\Datavalueobjects\ThemeFileInfo|null the file info if it's valid, or null if it's not.
*/
public static function getThemeFileInfo($path, $themeName, $sid = null)
{
if (is_null($path)) {
return null;
}
/** @var LimeSurvey\Datavalueobjects\ThemeFileCategory[] */
$categoryList = self::getFileCategories($themeName, $sid);
// Check if the path matches the virtual path category format
$prefix = self::getVirtualPathPrefix($path);
if (!empty($prefix)) {
// Find category that matches the prefix
$filteredCategories = array_filter($categoryList, function ($v) use ($prefix) {
return $v->pathPrefix == $prefix;
});
if (empty($filteredCategories)) {
return null; // No category matched the path's prefix
}
$category = reset($filteredCategories);
$categoryPath = realpath($category->path) . DIRECTORY_SEPARATOR;
// Validate that the file exists
$realPath = realpath($categoryPath . '/' . substr($path, strlen($prefix)));
// If the file exists and no traversing is done (the real path starts with the category's base path),
// return the file info
if ($realPath !== false && strpos($realPath, $categoryPath) === 0) {
$virtualPath = str_replace($categoryPath, $category->pathPrefix, $realPath);
return new ThemeFileInfo($realPath, $virtualPath, $category);
} else {
return null;
}
}
// Path doesn't match the virtual path category format, so we try the determine if it belongs to a category.
// Handle the case of absolute paths and paths relative to the root dir.
$result = self::getThemeFileInfoFromAbsolutePath($path, $categoryList);
if ($result !== false) {
return $result;
}
// If we got here, the path was not absolute (nor relative to the root dir), so we check
// if it's relative to a category.
$result = self::getThemeFileInfoFromRelativePath($path, $categoryList);
if ($result !== false) {
return $result;
}
// The path didn't belong to any category
return null;
}
/**
* Returns the virtual path for $path.
*
* @param string $path the path to check. Can be a "virtual" path (eg. 'image::theme::logo.png'), or a normal path.
* @param string $themeName
* @param mixed $sid
* @return string|null the virtual path if it's valid, of null if it's not.
*/
public static function getVirtualThemeFilePath($path, $themeName, $sid = null)
{
/** @var LimeSurvey\Datavalueobjects\ThemeFileInfo|null */
$fileInfo = self::getThemeFileInfo($path, $themeName, $sid);
if (empty($fileInfo)) {
return null;
}
return $fileInfo->virtualPath;
}
/**
* Returns the real aboslute path of $path
* If $path is not valid, returns null.
*
* @param string $path the path to check. Can be a "virtual" path (eg. 'image::theme::logo.png'), or a normal path.
* @param string $themeName
* @param mixed $sid
* @return string|null the real absolute path if it's valid, of null if it's not.
*/
public static function getRealThemeFilePath($path, $themeName, $sid = null)
{
/** @var LimeSurvey\Datavalueobjects\ThemeFileInfo|null */
$fileInfo = self::getThemeFileInfo($path, $themeName, $sid);
if (empty($fileInfo)) {
return null;
}
return $fileInfo->realPath;
}
/**
* Sanitizes a theme option value making sure that paths are valid.
*
* - All paths should be relative to the root directoy of the current theme or general files.
* - All paths should be a subdir of the current theme or general files -no path traversal (.. or . ) will be allowed - (example: "../../files/image.png" is not allowed)
*
* Options that match a file will be marked as invalid if the file
* is not valid, or replaced with the virtual path if the file is valid.
* The validity of paths depend on the theme configuration (basically the
* $themeName and the $sid, which could be empty for global options).
*
* @param string $value
* @param string $themeName
* @param string $sid
* @return string
*/
public static function sanitizePathInOption($value, $themeName, $sid = null)
{
// We only sanitize strings
if (!is_string($value)) {
return;
}
// This is used to sanitize all options of the theme. Not only classic ones which
// are expected to hold a path, as other options may hold a path as well (eg. custom theme options)
if (empty($value) || $value == 'inherit') {
return $value;
}
// If the value starts with 'invalid:', skip it.
if (stripos($value, 'invalid:', 0) === 0) {
return $value;
}
// Validation A - If option value is a path that matches a virtual path, transform the value to the virtual path
$virtualPath = self::getVirtualThemeFilePath($value, $themeName, $sid);
if (!empty($virtualPath)) {
$value = $virtualPath;
return $value;
}
// Validation B - If the file couldn't be matched to a category (validation A) we flag it as invalid if:
// - option value matches a virtual path format but is invalid or
// - option value matches a real existing path to a file either relative to the root LS installation or to the current workgin dir or absolute
// Mark the value as invalid, as that's not allowed. These are files outside the boundaries.
if (self::isVirtualPath($value) || realpath($value) !== false || realpath(Yii::app()->getConfig('rootdir') . '/' . $value) !== false) {
$value = 'invalid:' . $value;
return $value;
}
// Validation C - If the value contains certain substrings, we try to convert it into some known dirs.
$replacements = [
"~^.*themes[\\/]survey~" => [
Yii::app()->getConfig("standardthemerootdir"),
Yii::app()->getConfig("userthemerootdir")
]
];
$validPathFound = false;
foreach ($replacements as $pattern => $alternatives) {
// If the value matches the pattern, we replace that part by each of the alternative replacements,
// and try to get a valid virtual path from that.
if (preg_match($pattern, $value, $m)) {
foreach ($alternatives as $replacement) {
$path = preg_replace($pattern, (string) $replacement, $value);
$virtualPath = self::getVirtualThemeFilePath($path, $themeName, $sid);
if (!empty($virtualPath)) {
$value = $virtualPath;
$validPathFound = true;
break;
}
}
if ($validPathFound) {
break;
}
}
}
if ($validPathFound) {
return $value; // Not needed at the moment, because we are at the end of the method. But it's clearer in case another validation is added later.
}
// If we got here, it means the value couldn't be matched to real path.
// It may look like a path (maybe a file that no longer exists), or be something completely different.
return $value;
}
/**
* Checks and updates the given configuration file if necessary.
*
* This function loads the specified XML configuration file into a DOMDocument object, checks for its validity,
* and if applicable, updates it by calling `checkDomDocument`. If the file is invalid or an exception occurs,
* a warning is logged with details about the issue.
*
* @param string $configFile Path to the configuration file to be checked and potentially updated.
*
* @return void This function does not return a value. It may either update the configuration file
* or log a warning if the file is invalid or cannot be processed.
*
* @throws \Exception Propagates any exceptions thrown by `checkDomDocument`.
*/
public static function checkConfigFiles($configFile)
{
if (\PHP_VERSION_ID < 80000) {
libxml_disable_entity_loader(false);
}
$domDocument = new \DOMDocument;
$domDocument->load($configFile);
if (\PHP_VERSION_ID < 80000) {
libxml_disable_entity_loader(true);
}
if (!$domDocument) {
\Yii::log('Invalid config file at ' . $configFile, \CLogger::LEVEL_WARNING, 'application');
return;
}
try {
$newDomDocument = self::checkDomDocument($domDocument);
if ($newDomDocument) {
$newDomDocument->save($configFile);
}
} catch (\Exception $e) {
\Yii::log('Error: ' . $e->getMessage() . 'found in ' . $configFile, \CLogger::LEVEL_WARNING, 'application');
}
}
/**
* Processes a DOMDocument object to check and potentially modify its structure.
*
* This method specifically looks for 'cssframework' nodes within the given DOMDocument.
* If found, it examines child nodes for a default option and 'dropdownoptions'. It ensures that
* all 'option' nodes are wrapped within an 'optgroup' element. If any modifications are made,
* the DOMDocument is marked as changed.
*
* @param \DOMDocument $domDocument The DOMDocument object to be checked and potentially modified.
*
* @return \DOMDocument|null Returns the modified DOMDocument if changes were made, otherwise null.
* Changes include ensuring 'option' nodes within 'cssframework' are properly
* grouped under an 'optgroup' and setting a default option if not present.
*
* @throws \Exception If an invalid node is found within 'dropdownoptions' or if no 'dropdownoptions'
* nodes are found when expected.
*/
private static function checkDomDocument($domDocument)
{
$isChangedDomDocument = false;
// Find first 'cssframework' nodes in the document
$cssFrameworkNodes = $domDocument->getElementsByTagName('cssframework');
if ($cssFrameworkNodes) {
$cssFrameworkNode = $cssFrameworkNodes->item(0);
}
if ($cssFrameworkNode) {
$defaultOption = '';
$dropDownOptionsNode = null;
foreach ($cssFrameworkNode->childNodes as $child) {
if ($child->nodeType === XML_TEXT_NODE && trim($child->nodeValue) !== '') {
$defaultOption = $child->nodeValue;
} elseif ($child->nodeName === 'dropdownoptions') {
$dropDownOptionsNode = $child;
}
}
if ($dropDownOptionsNode) {
$optGroupNodeList = $dropDownOptionsNode->getElementsByTagName('optgroup');
if ($optGroupNodeList->length === 0) {
// Create a new 'optgroup' element
$optGroupNode = $domDocument->createElement('optgroup');
// Loop through all 'option' nodes and move them to 'optgroup'
while ($dropDownOptionsNode->childNodes->length > 0) {
$optionNode = $dropDownOptionsNode->firstChild;
// Skip text nodes or invalid nodes
if ($optionNode->nodeName === '#text' || trim($optionNode->nodeValue) === '') {
$dropDownOptionsNode->removeChild($optionNode);
continue;
}
// Check if the node is a valid 'option' node
if ($optionNode->nodeName != 'option') {
throw new \Exception('Invalid node in the config file.');
}
// Append valid 'option' nodes
$optGroupNode->appendChild($optionNode);
}
// Append the 'optgroup' with all the 'option' nodes into 'dropdownoptions'
if ($optGroupNode->childNodes->length > 0) {
$dropDownOptionsNode->appendChild($optGroupNode);
$isChangedDomDocument = true;
}
} else {
$optGroupNode = $optGroupNodeList->item(0);
}
} else {
throw new \Exception('No "dropdownoptions" nodes were found.');
}
if ($defaultOption === '' && isset($optGroupNode->firstChild)) {
$defaultOption = $optGroupNode->getElementsByTagName('option')->item(0)->nodeValue;
if (is_string($defaultOption) && trim($defaultOption) !== '') {
$textNode = $domDocument->createTextNode($defaultOption);
$cssFrameworkNode->insertBefore($textNode, $dropDownOptionsNode);
$isChangedDomDocument = true;
}
}
}
if ($isChangedDomDocument) {
return $domDocument;
}
return null;
}
}