<?php namespace WebPExpress; use \WebPExpress\FileHelper; use \WebPExpress\Multisite; use \WebPExpress\PathHelper; class Paths { public static function areAllImageRootsWithinDocRoot() { if (!PathHelper::isDocRootAvailable()) { return false; } $roots = self::getImageRootIds(); foreach ($roots as $dirId) { $dir = self::getAbsDirById($dirId); if (!PathHelper::canCalculateRelPathFromDocRootToDir($dir)) { return false; } } return true; } /** * Check if we can use document root for calculating relative paths (which may not contain "/.." directory traversal) * * Note that this method allows document root to be outside open_basedir as long as document root is * non-empty AND it is possible to calculate relative paths to all image roots (including "index"). * Here is a case when a relative CAN be calculated: * - Document root is configured to "/var/www/website" - which is also the absolute file path. * - open_basedir is set to "/var/www/website/wordpress" * - uploads is in "/var/www/website/wordpress/wp-content/uploads" (within open_basedir, as it should) * - "/wp-uploads" symlinks to "/var/www/website/wordpress") * - Wordpress has been configured to use "/wp-uploads" path for uploads. * * What happens? * First, it is tested if the configured upload path ("/wp-uploads") begins with the configured document root ("/var/www/website"). * This fails. * Next, it is tested if the uploads path can be resolved. It can, as it is within the open_basedir. * Next, it is tested if the *resolved* the uploads path begins with the configured document root. * As "/var/www/website/wordpress/wp-content/uploads" begins with "/var/www/website", we have a match. * The relative path can be calculated to be "wordpress/wp-content/uploads". * Later, when the relative path is used, it will be used as $docRoot + "/" + $relPath, which * will be "/var/www/website/wordpress/wp-content/uploads". All is well. * * Here is a case where it CAN NOT be calculated: * - Document root is configured to "/the-website", which symlinks to "/var/www/website" * - open_basedir is set to "/var/www/website/wordpress" * - uploads is in "/var/www/website/wordpress/wp-content/uploads" and wordpress is configured to use that upload path. * * What happens? * First, it is tested if the configured upload path begins with the configured document root * "/var/www/website/wordpress/wp-content/uploads" does not begin with "/the-website", so it fails. * Next, it is tested if the *resolved* the uploads path begins with the configured document root. * The resolved uploads path is the same as the configured so it also fails. * Next, it is tested if Document root can be resolved. It can not, as the resolved path is not within open_basedir. * If it could, it would have been tested if the resolved path begins with the resolved document root and we would have * gotten a yes, and the relative path would have been "wordpress/wp-content/uploads" and it would work. * However: Document root could not be resolved and we could not get a result. * To sum the scenario up: * If document root is configured to a symlink which cannot be resolved then it will only be possible to get relative paths * when all other configured paths begins are relative to that symlink. */ public static function canUseDocRootForRelPaths() { if (!PathHelper::isDocRootAvailable()) { return false; } return self::areAllImageRootsWithinDocRoot(); } public static function canCalculateRelPathFromDocRootToDir($absPath) { } /** * Check if we can use document root for structuring the cache dir. * * In order to structure the images by doc-root, WebP Express needs all images to be within document root. * Does WebP Express in addition to this need to be able to resolve document root? * Short answer is yes. * The long answer is available as a comment inside ConvertHelperIndependent::getDestination() * */ public static function canUseDocRootForStructuringCacheDir() { return (PathHelper::isDocRootAvailableAndResolvable() && self::canUseDocRootForRelPaths()); } public static function docRootStatusText() { if (!PathHelper::isDocRootAvailable()) { if (!isset($_SERVER['DOCUMENT_ROOT'])) { return 'Unavailable (DOCUMENT_ROOT is not set in the global $_SERVER var)'; } if ($_SERVER['DOCUMENT_ROOT'] == '') { return 'Unavailable (empty string)'; } return 'Unavailable'; } $imageRootsWithin = self::canUseDocRootForRelPaths(); if (!PathHelper::isDocRootAvailableAndResolvable()) { $status = 'Available, but either non-existing or not within open_basedir.' . ($imageRootsWithin ? '' : ' And not all image roots are within that document root.'); } elseif (!$imageRootsWithin) { $status = 'Available, but not all image roots are within that document root.'; } else { $status = 'Available and its "realpath" is available too.'; } if (self::canUseDocRootForStructuringCacheDir()) { $status .= ' Can be used for structuring cache dir.'; } else { $status .= ' Cannot be used for structuring cache dir.'; } return $status; } public static function getAbsDirId($absDir) { switch ($absDir) { case self::getContentDirAbs(): return 'wp-content'; case self::getIndexDirAbs(): return 'index'; case self::getHomeDirAbs(): return 'home'; case self::getPluginDirAbs(): return 'plugins'; case self::getUploadDirAbs(): return 'uploads'; case self::getThemesDirAbs(): return 'themes'; case self::getCacheDirAbs(): return 'cache'; } return false; } public static function getAbsDirById($dirId) { switch ($dirId) { case 'wp-content': return self::getContentDirAbs(); case 'index': return self::getIndexDirAbs(); case 'home': // "home" is still needed (used in PluginDeactivate.php) return self::getHomeDirAbs(); case 'plugins': return self::getPluginDirAbs(); case 'uploads': return self::getUploadDirAbs(); case 'themes': return self::getThemesDirAbs(); case 'cache': return self::getCacheDirAbs(); } return false; } /** * Get ids for folders where SOURCE images may reside */ public static function getImageRootIds() { return ['uploads', 'themes', 'plugins', 'wp-content', 'index']; } /** * Find which rootId a path belongs to. * * Note: If the root ids passed are ordered the way getImageRootIds() returns them, the root id * returned will be the "deepest" */ public static function findImageRootOfPath($path, $rootIdsToSearch) { foreach ($rootIdsToSearch as $rootId) { if (PathHelper::isPathWithinExistingDirPath($path, self::getAbsDirById($rootId))) { return $rootId; } } return false; } public static function getImageRootsDefForSelectedIds($ids) { $canUseDocRootForRelPaths = self::canUseDocRootForRelPaths(); $mapping = []; foreach ($ids as $rootId) { $obj = [ 'id' => $rootId, ]; $absPath = self::getAbsDirById($rootId); if ($canUseDocRootForRelPaths) { $obj['rel-path'] = PathHelper::getRelPathFromDocRootToDirNoDirectoryTraversalAllowed($absPath); } else { $obj['abs-path'] = $absPath; } $obj['url'] = self::getUrlById($rootId); $mapping[] = $obj; } return $mapping; } public static function getImageRootsDef() { return self::getImageRootsDefForSelectedIds(self::getImageRootIds()); } public static function filterOutSubRoots($rootIds) { // Get dirs of enabled roots $dirs = []; foreach ($rootIds as $rootId) { $dirs[] = self::getAbsDirById($rootId); } // Filter out dirs which are below other dirs $dirsToSkip = []; foreach ($dirs as $dirToExamine) { foreach ($dirs as $dirToCompareAgainst) { if ($dirToExamine == $dirToCompareAgainst) { continue; } if (self::isDirInsideDir($dirToExamine, $dirToCompareAgainst)) { $dirsToSkip[] = $dirToExamine; break; } } } $dirs = array_diff($dirs, $dirsToSkip); // back to ids $result = []; foreach ($dirs as $dir) { $result[] = self::getAbsDirId($dir); } return $result; } public static function createDirIfMissing($dir) { if (!@file_exists($dir)) { // We use the wp_mkdir_p, because it takes care of setting folder // permissions to that of parent, and handles creating deep structures too wp_mkdir_p($dir); } return file_exists($dir); } /** * Find out if $dir1 is inside - or equal to - $dir2 */ public static function isDirInsideDir($dir1, $dir2) { $rel = PathHelper::getRelDir($dir2, $dir1); return (substr($rel, 0, 3) != '../'); } /** * Return absolute dir. * * - Path is canonicalized (without resolving symlinks) * - trailing dash is removed - we don't use that around here. * * We do not resolve symlinks anymore. Information was lost that way. * And in some cases we needed the unresolved path - for example in the .htaccess. */ public static function getAbsDir($dir) { $dir = PathHelper::canonicalize($dir); return rtrim($dir, '/'); /* $result = realpath($dir); if ($result === false) { $dir = PathHelper::canonicalize($dir); } else { $dir = $result; }*/ } // ------------ Home Dir ------------- // PS: Home dir is not the same as index dir. // For example, if Wordpress folder has been moved (method 2), the home dir could be below. public static function getHomeDirAbs() { if (!function_exists('get_home_path')) { require_once ABSPATH . 'wp-admin/includes/file.php'; } return self::getAbsDir(get_home_path()); } // ------------ Index Dir (WP root dir) ------------- // (The Wordpress installation dir- where index.php and wp-load.php resides) public static function getIndexDirAbs() { // We used to return self::getAbsDir(ABSPATH), which used realpath. // It has been changed now, as it seems we do not need realpath for ABSPATH, as it is defined // (in wp-load.php) as dirname(__FILE__) . "/" and according to this link, __FILE__ returns resolved paths: // https://stackoverflow.com/questions/3221771/how-do-you-get-php-symlinks-and-file-to-work-together-nicely // AND a user reported an open_basedir restriction problem thrown by realpath($_SERVER['DOCUMENT_ROOT']), // due to symlinking and opendir restriction (see #322) return rtrim(ABSPATH, '/'); // TODO: read up on this, regarding realpath: // https://github.com/twigphp/Twig/issues/2707 } // ------------ .htaccess dir ------------- // (directory containing the relevant .htaccess) // (see https://github.com/rosell-dk/webp-express/issues/36) public static function canWriteHTAccessRulesHere($dirName) { return FileHelper::canEditOrCreateFileHere($dirName . '/.htaccess'); } public static function canWriteHTAccessRulesInDir($dirId) { return self::canWriteHTAccessRulesHere(self::getAbsDirById($dirId)); } public static function returnFirstWritableHTAccessDir($dirs) { foreach ($dirs as $dir) { if (self::canWriteHTAccessRulesHere($dir)) { return $dir; } } return false; } // ------------ Content Dir (the "WP" content dir) ------------- public static function getContentDirAbs() { return self::getAbsDir(WP_CONTENT_DIR); } public static function getContentDirRel() { return PathHelper::getRelPathFromDocRootToDirNoDirectoryTraversalAllowed(self::getContentDirAbs()); } public static function getContentDirRelToPluginDir() { return PathHelper::getRelDir(self::getPluginDirAbs(), self::getContentDirAbs()); } public static function getContentDirRelToWebPExpressPluginDir() { return PathHelper::getRelDir(self::getWebPExpressPluginDirAbs(), self::getContentDirAbs()); } public static function isWPContentDirMoved() { return (self::getContentDirAbs() != (ABSPATH . 'wp-content')); } public static function isWPContentDirMovedOutOfAbsPath() { return !(self::isDirInsideDir(self::getContentDirAbs(), ABSPATH)); } // ------------ Themes Dir ------------- public static function getThemesDirAbs() { return self::getContentDirAbs() . '/themes'; } // ------------ WebPExpress Content Dir ------------- // (the "webp-express" directory inside wp-content) public static function getWebPExpressContentDirAbs() { return self::getContentDirAbs() . '/webp-express'; } public static function getWebPExpressContentDirRel() { return PathHelper::getRelPathFromDocRootToDirNoDirectoryTraversalAllowed(self::getWebPExpressContentDirAbs()); } public static function createContentDirIfMissing() { return self::createDirIfMissing(self::getWebPExpressContentDirAbs()); } // ------------ Upload Dir ------------- public static function getUploadDirAbs() { $upload_dir = wp_upload_dir(null, false); return self::getAbsDir($upload_dir['basedir']); } public static function getUploadDirRel() { return PathHelper::getRelPathFromDocRootToDirNoDirectoryTraversalAllowed(self::getUploadDirAbs()); } /* public static function getUploadDirAbs() { if ( defined( 'UPLOADS' ) ) { return ABSPATH . rtrim(UPLOADS, '/'); } else { return self::getContentDirAbs() . '/uploads'; } }*/ public static function isUploadDirMovedOutOfWPContentDir() { return !(self::isDirInsideDir(self::getUploadDirAbs(), self::getContentDirAbs())); } public static function isUploadDirMovedOutOfAbsPath() { return !(self::isDirInsideDir(self::getUploadDirAbs(), ABSPATH)); } // ------------ Config Dir ------------- public static function getConfigDirAbs() { return self::getWebPExpressContentDirAbs() . '/config'; } public static function getConfigDirRel() { return PathHelper::getRelPathFromDocRootToDirNoDirectoryTraversalAllowed(self::getConfigDirAbs()); } public static function createConfigDirIfMissing() { $configDir = self::getConfigDirAbs(); // Using code from Wordfence bootstrap.php... // Why not simply use wp_mkdir_p ? - it sets the permissions to same as parent. Isn't that better? // or perhaps not... - Because we need write permissions in the config dir. if (!is_dir($configDir)) { @mkdir($configDir, 0775); @chmod($configDir, 0775); @file_put_contents(rtrim($configDir . '/') . '/.htaccess', <<<APACHE <IfModule mod_authz_core.c> Require all denied </IfModule> <IfModule !mod_authz_core.c> Order deny,allow Deny from all </IfModule> APACHE ); @chmod($configDir . '/.htaccess', 0664); } return is_dir($configDir); } public static function getConfigFileName() { return self::getConfigDirAbs() . '/config.json'; } public static function getWodOptionsFileName() { return self::getConfigDirAbs() . '/wod-options.json'; } // ------------ Cache Dir ------------- public static function getCacheDirAbs() { return self::getWebPExpressContentDirAbs() . '/webp-images'; } public static function getCacheDirRelToDocRoot() { return PathHelper::getRelPathFromDocRootToDirNoDirectoryTraversalAllowed(self::getCacheDirAbs()); } public static function getCacheDirForImageRoot($destinationFolder, $destinationStructure, $imageRootId) { if (($destinationFolder == 'mingled') && ($imageRootId == 'uploads')) { return self::getUploadDirAbs(); } if ($destinationStructure == 'doc-root') { $relPath = PathHelper::getRelPathFromDocRootToDirNoDirectoryTraversalAllowed( self::getAbsDirById($imageRootId) ); return self::getCacheDirAbs() . '/doc-root/' . $relPath; } else { return self::getCacheDirAbs() . '/' . $imageRootId; } } public static function createCacheDirIfMissing() { return self::createDirIfMissing(self::getCacheDirAbs()); } // ------------ Log Dir ------------- public static function getLogDirAbs() { return self::getWebPExpressContentDirAbs() . '/log'; } // ------------ Bigger-than-source dir ------------- public static function getBiggerThanSourceDirAbs() { return self::getWebPExpressContentDirAbs() . '/webp-images-bigger-than-source'; } // ------------ Plugin Dir (all plugins) ------------- public static function getPluginDirAbs() { return self::getAbsDir(WP_PLUGIN_DIR); } public static function isPluginDirMovedOutOfAbsPath() { return !(self::isDirInsideDir(self::getPluginDirAbs(), ABSPATH)); } public static function isPluginDirMovedOutOfWpContent() { return !(self::isDirInsideDir(self::getPluginDirAbs(), self::getContentDirAbs())); } // ------------ WebP Express Plugin Dir ------------- public static function getWebPExpressPluginDirAbs() { return self::getAbsDir(WEBPEXPRESS_PLUGIN_DIR); } // ------------------------------------ // --------- Url paths ---------- // ------------------------------------ /** * Get url path (relative to domain) from absolute url. * Ie: "http://example.com/blog" => "blog" * Btw: By "url path" we shall always mean relative to domain * By "url" we shall always mean complete URL (with domain and everything) * (or at least something that starts with it...) * * Also note that in this library, we never returns trailing or leading slashes. */ public static function getUrlPathFromUrl($url) { $parsed = parse_url($url); if (!isset($parsed['path'])) { return ''; } if (is_null($parsed['path'])) { return ''; } $path = untrailingslashit($parsed['path']); return ltrim($path, '/\\'); } public static function getUrlById($dirId) { switch ($dirId) { case 'wp-content': return self::getContentUrl(); case 'index': return self::getHomeUrl(); case 'home': return self::getHomeUrl(); case 'plugins': return self::getPluginsUrl(); case 'uploads': return self::getUploadUrl(); case 'themes': return self::getThemesUrl(); } return false; } /** * Get destination root url and path, provided rootId and some configuration options * * This method kind of establishes the overall structure of the cache dir. * (but not quite, as the logic is also in ConverterHelperIndependent::getDestination). * * @param string $rootId * @param DestinationOptions $destinationOptions * * @return array url and abs-path of destination root */ public static function destinationRoot($rootId, $destinationOptions) { if (($destinationOptions->mingled) && ($rootId == 'uploads')) { return [ 'url' => self::getUrlById('uploads'), 'abs-path' => self::getUploadDirAbs() ]; } else { // Its within these bases: $destUrl = self::getUrlById('wp-content') . '/webp-express/webp-images'; $destPath = self::getAbsDirById('wp-content') . '/webp-express/webp-images'; if (($destinationOptions->useDocRoot) && self::canUseDocRootForStructuringCacheDir()) { $relPathFromDocRootToSourceImageRoot = PathHelper::getRelPathFromDocRootToDirNoDirectoryTraversalAllowed( self::getAbsDirById($rootId) ); return [ 'url' => $destUrl . '/doc-root/' . $relPathFromDocRootToSourceImageRoot, 'abs-path' => $destPath . '/doc-root/' . $relPathFromDocRootToSourceImageRoot ]; } else { $extraPath = ''; if (is_multisite() && (get_current_blog_id() != 1)) { $extraPath = '/sites/' . get_current_blog_id(); // #510 } return [ 'url' => $destUrl . '/' . $rootId . $extraPath, 'abs-path' => $destPath . '/' . $rootId . $extraPath ]; } } } public static function getRootAndRelPathForDestination($destinationPath, $imageRoots) { foreach ($imageRoots->getArray() as $i => $imageRoot) { $rootPath = $imageRoot->getAbsPath(); if (strpos($destinationPath, realpath($rootPath)) !== false) { $relPath = substr($destinationPath, strlen(realpath($rootPath)) + 1); return [$imageRoot->id, $relPath]; } } return ['', '']; } // PST: // appendOrSetExtension() have been copied from ConvertHelperIndependent. // TODO: I should complete the move ASAP. /** * Append ".webp" to path or replace extension with "webp", depending on what is appropriate. * * If destination-folder is set to mingled and destination-extension is set to "set" and * the path is inside upload folder, the appropriate thing is to SET the extension. * Otherwise, it is to APPEND. * * @param string $path * @param string $destinationFolder * @param string $destinationExt * @param boolean $inUploadFolder */ public static function appendOrSetExtension($path, $destinationFolder, $destinationExt, $inUploadFolder) { if (($destinationFolder == 'mingled') && ($destinationExt == 'set') && $inUploadFolder) { return preg_replace('/\\.(jpe?g|png)$/i', '', $path) . '.webp'; } else { return $path . '.webp'; } } /** * Get destination root url and path, provided rootId and some configuration options * * This method kind of establishes the overall structure of the cache dir. * (but not quite, as the logic is also in ConverterHelperIndependent::getDestination). * * @param string $rootId * @param string $relPath * @param string $destinationFolder ("mingled" or "separate") * @param string $destinationExt ('append' or 'set') * @param string $destinationStructure ("doc-root" or "image-roots") * * @return array url and abs-path of destination */ /* public static function destinationPath($rootId, $relPath, $destinationFolder, $destinationExt, $destinationStructure) { // TODO: Current logic will not do! // We must use ConvertHelper::getDestination for the abs path. // And we must use logic from AlterHtmlHelper to get the URL // Perhaps this method must be abandonned $root = self::destinationRoot($rootId, $destinationFolder, $destinationStructure); $inUploadFolder = ($rootId == 'upload'); $relPath = ConvertHelperIndependent::appendOrSetExtension($relPath, $destinationFolder, $destinationExt, $inUploadFolder); return [ 'abs-path' => $root['abs-path'] . '/' . $relPath, 'url' => $root['url'] . '/' . $relPath, ]; } public static function destinationPathConvenience($rootId, $relPath, $config) { return self::destinationPath( $rootId, $relPath, $config['destination-folder'], $config['destination-extension'], $config['destination-structure'] ); }*/ public static function getDestinationPathCorrespondingToSource($source, $destinationOptions) { return Destination::getDestinationPathCorrespondingToSource( $source, Paths::getWebPExpressContentDirAbs(), Paths::getUploadDirAbs(), $destinationOptions, new ImageRoots(self::getImageRootsDef()) ); } public static function getUrlPathById($dirId) { return self::getUrlPathFromUrl(self::getUrlById($dirId)); } public static function getHostNameOfUrl($url) { $urlComponents = parse_url($url); /* ie: ( [scheme] => http [host] => we0 [path] => /wordpress/uploads-moved )*/ if (!isset($urlComponents['host'])) { return ''; } else { return $urlComponents['host']; } } // Get complete home url (no trailing slash). Ie: "http://example.com/blog" public static function getHomeUrl() { if (!function_exists('home_url')) { // silence is golden? // bad joke. Need to handle this... } return untrailingslashit(home_url()); } /** Get home url, relative to domain. Ie "" or "blog" * If home url is for example http://example.com/blog/, the result is "blog" */ public static function getHomeUrlPath() { return self::getUrlPathFromUrl(self::getHomeUrl()); } public static function getUploadUrl() { $uploadDir = wp_upload_dir(null, false); return untrailingslashit($uploadDir['baseurl']); } public static function getUploadUrlPath() { return self::getUrlPathFromUrl(self::getUploadUrl()); } public static function getContentUrl() { return untrailingslashit(content_url()); } public static function getContentUrlPath() { return self::getUrlPathFromUrl(self::getContentUrl()); } public static function getThemesUrl() { return self::getContentUrl() . '/themes'; } /** * Get Url to plugins (base) */ public static function getPluginsUrl() { return untrailingslashit(plugins_url()); } /** * Get Url to WebP Express plugin (this is in fact an incomplete URL, you need to append ie '/webp-on-demand.php' to get a full URL) */ public static function getWebPExpressPluginUrl() { return untrailingslashit(plugins_url('', WEBPEXPRESS_PLUGIN)); } public static function getWebPExpressPluginUrlPath() { return self::getUrlPathFromUrl(self::getWebPExpressPluginUrl()); } public static function getWodFolderUrlPath() { return self::getWebPExpressPluginUrlPath() . '/wod'; } public static function getWod2FolderUrlPath() { return self::getWebPExpressPluginUrlPath() . '/wod2'; } public static function getWodUrlPath() { return self::getWodFolderUrlPath() . '/webp-on-demand.php'; } public static function getWod2UrlPath() { return self::getWod2FolderUrlPath() . '/webp-on-demand.php'; } public static function getWebPRealizerUrlPath() { return self::getWodFolderUrlPath() . '/webp-realizer.php'; } public static function getWebPRealizer2UrlPath() { return self::getWod2FolderUrlPath() . '/webp-realizer.php'; } public static function getWebServiceUrl() { //return self::getWebPExpressPluginUrl() . '/wpc.php'; //return self::getHomeUrl() . '/webp-express-server'; return self::getHomeUrl() . '/webp-express-web-service'; } public static function getUrlsAndPathsForTheJavascript() { return [ 'urls' => [ 'webpExpressRoot' => self::getWebPExpressPluginUrlPath(), 'content' => self::getContentUrlPath(), ], 'filePaths' => [ 'webpExpressRoot' => self::getWebPExpressPluginDirAbs(), 'destinationRoot' => self::getCacheDirAbs(), ] ]; } public static function getSettingsUrl() { if (!function_exists('admin_url')) { require_once ABSPATH . 'wp-includes/link-template.php'; } if (Multisite::isNetworkActivated()) { // network_admin_url is also defined in link-template.php. return network_admin_url('settings.php?page=webp_express_settings_page'); } else { return admin_url('options-general.php?page=webp_express_settings_page'); } } }