<?php namespace Resmush\FileSystem\Model\File; use Resmush\ShortpixelLogger\ShortPixelLogger as Log; if ( ! defined( 'ABSPATH' ) ) { exit; // Exit if accessed directly. } /* FileModel class. * * * - Represents a -single- file. * - Can handle any type * - Usually controllers would use a collection of files * - Meant for all low-level file operations and checks. * - Every file can have a backup counterpart. * */ class FileModel { // File info protected $fullpath = null; protected $rawfullpath = null; protected $filename = null; // filename + extension protected $filebase = null; // filename without extension protected $directory = null; protected $extension = null; protected $mime = null; protected $permissions = null; protected $filesize = null; // File Status protected $exists = null; protected $is_writable = null; protected $is_directory_writable = null; protected $is_readable = null; protected $is_file = null; protected $is_virtual = false; protected $is_restricted = false; protected $virtual_status = null; protected $status; // seems unused ? const FILE_OK = 1; const FILE_UNKNOWN_ERROR = 2; public static $TRUSTED_MODE = false; // Constants for is_virtual . Virtual Remote is truly a remote file, not writable from machine. Stateless means it looks remote, but it's a protocol-based filesystem remote or not - that will accept writes / is_writable. Stateless also mean performance issue since it can't be 'translated' to a local path. All communication happens over http wrapper, so check should be very limited. public static $VIRTUAL_REMOTE = 1; public static $VIRTUAL_STATELESS = 2; /** Creates a file model object. FileModel files don't need to exist on FileSystem */ public function __construct($path) { $this->rawfullpath = $path; if (is_null($path)) { Log::addWarn('FileModel: Loading null path! '); return false; } if (strlen($path) > 0) $path = trim($path); $this->fullpath = $path; $this->checkTrustedMode(); $fs = $this->getFS(); if ($fs->pathIsUrl($path)) // Asap check for URL's to prevent remote wrappers from running. { $this->UrlToPath($path); } } private function getFS() { return new \Resmush\FileSystem\Controller\FileSystemController(); } /* Get a string representation of file, the fullpath * Note - this might be risky, without processedpath, in cases. * @return String Full path processed or unprocessed. */ public function __toString() { return (string) $this->fullpath; } protected function setFileInfo() { $processed_path = $this->processPath($this->fullpath); if ($processed_path !== false) $this->fullpath = $processed_path; // set processed path if that went alright $info = $this->mb_pathinfo($this->fullpath); // Todo, maybe replace this with splFileINfo. if ($this->is_file()) // only set fileinfo when it's an actual file. { $this->filename = isset($info['basename']) ? $info['basename'] : null; // filename + extension $this->filebase = isset($info['filename']) ? $info['filename'] : null; // only filename $this->extension = isset($info['extension']) ? strtolower($info['extension']) : null; // only (last) extension } } /** Call when file status changed, so writable / readable / exists are not reliable anymore */ public function resetStatus() { $this->is_writable = null; $this->is_directory_writable = null; $this->is_readable = null; $this->is_file = null; $this->is_restricted = null; $this->exists = null; $this->is_virtual = null; $this->filesize = null; $this->permissions = null; } /** * @param $forceCheck Forces a filesystem check instead of using cached. Use very sparingly. Implemented for retina on trusted mode. */ public function exists($forceCheck = false) { if (true === $forceCheck || is_null($this->exists)) { if (true === $this->fileIsRestricted($this->fullpath)) { $this->exists = false; } else { $this->exists = (@file_exists($this->fullpath) && is_file($this->fullpath)); } } $this->exists = apply_filters('shortpixel_image_exists', $this->exists, $this->fullpath, $this); //legacy $this->exists = apply_filters('resmush/file/exists', $this->exists, $this->fullpath, $this); return $this->exists; } public function is_writable() { // Return when already asked / Stateless might set this if (! is_null($this->is_writable)) { return $this->is_writable; } elseif ($this->is_virtual()) { $this->is_writable = false; // can't write to remote files } elseif (is_null($this->is_writable)) { if ($this->exists()) { $this->is_writable = @is_writable($this->fullpath); } else // quite expensive check to see if file is writable. { $res = $this->create(); $this->delete(); $this->is_writable = $res; } } return $this->is_writable; } public function is_directory_writable() { // Return when already asked / Stateless might set this if (! is_null($this->is_directory_writable)) { return $this->is_directory_writable; } elseif ($this->is_virtual()) { $this->is_directory_writable = false; // can't write to remote files } elseif (is_null($this->is_directory_writable)) { $directory = $this->getFileDir(); if (is_object($directory) && $directory->exists()) { $this->is_directory_writable = $directory->is_writable(); } else { $this->is_directory_writable = false; } } return $this->is_directory_writable; } public function is_readable() { if (is_null($this->is_readable)) $this->is_readable = @is_readable($this->fullpath); return $this->is_readable; } // A file is virtual when the file is remote with URL and no local alternative is present. public function is_virtual() { if ( is_null($this->is_virtual)) $this->is_virtual = false; // return bool return $this->is_virtual; } /* Function checks if path is actually a file. This can be used to check possible confusion if a directory path is given to filemodel */ public function is_file() { if ($this->is_virtual()) // don't look further when virtual { $this->is_file = true; return $this->is_file; } elseif (is_null($this->is_file)) { if ($this->exists()) { if (basename($this->fullpath) == '..' || basename($this->fullpath) == '.') $this->is_file = false; else $this->is_file = is_file($this->fullpath); } else // file can not exist, but still have a valid filepath format. In that case, if file should return true. { /* if file does not exist on disk, anything can become a file ( with/ without extension, etc). Meaning everything non-existing is a potential file ( or directory ) until created. */ if (basename($this->fullpath) == '..' || basename($this->fullpath) == '.') // don't see this as file. { $this->is_file = false; } else if (! file_exists($this->fullpath) && ! is_dir($this->fullpath)) { $this->is_file = true; } else //if (! is_file($this->fullpath)) // can be a non-existing directory. / { $this->is_file = false; } } } return $this->is_file; } public function getModified() { return filemtime($this->fullpath); } /** Returns the Directory Model this file resides in * * @return DirectoryModel Directorymodel Object */ public function getFileDir() { $fullpath = $this->getFullPath(); // triggers a file lookup if needed. // create this only when needed. if (is_null($this->directory) && strlen($fullpath) > 0) { // Feed to full path to DirectoryModel since it checks if input is file, or dir. Using dirname here would cause errors when fullpath is already just a dirpath ( faulty input ) $this->directory = new DirectoryModel($fullpath); } return $this->directory; } public function getFileSize() { if (! is_null($this->filesize)) { return $this->filesize; } elseif ($this->exists() && false === $this->is_virtual() ) { $this->filesize = filesize($this->fullpath); return $this->filesize; } elseif (true === $this->is_virtual()) { return -1; } else return 0; } // Creates an empty file public function create() { if (! $this->exists() ) { $fileDir = $this->getFileDir(); if (! is_null($fileDir) && $fileDir->exists()) { $res = @touch($this->fullpath); $this->exists = $res; return $res; } } else Log::addWarn('Could not create/write file: ' . $this->fullpath); return false; } public function append($message) { if (! $this->exists() ) $this->create(); if (! $this->is_writable() ) { Log::addWarn('File append failed on ' . $this->getFullPath() . ' - not writable'); return false; } $handle = fopen($this->getFullPath(), 'a'); fwrite($handle, $message); fclose($handle); return true; } /** Copy a file to somewhere * * @param $destination String Full Path to new file. */ public function copy(FileModel $destination) { $sourcePath = $this->getFullPath(); $destinationPath = $destination->getFullPath(); Log::addDebug("Copy from $sourcePath to $destinationPath "); if (! strlen($sourcePath) > 0 || ! strlen($destinationPath) > 0) { Log::addWarn('Attempted Copy on Empty Path', array($sourcePath, $destinationPath)); return false; } if (! $this->exists()) { Log::addWarn('Tried to copy non-existing file - ' . $sourcePath); return false; } $is_new = ($destination->exists()) ? false : true; $status = @copy($sourcePath, $destinationPath); if (! $status) { Log::addWarn('Could not copy file ' . $sourcePath . ' to' . $destinationPath); } else { $destination->resetStatus(); $destination->setFileInfo(); // refresh info. } // do_action('resmush/filesystem/addfile', array($destinationPath, $destination, $this, $is_new)); return $status; } /** Move a file to somewhere * This uses copy and delete functions and will fail if any of those fail. * @param $destination String Full Path to new file. */ public function move(FileModel $destination) { $result = false; if ($this->copy($destination)) { $result = $this->delete(); if ($result == false) { Log::addError('Move can\'t remove file ' . $this->getFullPath()); } $this->resetStatus(); $destination->resetStatus(); } return $result; } /** Deletes current file * This uses the WP function since it has a filter that might be useful */ public function delete() { if ($this->exists()) { \wp_delete_file($this->fullpath); // delete file hook via wp_delete_file } else { Log::addWarn('Trying to remove non-existing file: ' . $this->getFullPath()); } if (! file_exists($this->fullpath)) { $this->resetStatus(); return true; } else { $writable = ($this->is_writable()) ? 'true' : 'false'; Log::addWarn('File seems not removed - ' . $this->getFullPath() . ' (writable:' . $writable . ')'); return false; } } public function getContents() { return file_get_contents($this->getFullPath()); } public function getFullPath() { // filename here since fullpath is set unchecked in constructor, but might be a different take if (is_null($this->filename)) { $this->setFileInfo(); } return $this->fullpath; } // Testing this. Principle is that when the plugin is absolutely sure this is a file, not something remote, not something non-existing, get the fullpath without any check. // This function should *only* be used when processing mega amounts of files while not doing optimization or any processing. // So far, testing use for file Filter */ public function getRawFullPath() { return $this->rawfullpath; } public function getFileName() { if (is_null($this->filename)) $this->setFileInfo(); return $this->filename; } public function getFileBase() { if (is_null($this->filebase)) $this->setFileInfo(); return $this->filebase; } public function getExtension() { if (is_null($this->extension)) $this->setFileInfo(); return $this->extension; } public function getMime() { if (is_null($this->mime)) $this->setFileInfo(); if ($this->exists() && ! $this->is_virtual() ) { $this->mime = wp_get_image_mime($this->fullpath); if (false === $this->mime) { $image_data = wp_check_filetype_and_ext($this->getFullPath(), $this->getFileName()); if (is_array($image_data) && isset($image_data['type']) && strlen($image_data['type']) > 0) { $this->mime = $image_data['type']; } } } else $this->mime = false; return $this->mime; } /* Internal function to check if path is a real path * - Test for URL's based on http / https * - Test if given path is absolute, from the filesystem root. * @param $path String The file path * @param String The Fixed filepath. */ protected function processPath($path) { $original_path = $path; $fs = $this->getFS(); if ($fs->pathIsUrl($path)) { $path = $this->UrlToPath($path); } if ($path === false) // don't process further return false; //$path = wp_normalize_path($path); $abspath = $fs->getWPAbsPath(); // Prevent file operation below if trusted. if (true === self::$TRUSTED_MODE) { return $path; } // Check if some openbasedir is active. if (true === $this->fileIsRestricted($path)) { $path = $this->relativeToFullPath($path); } if ( is_file($path) && ! is_dir($path) ) // if path and file exist, all should be okish. { return $path; } // If attempted file does not exist, but the file is in a dir that exists, that is good enough. elseif ( ! is_dir($path) && is_dir(dirname($path)) ) { return $path; } // If path is not in the abspath, it might be relative. elseif (strpos($path, $abspath->getPath()) === false) { // if path does not contain basepath. //$uploadDir = $fs->getWPUploadBase(); //$abspath = $fs->getWPAbsPath(); $path = $this->relativeToFullPath($path); } $path = apply_filters('resmush/filesystem/processFilePath', $path, $original_path); /* This needs some check here on malformed path's, but can't be test for existing since that's not a requirement. if (file_exists($path) === false) // failed to process path to something workable. { // Log::addInfo('Failed to process path', array($path)); $path = false; } */ return $path; } protected function checkTrustedMode() { // When in trusted mode prevent filesystem checks as much as possible. if (true === self::$TRUSTED_MODE) { // At this point file info might not be loaded, because it goes w/ construct -> processpath -> urlToPath etc on virtual files. And called via getFileInfo. Using any of the file info functions can trigger a loop. if (is_null($this->extension)) { $extension = pathinfo($this->fullpath, PATHINFO_EXTENSION); } else { $extension = $this->getExtension(); } $this->exists = true; $this->is_writable = true; $this->is_directory_writable = true; $this->is_readable = true; $this->is_file = true; // Set mime to prevent lookup in IsImage $this->mime = 'image/' . $extension; if (is_null($this->filesize)) { $this->filesize = 0; } } } /** Check if path is allowed within openbasedir restrictions. This is an attempt to limit notices in file funtions if so. Most likely the path will be relative in that case. * @param String Path as String */ private function fileIsRestricted($path) { if (! is_null($this->is_restricted)) { return $this->is_restricted; } $basedir = ini_get('open_basedir'); if (false === $basedir || strlen($basedir) == 0) { return false; } $restricted = true; $basedirs = preg_split('/:|;/i', $basedir); foreach($basedirs as $basepath) { if (strpos($path, $basepath) !== false) { $restricted = false; break; } } // Allow this to be overridden due to specific server configs ( ie symlinks ) might get this flagged falsely. $restricted = apply_filters('resmush/file/basedir_check', $restricted); $this->is_restricted = $restricted; return $restricted; } /** Resolve an URL to a local path * This partially comes from WordPress functions attempting the same * @param String $url The URL to resolve * @return String/Boolean - False is this seems an external domain, otherwise resolved path. */ private function UrlToPath($url) { //$uploadDir = wp_upload_dir(); // If files is present, high chance that it's WPMU old style, which doesn't have in home_url the /files/ needed to properly replace and get the filepath . It would result in a /files/files path which is incorrect. if (strpos($url, '/files/') !== false) { $uploadDir = wp_upload_dir(); $site_url = str_replace(array('http:', 'https:'), '', $uploadDir['baseurl']); } else { $site_url = str_replace('http:', '', home_url('', 'http')); } $url = str_replace(array('http:', 'https:'), '', $url); $fs = $this->getFS(); if (strpos($url, $site_url) !== false) { // try to replace URL for Path $abspath = $this->getFS()->getWPAbsPath(); $path = str_replace($site_url, rtrim($abspath->getPath(),'/'), $url); if (! $fs->pathIsUrl($path)) // test again. { return $path; } } $this->is_virtual = true; /* This filter checks if some supplier will be able to handle the file when needed. * Use translate filter to correct filepath when needed. * Return could be true, or fileModel virtual constant */ $result = apply_filters('resmush/image/urltopath', false, $url); if ($result === false) { $this->exists = false; $this->is_readable = false; $this->is_file = false; } else { $this->exists = true; $this->is_readable = true; $this->is_file = true; } // If return is a stateless server, assume that it's writable and all that. if ($result === self::$VIRTUAL_STATELESS) { $this->is_writable = true; $this->is_directory_writable = true; $this->virtual_status = self::$VIRTUAL_STATELESS; } elseif ($result === self::$VIRTUAL_REMOTE) { $this->virtual_status = self::$VIRTUAL_REMOTE; } return false; // seems URL from other server, use virtual mode. } /** Tries to find the full path for a perceived relative path. * * Relative path is detected on basis of WordPress ABSPATH. If this doesn't appear in the file path, it might be a relative path. * Function checks for expections on this rule ( tmp path ) and returns modified - or not - path. * @param $path The path for the file_exists * @returns String The updated path, if that was possible. */ private function relativeToFullPath($path) { $originalPath = $path; // for safe-keeping // A file with no path, can never be created to a fullpath. if (strlen($path) == 0) return $path; // if the file plainly exists, it's usable /** if (false === $this->fileIsRestricted($path) && file_exists($path)) { return $path; } // Test if our 'relative' path is not a path to /tmp directory. // This ini value might not exist. $tempdirini = ini_get('upload_tmp_dir'); if ( (strlen($tempdirini) > 0) && strpos($path, $tempdirini) !== false) return $path; $tempdir = sys_get_temp_dir(); if ( (strlen($tempdir) > 0) && strpos($path, $tempdir) !== false) return $path; // Path contains upload basedir. This happens when upload dir is outside of usual WP. $fs = $this->getFS(); $uploadDir = $fs->getWPUploadBase(); $abspath = $fs->getWPAbsPath(); if (strpos($path, $uploadDir->getPath()) !== false) // If upload Dir is feature in path, consider it ok. { return $path; } elseif (file_exists($abspath->getPath() . $path)) // If upload dir is abspath plus return path. Exceptions. { return $abspath->getPath() . $path; } elseif(file_exists($uploadDir->getPath() . $path)) // This happens when upload_dir is not properly prepended in get_attachment_file due to WP errors { return $uploadDir->getPath() . $path; } // this is probably a bit of a sharp corner to take. // if path starts with / remove it due to trailingslashing ABSPATH $path = ltrim($path, '/'); $fullpath = $abspath->getPath() . $path; // We can't test for file_exists here, since file_model allows non-existing files. // Test if directory exists, perhaps. Otherwise we are in for a failure anyhow. //if (is_dir(dirname($fullpath))) return $fullpath; //else // return $originalPath; } public function getPermissions() { if (is_null($this->permissions)) $this->permissions = fileperms($this->getFullPath()) & 0777; return $this->permissions; } // @tozo Lazy IMplementation / copy, should be brought in line w/ other attributes. public function setPermissions($permissions) { @chmod($this->fullpath, $permissions); } /** Fix for multibyte pathnames and pathinfo which doesn't take into regard the locale. * This snippet taken from PHPMailer. */ private function mb_pathinfo($path, $options = null) { $ret = ['dirname' => '', 'basename' => '', 'extension' => '', 'filename' => '']; $pathinfo = []; if (preg_match('#^(.*?)[\\\\/]*(([^/\\\\]*?)(\.([^.\\\\/]+?)|))[\\\\/.]*$#m', $path, $pathinfo)) { if (array_key_exists(1, $pathinfo)) { $ret['dirname'] = $pathinfo[1]; } if (array_key_exists(2, $pathinfo)) { $ret['basename'] = $pathinfo[2]; } if (array_key_exists(5, $pathinfo)) { $ret['extension'] = $pathinfo[5]; } if (array_key_exists(3, $pathinfo)) { $ret['filename'] = $pathinfo[3]; } } switch ($options) { case PATHINFO_DIRNAME: case 'dirname': return $ret['dirname']; case PATHINFO_BASENAME: case 'basename': return $ret['basename']; case PATHINFO_EXTENSION: case 'extension': return $ret['extension']; case PATHINFO_FILENAME: case 'filename': return $ret['filename']; default: return $ret; } } public function __debuginfo() { return [ 'fullpath' => $this->fullpath, 'filename' => $this->filename, 'filebase' => $this->filebase, 'exists' => $this->exists, 'is_writable' => $this->is_writable, 'is_readable' => $this->is_readable, 'is_virtual' => $this->is_virtual, ]; } } // FileModel Class