Newer
Older
Jaime Perez Crespo
committed
namespace SimpleSAML;
use Symfony\Component\Config\Exception\FileLocatorFileNotFoundException;
use Symfony\Component\Filesystem\{Filesystem, Path};
use Symfony\Component\Finder\Finder;
use Symfony\Component\HttpFoundation\{BinaryFileResponse, RedirectResponse, Request, Response, ResponseHeaderBag};
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use function array_filter;
use function array_key_exists;
use function class_exists;
use function count;
use function dirname;
use function explode;
use function function_exists;
use function in_array;
use function is_bool;
use function is_callable;
use function is_dir;
use function is_file;
use function is_null;
use function is_subclass_of;
use function mb_strtolower;
use function mime_content_type;
use function preg_match;
use function rtrim;
use function str_replace;
use function strlen;
use function strpos;
use function strtolower;
use function strval;
use function substr;
/**
* Helper class for accessing information about modules.
*
Jaime Perez Crespo
committed
class Module
/**
* Index pages: file names to attempt when accessing directories.
*
public static array $indexFiles = ['index.php', 'index.html', 'index.htm', 'index.txt'];
/**
* MIME Types
*
* The key is the file extension and the value the corresponding MIME type.
*
'bmp' => 'image/x-ms-bmp',
'css' => 'text/css',
'gif' => 'image/gif',
'htm' => 'text/html',
'html' => 'text/html',
'shtml' => 'text/html',
'ico' => 'image/vnd.microsoft.icon',
'jpe' => 'image/jpeg',
'jpeg' => 'image/jpeg',
'jpg' => 'image/jpeg',
'js' => 'text/javascript',
'pdf' => 'application/pdf',
'png' => 'image/png',
'svg' => 'image/svg+xml',
'svgz' => 'image/svg+xml',
'swf' => 'application/x-shockwave-flash',
'swfl' => 'application/x-shockwave-flash',
'txt' => 'text/plain',
'xht' => 'application/xhtml+xml',
'xhtml' => 'application/xhtml+xml',
];
* A list containing the modules currently installed.
Tim van Dijen
committed
/**
* A list containing the modules that are enabled by default, unless specifically disabled
*
Tim van Dijen
committed
*/
Tim van Dijen
committed
'core' => true,
'saml' => true
];
/**
* A cache containing specific information for modules, like whether they are enabled or not, or their hooks.
*
* @var array
*/
/**
* Retrieve the base directory for a module.
*
* The returned path name will be an absolute path.
*
* @param string $module Name of the module
*
* @return string The base directory of a module.
*/
public static function getModuleDir(string $module): string
$baseDir = dirname(__FILE__, 3) . '/modules';
return $moduleDir;
}
/**
* Determine whether a module is enabled.
*
Jaime Perez Crespo
committed
* Will return false if the given module doesn't exist.
*
* @param string $module Name of the module
*
* @return bool True if the given module is enabled, false otherwise.
*
Jaime Perez Crespo
committed
* @throws \Exception If module.enable is set and is not boolean.
public static function isModuleEnabled(string $module): bool
$config = Configuration::getOptionalConfig();
return self::isModuleEnabledWithConf($module, $config->getOptionalArray('module.enable', self::$core_modules));
/**
* Handler for module requests.
*
* This controller receives requests for pages hosted by modules, and processes accordingly. Depending on the
* configuration and the actual request, it will run a PHP script and exit, or return a Response produced either
* by another controller or by a static file.
*
* @param Request|null $request The request to process. Defaults to the current one.
*
* @return Response|BinaryFileResponse Returns a Response object that can be sent to the browser.
* @throws Error\BadRequest In case the request URI is malformed.
* @throws Error\NotFound In case the request URI is invalid or the resource it points to cannot be found.
*/
public static function process(Request $request = null): Response
{
if ($request === null) {
$request = Request::createFromGlobals();
}
if ($request->server->get('PATH_INFO') === '/') {
throw new Error\NotFound('No PATH_INFO to module.php');
}
$url = $request->server->get('PATH_INFO');
/* clear the PATH_INFO option, so that a script can detect whether it is called with anything following the
*'.php'-ending.
*/
unset($_SERVER['PATH_INFO']);
$modEnd = strpos($url, '/', 1);
if ($modEnd === false) {
$modEnd = strlen($url);
$module = substr($url, 1);
} else {
$module = substr($url, 1, $modEnd - 1);
$url = substr($url, $modEnd + 1);
}
if (!self::isModuleEnabled($module)) {
throw new Error\NotFound(sprintf("The module '%s' was either not found, or wasn't enabled.", $module));
}
/* Make sure that the request isn't suspicious (contains references to current directory or parent directory or
* anything like that. Searching for './' in the URL will detect both '../' and './'. Searching for '\' will
* detect attempts to use Windows-style paths.
*/
if (strpos($url, '\\') !== false) {
throw new Error\BadRequest('Requested URL contained a backslash.');
} elseif (strpos($url, './') !== false) {
throw new Error\BadRequest('Requested URL contained \'./\'.');
}
$config = Configuration::getInstance();
// rebuild REQUEST_URI and SCRIPT_NAME just in case we need to.
// This is needed for server aliases and rewrites
$translated_uri = $config->getBasePath() . 'module.php/' . $module . '/' . $url;
$request->server->set('REQUEST_URI', $translated_uri);
$request->server->set('SCRIPT_NAME', $config->getBasePath() . 'module.php');
// strip any NULL files (form file fields with nothing selected)
// because initialize() will throw an error on them
$request_files = array_filter(
$request->files->all(),
function ($val) {
return !is_null($val);
}
);
$request->initialize(
$request->query->all(),
$request->request->all(),
$request->attributes->all(),
$request->cookies->all(),
$request->server->all(),
$request->getContent()
);
try {
$kernel = new Kernel($module);
$response = $kernel->handle($request);
$kernel->terminate($request, $response);
return $response;
} catch (FileLocatorFileNotFoundException $e) {
// no routes configured for this module, fall back to the old system
} catch (NotFoundHttpException $e) {
// this module has been migrated, but the route wasn't found
}
$moduleDir = self::getModuleDir($module) . '/public/';
// check for '.php/' in the path, the presence of which indicates that another php-script should handle the
// request
for ($phpPos = strpos($url, '.php/'); $phpPos !== false; $phpPos = strpos($url, '.php/', $phpPos + 1)) {
$newURL = substr($url, 0, $phpPos + 4);
$param = substr($url, $phpPos + 4);
if (is_file($moduleDir . $newURL)) {
/* $newPath points to a normal file. Point execution to that file, and save the remainder of the path
* in PATH_INFO.
*/
$url = $newURL;
$request->server->set('PATH_INFO', $param);
$_SERVER['PATH_INFO'] = $param;
break;
}
}
$path = $moduleDir . $url;
if ($path[strlen($path) - 1] === '/') {
// path ends with a slash - directory reference. Attempt to find index file in directory
foreach (self::$indexFiles as $if) {
if ($fileSystem->exists($path . $if)) {
$path .= $if;
break;
}
}
}
if (is_dir($path)) {
/* Path is a directory - maybe no index file was found in the previous step, or maybe the path didn't end
* with a slash. Either way, we don't do directory listings.
*/
throw new Error\NotFound('Directory listing not available.');
}
if (!$fileSystem->exists($path)) {
// file not found
Logger::info('Could not find file \'' . $path . '\'.');
throw new Error\NotFound("The URL wasn't found in the module.");
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
}
if (mb_strtolower(substr($path, -4), 'UTF-8') === '.php') {
// PHP file - attempt to run it
/* In some environments, $_SERVER['SCRIPT_NAME'] is already set with $_SERVER['PATH_INFO']. Check for that
* case, and append script name only if necessary.
*
* Contributed by Travis Hegner.
*/
$script = "/$module/$url";
if (strpos($request->getScriptName(), $script) === false) {
$request->server->set('SCRIPT_NAME', $request->getScriptName() . '/' . $module . '/' . $url);
}
require($path);
exit();
}
// some other file type - attempt to serve it
// find MIME type for file, based on extension
$contentType = null;
if (preg_match('#\.([^/\.]+)$#D', $path, $type)) {
$type = strtolower($type[1]);
if (array_key_exists($type, self::$mimeTypes)) {
$contentType = self::$mimeTypes[$type];
}
}
if ($contentType === null) {
/* We were unable to determine the MIME type from the file extension. Fall back to mime_content_type (if it
* exists).
*/
if (function_exists('mime_content_type')) {
$contentType = mime_content_type($path);
} else {
// mime_content_type doesn't exist. Return a default MIME type
Logger::warning('Unable to determine mime content type of file: ' . $path);
$contentType = 'application/octet-stream';
}
}
Thijs Kinkhorst
committed
$assetConfig = $config->getOptionalConfigItem('assets', []);
$cacheConfig = $assetConfig->getOptionalConfigItem('caching', []);
$response = new BinaryFileResponse($path);
$response->setCache([
// "public" allows response caching even if the request was authenticated,
// which is exactly what we want for static resources
'public' => true,
'max_age' => strval($cacheConfig->getOptionalInteger('max_age', 86400))
if ($cacheConfig->getOptionalBoolean('etag', false)) {
$response->setAutoEtag();
}
$response->isNotModified($request);
$response->headers->set('Content-Type', $contentType);
$response->setContentDisposition(ResponseHeaderBag::DISPOSITION_INLINE);
$response->prepare($request);
return $response;
}
/**
* @param string $module
* @param array $mod_config
* @return bool
*/
private static function isModuleEnabledWithConf(string $module, array $mod_config): bool
{
if (isset(self::$module_info[$module]['enabled'])) {
return self::$module_info[$module]['enabled'];
}
if (!empty(self::$modules) && !in_array($module, self::$modules, true)) {
$moduleDir = self::getModuleDir($module);
if (!is_dir($moduleDir)) {
self::$module_info[$module]['enabled'] = false;
if (isset($mod_config[$module])) {
if (is_bool($mod_config[$module])) {
self::$module_info[$module]['enabled'] = $mod_config[$module];
return $mod_config[$module];
throw new Exception("Invalid module.enable value for the '$module' module.");
$core_module = array_key_exists($module, self::$core_modules) ? true : false;
Tim van Dijen
committed
self::$module_info[$module]['enabled'] = $core_module ? true : false;
return $core_module ? true : false;
}
/**
* Get available modules.
*
Jaime Perez Crespo
committed
* @throws \Exception If we cannot open the module's directory.
if (!empty(self::$modules)) {
return self::$modules;
$path = self::getModuleDir('.');
$finder = new Finder();
$finder->directories()->in($path)->depth(0);
foreach ($finder as $module) {
self::$modules[] = $module->getFileName();
return self::$modules;
}
/**
* Resolve module class.
*
* This function takes a string on the form "<module>:<class>" and converts it to a class
* name. It can also check that the given class is a subclass of a specific class. The
* resolved classname will be "\SimleSAML\Module\<module>\<$type>\<class>.
*
* It is also possible to specify a full classname instead of <module>:<class>.
*
* An exception will be thrown if the class can't be resolved.
*
* @param string $id The string we should resolve.
* @param string $type The type of the class.
* @param string|null $subclass The class should be a subclass of this class. Optional.
*
* @return string The classname.
*
Jaime Perez Crespo
committed
* @throws \Exception If the class cannot be resolved.
public static function resolveClass(string $id, string $type, ?string $subclass = null): string
if (count($tmp) === 1) {
// no module involved
Jaime Perez Crespo
committed
if (!class_exists($className)) {
throw new Exception("Could not resolve '$id': no class named '$className'.");
Jaime Perez Crespo
committed
}
} elseif (!in_array($tmp[0], self::getModules())) {
// Module not installed
throw new Exception('No module named \'' . $tmp[0] . '\' has been installed.');
} elseif (!self::isModuleEnabled($tmp[0])) {
// Module installed, but not enabled
throw new Exception('The module \'' . $tmp[0] . '\' is not enabled.');
Jaime Perez Crespo
committed
// make sure empty types are handled correctly
$type = (empty($type)) ? '\\' : '\\' . $type . '\\';
Jaime Perez Crespo
committed
$className = 'SimpleSAML\\Module\\' . $tmp[0] . $type . $tmp[1];
Jaime Perez Crespo
committed
if ($subclass !== null && !is_subclass_of($className, $subclass)) {
'Could not resolve \'' . $id . '\': The class \'' . $className
. '\' isn\'t a subclass of \'' . $subclass . '\'.'
);
}
return $className;
}
/**
* Get absolute URL to a specified module resource.
*
* This function creates an absolute URL to a resource stored under ".../modules/<module>/public/".
*
* @param string $resource Resource path, on the form "<module name>/<resource>"
* @param array $parameters Extra parameters which should be added to the URL. Optional.
*
* @return string The absolute URL to the given resource.
*/
public static function getModuleURL(string $resource, array $parameters = []): string
$httpUtils = new Utils\HTTP();
$url = $httpUtils->getBaseURL() . 'module.php/' . $resource;
$url = $httpUtils->addURLParameters($url, $parameters);
/**
* Get the available hooks for a given module.
*
* @param string $module The module where we should look for hooks.
*
* @return array An array with the hooks available for this module. Each element is an array with two keys: 'file'
* points to the file that contains the hook, and 'func' contains the name of the function implementing that hook.
* When there are no hooks defined, an empty array is returned.
*/
public static function getModuleHooks(string $module): array
{
if (isset(self::$modules[$module]['hooks'])) {
return self::$modules[$module]['hooks'];
}
$hook_dir = Path::canonicalize(dirname(__FILE__, 3) . '/modules/' . $module . '/hooks');
if ((new Filesystem())->exists($hook_dir)) {
$finder = new Finder();
$finder->files()->in($hook_dir)->depth(0);
foreach ($finder as $file) {
if (preg_match('/^hook_(\w+)\.php$/', $file->getFileName(), $matches)) {
$hook_name = $matches[1];
$hook_func = $module . '_hook_' . $hook_name;
$hooks[$hook_name] = ['file' => Path::canonicalize(strval($file)), 'func' => $hook_func];
}
return $hooks;
}
/**
* Call a hook in all enabled modules.
*
* This function iterates over all enabled modules and calls a hook in each module.
*
* @param string $hook The name of the hook.
* @param mixed &$data The data which should be passed to each hook. Will be passed as a reference.
* @throws \SimpleSAML\Error\Exception If an invalid hook is found in a module.
public static function callHooks(string $hook, &$data = null): void
$config = Configuration::getOptionalConfig()->getOptionalArray('module.enable', []);
sort($modules);
foreach ($modules as $module) {
if (!self::isModuleEnabledWithConf($module, $config)) {
if (!isset(self::$module_info[$module]['hooks'])) {
self::$module_info[$module]['hooks'] = self::getModuleHooks($module);
}
if (
!isset(self::$module_info[$module]['hooks'][$hook])
|| empty(self::$module_info[$module]['hooks'][$hook])
require_once(self::$module_info[$module]['hooks'][$hook]['file']);
if (!is_callable(self::$module_info[$module]['hooks'][$hook]['func'])) {
throw new Error\Exception('Invalid hook \'' . $hook . '\' for module \'' . $module . '\'.');
$fn = self::$module_info[$module]['hooks'][$hook]['func'];
$fn($data);
/**
* Handle a valid request for a module that lacks a trailing slash.
*
* This method add the trailing slash and redirects to the resulting URL.
*
* @param Request $request The request to process by this controller method.
*
* @return RedirectResponse A redirection to the URI specified in the request, but with a trailing slash.
*/
public static function addTrailingSlash(Request $request): RedirectResponse
{
// Must be of form /{module} - append a slash
return new RedirectResponse($request->getRequestUri() . '/', 308);
}
/**
* Handle a valid request that ends with a trailing slash.
*
* This method removes the trailing slash and redirects to the resulting URL.
*
* @param \Symfony\Component\HttpFoundation\Request $request The request to process by this controller method.
* @return \Symfony\Component\HttpFoundation\RedirectResponse
* A redirection to the URI specified in the request, but without the trailing slash.
public static function removeTrailingSlash(Request $request): RedirectResponse
$pathInfo = $request->server->get('PATH_INFO');
$url = str_replace($pathInfo, rtrim($pathInfo, ' /'), $request->getRequestUri());
return new RedirectResponse($url, 308);
}