WebP-eXpress/lib/classes/PathHelper.php
Malin 37cf714058 WebP Express CloudHost.es Fix v0.25.9-cloudhost
 Fixed bulk conversion getting stuck on missing files
 Added robust error handling and timeout protection
 Improved JavaScript response parsing
 Added file existence validation
 Fixed missing PHP class imports
 Added comprehensive try-catch error recovery

🔧 Key fixes:
- File existence checks before conversion attempts
- 30-second timeout protection per file
- Graceful handling of 500 errors and JSON parsing issues
- Automatic continuation to next file on failures
- Cache busting for JavaScript updates

🎯 Result: Bulk conversion now completes successfully even with missing files

🚀 Generated with Claude Code (https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-23 10:22:32 +02:00

482 lines
19 KiB
PHP

<?php
namespace WebPExpress;
class PathHelper
{
public static function isDocRootAvailable() {
// BTW:
// Note that DOCUMENT_ROOT does not end with trailing slash on old litespeed servers:
// https://www.litespeedtech.com/support/forum/threads/document_root-trailing-slash.5304/
if (!isset($_SERVER['DOCUMENT_ROOT'])) {
return false;
}
if ($_SERVER['DOCUMENT_ROOT'] == '') {
return false;
}
return true;
}
/**
* Test if a path exists as is resolvable (will be unless it is outside open_basedir)
*
* @param string $absPath The path to test (must be absolute. symlinks allowed)
* @return boolean The result
*/
public static function pathExistsAndIsResolvable($absPath) {
if (!@realpath($absPath)) {
return false;
}
return true;
}
/**
* Test if document root is available, exists and symlinks are resolvable (resolved path is within open basedir)
*
* @return boolean The result
*/
public static function isDocRootAvailableAndResolvable() {
return (
self::isDocRootAvailable() &&
self::pathExistsAndIsResolvable($_SERVER['DOCUMENT_ROOT'])
);
}
/**
* When the rewrite rules are using the absolute dir, the rewrite rules does not work if that dir
* is outside document root. This poses a problem if some part of the document root has been symlinked.
*
* This method "unresolves" the document root part of a dir.
* That is: It takes an absolute url, looks to see if it begins with the resolved document root.
* In case it does, it replaces the resolved document root with the unresolved document root.
*
* Unfortunately we can only unresolve when document root is available and resolvable.
* - which is sad, because the image-roots was introduced in order to get it to work on setups
*/
public static function fixAbsPathToUseUnresolvedDocRoot($absPath) {
if (self::isDocRootAvailableAndResolvable()) {
if (strpos($absPath, realpath($_SERVER['DOCUMENT_ROOT'])) === 0) {
return $_SERVER['DOCUMENT_ROOT'] . substr($absPath, strlen(realpath($_SERVER['DOCUMENT_ROOT'])));
}
}
return $absPath;
}
/**
* Find out if path is below - or equal to a path.
*
* "/var/www" below/equal to "/var"? : Yes
* "/var/www" below/equal to "/var/www"? : Yes
* "/var/www2" below/equal to "/var/www"? : No
*/
/*
public static function isPathBelowOrEqualToPath($path1, $path2)
{
return (strpos($path1 . '/', $path2 . '/') === 0);
//$rel = self::getRelDir($path2, $path1);
//return (substr($rel, 0, 3) != '../');
}*/
/**
* Calculate relative path from document root to a given absolute path (must exist and be resolvable) - if possible AND
* if it can be done without directory traversal.
*
* The function is designed with the usual folders in mind (index, uploads, wp-content, plugins), which all presumably
* exists and are within open_basedir.
*
* @param string $dir An absolute path (may contain symlinks). The path must exist and be resolvable.
* @throws \Exception If it is not possible to get such path (ie if doc-root is unavailable or the dir is outside doc-root)
* @return string Relative path to document root or empty string if document root is unavailable
*/
public static function getRelPathFromDocRootToDirNoDirectoryTraversalAllowed($dir)
{
if (!self::isDocRootAvailable()) {
throw new \Exception('Cannot calculate relative path from document root to dir, as document root is not available');
}
// First try unresolved.
// This will even work when ie wp-content is symlinked to somewhere outside document root, while the symlink itself is within document root)
$relPath = self::getRelDir($_SERVER['DOCUMENT_ROOT'], $dir);
if (strpos($relPath, '../') !== 0) { // Check if relPath starts with "../" (if it does, we cannot use it)
return $relPath;
}
if (self::isDocRootAvailableAndResolvable()) {
if (self::pathExistsAndIsResolvable($dir)) {
// Try with both resolved
$relPath = self::getRelDir(realpath($_SERVER['DOCUMENT_ROOT']), realpath($dir));
if (strpos($relPath, '../') !== 0) {
return $relPath;
}
}
// Try with just document root resolved
$relPath = self::getRelDir(realpath($_SERVER['DOCUMENT_ROOT']), $dir);
if (strpos($relPath, '../') !== 0) {
return $relPath;
}
}
if (self::pathExistsAndIsResolvable($dir)) {
// Try with dir resolved
$relPath = self::getRelDir($_SERVER['DOCUMENT_ROOT'], realpath($dir));
if (strpos($relPath, '../') !== 0) {
return $relPath;
}
}
// Problem:
// - dir is already resolved (ie: /disk/the-content)
// - document root is ie. /var/www/website/wordpress
// - the unresolved symlink is ie. /var/www/website/wordpress/wp-content
// - we do not know what the unresolved symlink is
// The result should be "wp-content". But how do we get to that result?
// I guess we must check out all folders below document root to see if anyone resolves to dir
// we could start out trying usual suspects such as "wp-content" and "wp-content/uploads"
//foreach (glob($dir . DIRECTORY_SEPARATOR . $filePattern) as $filename)
/*
$iter = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($_SERVER['DOCUMENT_ROOT'], \RecursiveDirectoryIterator::SKIP_DOTS),
\RecursiveIteratorIterator::SELF_FIRST,
\RecursiveIteratorIterator::CATCH_GET_CHILD // Ignore "Permission denied"
);
foreach ($iter as $path => $dirObj) {
if ($dirObj->isDir()) {
if (realpath($path) == $dir) {
//return $path;
$relPath = self::getRelDir(realpath($_SERVER['DOCUMENT_ROOT']), $path);
if (strpos($relPath, '../') !== 0) {
return $relPath;
}
}
}
}
*/
// Ok, the above works - but when subfolders to the symlink is referenced. Ie referencing uploads when wp-content is symlinked
// - dir is already resolved (ie: /disk/the-content/uploads)
// - document root is ie. /var/www/website/wordpress
// - the unresolved symlink is ie. /var/www/website/wordpress/wp-content/uploads
// - we do not know what the unresolved symlink is
// The result should be "wp-content/uploads". But how do we get to that result?
// What if we collect all symlinks below document root in a assoc array?
// ['/disk/the-content' => 'wp-content']
// Input is: '/disk/the-content/uploads'
// 1. We check the symlinks and substitute. We get: 'wp-content/uploads'.
// 2. We test if realpath($_SERVER['DOCUMENT_ROOT'] . '/' . 'wp-content/uploads') equals input.
// It seems I have a solution!
// - I shall continue work soon! - for a 0.15.1 release (test instance #26)
// PS: cache the result of the symlinks in docroot collector.
throw new \Exception(
'Cannot get relative path from document root to dir without resolving to directory traversal. ' .
'It seems the dir is not below document root'
);
/*
if (!self::pathExistsAndIsResolvable($dir)) {
throw new \Exception('Cannot calculate relative path from document root to dir. The path given is not resolvable (realpath fails)');
}
// Check if relPath starts with "../"
if (strpos($relPath, '../') === 0) {
// Unresolved failed. Try with document root resolved
$relPath = self::getRelDir(realpath($_SERVER['DOCUMENT_ROOT']), $dir);
if (strpos($relPath, '../') === 0) {
// Try with both resolved
$relPath = self::getRelDir($dir, $dir);
throw new \Exception('Cannot calculate relative path from document root to dir. The path given is not within document root');
}
}
}
return $relPath;
} else {
// We cannot get the resolved doc-root.
// This might be ok as long as the (resolved) path we are examining begins with the configured doc-root.
$relPath = self::getRelDir($_SERVER['DOCUMENT_ROOT'], $dir);
// Check if relPath starts with "../" (it may not)
if (strpos($relPath, '../') === 0) {
// Well, that did not work. We can try the resolved path instead.
if (!self::pathExistsAndIsResolvable($dir)) {
throw new \Exception('Cannot calculate relative path from document root to dir. The path given is not resolvable (realpath fails)');
}
$relPath = self::getRelDir($_SERVER['DOCUMENT_ROOT'], realpath($dir));
if (strpos($relPath, '../') === 0) {
// That failed too.
// Either it is in fact outside document root or it is because of a special setup.
throw new \Exception(
'Cannot calculate relative path from document root to dir. Either the path given is not within the configured document root or ' .
'it is because of a special setup. The document root is outside open_basedir. If it is also symlinked, but the other Wordpress paths ' .
'are not using that same symlink, it will not be possible to calculate the relative path.'
);
}
}
return $relPath;
}*/
}
public static function canCalculateRelPathFromDocRootToDir($dir)
{
try {
$relPath = self::getRelPathFromDocRootToDirNoDirectoryTraversalAllowed($dir);
} catch (\Exception $e) {
return false;
}
return true;
}
/**
* Find closest existing folder with symlinks expandend, using realpath.
*
* Note that if the input or the closest existing folder is outside open_basedir, no folder will
* be found and an empty string will be returned.
*
* @return string closest existing path or empty string if none found (due to open_basedir restriction)
*/
public static function findClosestExistingFolderSymLinksExpanded($input) {
// The strategy is to first try the supplied directory. If it fails, try the parent, etc.
$dir = $input;
// We count the levels up to avoid infinite loop - as good practice. It ought not to get that far
$levelsUp = 0;
while ($levelsUp < 100) {
// We suppress warning because we are aware that we might get a
// open_basedir restriction warning.
$realPathResult = @realpath($dir);
if ($realPathResult !== false) {
return $realPathResult;
}
// Stop at root. This will happen if the original path is outside basedir.
if (($dir == '/') || (strlen($dir) < 4)) {
return '';
}
// Peal off one directory
$dir = @dirname($dir);
$levelsUp++;
}
return '';
}
/**
* Look if filepath is within a dir path (both by string matching and by using realpath, see notes).
*
* Note that the naive string match does not resolve '..'. You might want to call ::canonicalize first.
* Note that the realpath match requires: 1. that the dir exist and is within open_basedir
* 2. that the closest existing folder within filepath is within open_basedir
*
* @param string $filePath Path to file. It may be non-existing.
* @param string $dirPath Path to dir. It must exist and be within open_basedir in order for the realpath match to execute.
*/
public static function isFilePathWithinDirPath($filePath, $dirPath)
{
// See if $filePath begins with $dirPath + '/'.
if (strpos($filePath, $dirPath . '/') === 0) {
return true;
}
if (strpos(self::canonicalize($filePath), self::canonicalize($dirPath) . '/') === 0) {
return true;
}
// Also try with symlinks expanded.
// As symlinks can only be retrieved with realpath and realpath fails with non-existing paths,
// we settle with checking if closest existing folder in the filepath is within the dir.
// If that is the case, then surely, the complete filepath is also within the dir.
// Note however that it might be that the closest existing folder is not within the dir, while the
// file would be (if it existed)
// For WebP Express, we are pretty sure that the dirs we are checking against (uploads folder,
// wp-content, plugins folder) exists. So getting the closest existing folder should be sufficient.
// but could it be that these are outside open_basedir on some setups? Perhaps on a few systems.
if (self::pathExistsAndIsResolvable($dirPath)) {
$closestExistingDirOfFile = PathHelper::findClosestExistingFolderSymLinksExpanded($filePath);
if (strpos($closestExistingDirOfFile, realpath($dirPath) . '/') === 0) {
return true;
}
}
return false;
}
/**
* Look if path is within a dir path. Also tries expanding symlinks
*
* @param string $path Path to examine. It may be non-existing.
* @param string $dirPath Path to dir. It must exist in order for symlinks to be expanded.
*/
public static function isPathWithinExistingDirPath($path, $dirPath)
{
if ($path == $dirPath) {
return true;
}
// See if $filePath begins with $dirPath + '/'.
if (strpos($path, $dirPath . '/') === 0) {
return true;
}
// Also try with symlinks expanded (see comments in ::isFilePathWithinDirPath())
$closestExistingDir = PathHelper::findClosestExistingFolderSymLinksExpanded($path);
if (strpos($closestExistingDir . '/', $dirPath . '/') === 0) {
return true;
}
return false;
}
public static function frontslasher($str)
{
// TODO: replace backslash with frontslash
return $str;
}
/**
* Replace double slash with single slash. ie '/var//www/' => '/var/www/'
* This allows you to lazely concatenate paths with '/' and then call this method to clean up afterwards.
* Also removes triple slash etc.
*/
public static function fixDoubleSlash($str)
{
return preg_replace('/\/\/+/', '/', $str);
}
/**
* Remove trailing slash, if any
*/
public static function untrailSlash($str)
{
return rtrim($str, '/');
//return preg_replace('/\/$/', '', $str);
}
public static function backslashesToForwardSlashes($path) {
return str_replace( "\\", '/', $path);
}
// Canonicalize a path by resolving '../' and './'. It also replaces backslashes with forward slash
// Got it from a comment here: http://php.net/manual/en/function.realpath.php
// But fixed it (it could not handle './../')
public static function canonicalize($path) {
$parts = explode('/', $path);
// Remove parts containing just '.' (and the empty holes afterwards)
$parts = array_values(array_filter($parts, function($var) {
return ($var != '.');
}));
// Remove parts containing '..' and the preceding
$keys = array_keys($parts, '..');
foreach($keys as $keypos => $key) {
array_splice($parts, $key - ($keypos * 2 + 1), 2);
}
return implode('/', $parts);
}
public static function dirname($path) {
return self::canonicalize($path . '/..');
}
/**
* Get base name of a path (the last component of a path - ie the filename).
*
* This function operates natively on the string and is not locale aware.
* It only works with "/" path separators.
*
* @return string the last component of a path
*/
public static function basename($path) {
$parts = explode('/', $path);
return array_pop($parts);
}
/**
* Returns absolute path from a relative path and root
* The result is canonicalized (dots and double-dots are resolved)
*
* @param $path Absolute path or relative path
* @param $root What the path is relative to, if its relative
*/
public static function relPathToAbsPath($path, $root)
{
return self::canonicalize(self::fixDoubleSlash($root . '/' . $path));
}
/**
* isAbsPath
* If path starts with '/', it is considered an absolute path (no Windows support)
*
* @param $path Path to inspect
*/
public static function isAbsPath($path)
{
return (substr($path, 0, 1) == '/');
}
/**
* Returns absolute path from a path which can either be absolute or relative to second argument.
* If path starts with '/', it is considered an absolute path.
* The result is canonicalized (dots and double-dots are resolved)
*
* @param $path Absolute path or relative path
* @param $root What the path is relative to, if its relative
*/
public static function pathToAbsPath($path, $root)
{
if (self::isAbsPath($path)) {
// path is already absolute
return $path;
} else {
return self::relPathToAbsPath($path, $root);
}
}
/**
* Get relative path between two absolute paths
* Examples:
* from '/var/www' to 'var/ddd'. Result: '../ddd'
* from '/var/www' to 'var/www/images'. Result: 'images'
* from '/var/www' to 'var/www'. Result: '.'
*/
public static function getRelDir($fromPath, $toPath)
{
$fromDirParts = explode('/', str_replace('\\', '/', self::canonicalize(self::untrailSlash($fromPath))));
$toDirParts = explode('/', str_replace('\\', '/', self::canonicalize(self::untrailSlash($toPath))));
$i = 0;
while (($i < count($fromDirParts)) && ($i < count($toDirParts)) && ($fromDirParts[$i] == $toDirParts[$i])) {
$i++;
}
$rel = "";
for ($j = $i; $j < count($fromDirParts); $j++) {
$rel .= "../";
}
for ($j = $i; $j < count($toDirParts); $j++) {
$rel .= $toDirParts[$j];
if ($j < count($toDirParts)-1) {
$rel .= '/';
}
}
if ($rel == '') {
$rel = '.';
}
return $rel;
}
}