artabro/wire/core/PagefilesManager.php
2024-08-27 11:35:37 +02:00

811 lines
22 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php namespace ProcessWire;
/**
* ProcessWire PagefilesManager
*
* #pw-summary Manages files and file directories for a page independent of a particular field.
* #pw-summary-static These methods are not connected with a particular instance and may only be called statically.
* #pw-body =
* Files in ProcessWire are always connected with a particular `Field` on a `Page`. This is typically
* a `FieldtypeFile` field or a `FieldtypeImage` field, which exist as `Pagefiles` or `Pageimages`
* values on the Page. Sometimes it is necessary to manage all files connected with a page as a
* group, and this files manager class provides that. Likewise, something needs to manage the paths
* and URLs where these files live, and that is where this files manager comes into play as well.
*
* **Summary of what PagefilesManager does**
*
* - Provides methods for movement of all files connected with a page as a group.
* - Ensures that file directories for a page are created (and removed) when applicable.
* - Manages secured vs. normal page file paths (see `$config->pagefileSecure`).
* - Manages extended vs. normal page file paths (see `$config->pagefileExtendedPaths`).
*
* **How to access the Page files manager**
*
* The Page files manager can be accessed from any pages `Page::filesManager()` method or property.
*
* ~~~~~
* // Example of getting a Pages dedicated file path and URL
* $filesPath = $page->filesManager->path();
* $filesURL = $page->filesManager->url();
* ~~~~~
*
* #pw-body
*
* ProcessWire 3.x, Copyright 2022 by Ryan Cramer
* https://processwire.com
*
* @method save() #pw-hooker
* @property string $path
* @property string $url
* @property Page $page Page that this files manager is for.
*
*
*/
class PagefilesManager extends Wire {
/**
* Default prefix for secure paths when not defined by config.php
*
*/
const defaultSecurePathPrefix = '.';
/**
* Prefix to all extended path directories
*
*/
const extendedDirName = '0/';
/**
* Name of file that maintains the last modification time independent of directory (LATER/FUTURE)
*
const metaFileName = '.pw';
*/
/**
* Count of renamed paths when changing between pagefileSecure and non-pagefileSecure
*
* @var int
*
*/
static $numRenamedPaths = 0;
/**
* Reference to the Page object this PagefilesManager is managing
*
* @var Page
*
*/
protected $page;
/**
* Cached copy of $path, once it has been determined
*
* @var string|null
*
*/
protected $path = null;
/**
* Cached copy of $url, once it has been determined
*
* @var string|null
*
*/
protected $url = null;
/**
* Construct the PagefilesManager and ensure all needed paths are created
*
* @param Page $page
*
*/
public function __construct(Page $page) {
parent::__construct();
$page->wire($this);
$this->init($page);
}
/**
* Initialize the PagefilesManager with a Page
*
* Same as construct, but separated for when cloning a page
*
* #pw-internal
*
* @param Page $page
*
*/
public function init(Page $page) {
$this->setPage($page);
$this->path = null; // to uncache, if this PagefilesManager has been cloned
$this->createPath();
}
/**
* Set the page
*
* #pw-internal
*
* @param Page $page
*
*/
public function setPage(Page $page) {
$this->page = $page;
}
/**
* Get an array of all published filenames on the current Page.
*
* @return array Array of file basenames
*
*/
public function getFiles() {
$files = array();
foreach(new \DirectoryIterator($this->path()) as $file) {
if($file->isDot() || $file->isDir()) continue;
// if($file->isFile()) $files[] = $file->getBasename(); // PHP 5.2.2
if($file->isFile()) $files[] = $file->getFilename();
}
return $files;
}
/**
* Get the Pagefile object containing the given filename.
*
* @param string $name Name of file to get:
* - If given a URL or path, this will traverse to other pages.
* - If given a basename, it will stay with current page.
* @return Pagefile|Pageimage|null Returns Pagefile or Pageimage object if found, or null if not.
*
*/
public function getFile($name) {
$pagefile = null;
if(strpos($name, '/') !== false) {
$pageID = self::dirToPageID($name);
if(!$pageID) return null;
if($pageID == $this->page->id) {
$page = $this->page;
} else {
$page = $this->wire()->pages->get($pageID);
}
if(!$page->id) return null;
} else {
$page = $this->page;
}
foreach($page->fieldgroup as $field) {
/** @var Field $field */
if(!$field->type instanceof FieldtypeFile) continue;
$pagefiles = $page->get($field->name);
// if mapping to single file, ask it for the parent array
if($pagefiles instanceof Pagefile) $pagefiles = $pagefiles->pagefiles;
if($pagefiles instanceof Pagefiles) $pagefile = $pagefiles->getFile($name);
if(!$pagefile) continue;
break;
}
return $pagefile;
}
/**
* Recursively copy all files in $fromPath to $toPath, for internal use
*
* @param string $fromPath Path to copy from
* @param string $toPath Path to copy to
* @param bool $rename Rename files rather than copy? (makes this perform like a move rather than copy)
* @return int Number of files copied
*
*/
protected function _copyFiles($fromPath, $toPath, $rename = false) {
if(!is_dir($toPath)) return 0;
$numCopied = 0;
$fromPath = rtrim($fromPath, '/') . '/';
$toPath = rtrim($toPath, '/') . '/';
foreach(new \DirectoryIterator($fromPath) as $file) {
if($file->isDot()) continue;
if($file->isDir()) {
$fromDir = $file->getPathname();
$toDir = $toPath . $file->getFilename() . '/';
if($rename && rename($fromDir, $toDir)) {
$numCopied++;
} else {
$this->_createPath($toDir);
$numCopied += $this->_copyFiles($fromDir, $toDir, $rename);
if($rename) wireRmdir($fromDir, true); // this line not likely to ever be executed
}
} else if($file->isFile()) {
$fromFile = $file->getPathname();
$toFile = $toPath . $file->getFilename();
$success = $rename ? rename($fromFile, $toFile) : copy($fromFile, $toFile);
if($success) {
$numCopied++;
// $this->message("Copied $fromFile => $toFile", Notice::debug);
} else {
$this->error("Failed to copy: $fromFile");
}
}
}
return $numCopied;
}
/**
* Recursively copy all files managed by this PagefilesManager into a new path.
*
* #pw-group-manipulation
*
* @param $toPath string Path to copy files into.
* @return int Number of files/directories copied.
*
*/
public function copyFiles($toPath) {
return $this->_copyFiles($this->path(), $toPath);
}
/**
* Copy/import files from given path into the pages files directory
*
* #pw-group-manipulation
*
* @param string $fromPath Path to copy/import files from.
* @param bool $move Move files into directory rather than copy?
* @return int Number of files/directories copied.
* @since 3.0.114
*
*/
public function importFiles($fromPath, $move = false) {
return $this->_copyFiles($fromPath, $this->path(), $move);
}
/**
* Replace all pages files with those from given path
*
* #pw-group-manipulation
*
* @param string $fromPath
* @param bool $move Move files to destination rather than copy? (default=false)
* @return int Number of files/directories copied.
* @throws WireException if given a path that does not exist.
* @since 3.0.114
*
*
*/
public function replaceFiles($fromPath, $move = false) {
if(!is_dir($fromPath)) throw new WireException("Path does not exist: $fromPath");
$this->emptyPath();
return $this->_copyFiles($fromPath, $this->path(), $move);
}
/**
* Recursively move all files managed by this PagefilesManager into a new path.
*
* #pw-group-manipulation
*
* @param $toPath string Path to move files into.
* @return int Number of files/directories moved.
*
*/
public function moveFiles($toPath) {
$this->_createPath($toPath);
return $this->_copyFiles($this->path(), $toPath, true);
}
/**
* Create a directory with proper permissions, for internal use.
*
* @param string $path Path to create
* @return bool True on success, false if not
*
*/
protected function _createPath($path) {
if(empty($path)) return false;
if(is_dir("$path")) return true;
return $this->wire()->files->mkdir($path, true);
}
/**
* Create the directory path where published files will be stored.
*
* #pw-internal
*
* @return bool True on success, false if not
*
*/
public function createPath() {
return $this->_createPath($this->path());
}
/**
* Empty out the published files (delete all of them)
*
* #pw-group-manipulation
*
* @param bool $rmdir Remove the directory too? (default=false)
* @param bool $recursive Recursively do the same for subdirectories? (default=true)
* @return bool True on success, false on error (since 3.0.17, previous versions had no return value).
*
*/
public function emptyPath($rmdir = false, $recursive = true) {
$files = $this->wire()->files;
$path = $this->path();
if(!is_dir($path)) return true;
$errors = 0;
if($recursive) {
// clear out path and everything below it
if(!$files->rmdir($path, true, true)) $errors++;
if(!$rmdir) $this->_createPath($path);
} else {
// only clear out files in path
foreach(new \DirectoryIterator($path) as $file) {
if($file->isDot() || $file->isDir()) continue;
if(!$files->unlink($file->getPathname(), true)) $errors++;
}
if($rmdir) {
$files->rmdir($path, false, true); // will not be successful if other dirs within it
}
}
return $errors === 0;
}
/**
* Empties all file paths related to the Page, and removes the directories
*
* This is the same as calling the `PagefilesManager:emptyPath()` method with the
* `$rmdir` and `$recursive` options as both true.
*
* #pw-group-manipulation
*
* @return bool True on success, false on error (since 3.0.17, previous versions had no return value).
*
*/
public function emptyAllPaths() {
return $this->emptyPath(true);
}
/**
* Get the published path for files
*
* #pw-hooks
*
* @return string
* @throws WireException if attempting to access this on a Page that doesn't yet exist in the database
*
*/
public function path() {
return $this->wire()->hooks->isHooked('PagefilesManager::path()') ? $this->__call('path', array()) : $this->___path();
}
/**
* Get the published path (for use with hooks)
*
* #pw-internal
*
* @return string
* @throws WireException if attempting to access this on a Page that doesn't yet exist in the database
*
*/
public function ___path() {
if(is_null($this->path)) {
if(!$this->page->id) throw new WireException("New page '{$this->page->url}' must be saved before files can be accessed from it");
$this->path = self::_path($this->page);
}
return $this->path;
}
/**
* Get the published URL for files
*
* #pw-hooks
*
* @return string
* @throws WireException if attempting to access this on a Page that doesn't yet exist in the database
*
*/
public function url() {
return $this->wire()->hooks->isHooked('PagefilesManager::url()') ? $this->__call('url', array()) : $this->___url();
}
/**
* Return the auto-determined URL, hookable version
*
* #pw-internal
*
* Note: your hook can get the $page from $event->object->page;
*
* @return string
* @throws WireException if attempting to access this on a Page that doesn't yet exist in the database
*
*/
public function ___url() {
if(!is_null($this->url)) return $this->url;
$config = $this->wire()->config;
if(strpos($this->path(), $config->paths->files . self::extendedDirName) !== false) {
$this->url = $config->urls->files . self::_dirExtended($this->page->id);
} else {
$this->url = $config->urls->files . $this->page->id . '/';
}
return $this->url;
}
/**
* For hooks to listen to on page save action, for file-specific operations
*
* Executed before a page draft/published assets are moved around, when changes to files may be best to execute.
*
* There are no arguments or return values here.
* Hooks may retrieve the Page object being saved from `$event->object->page`.
*
* #pw-hooker
*
*/
public function ___save() { }
/**
* Uncache/unload any data that should be unloaded with the page
*
* #pw-internal
*
*/
public function uncache() {
$this->url = null;
$this->path = null;
// $this->page = null;
}
/**
* Handle non-function versions of some properties
*
* @param string $name
* @return mixed
*
*/
public function __get($name) {
if($name === 'path') return $this->path();
if($name === 'url') return $this->url();
if($name === 'page') return $this->page;
return parent::__get($name);
}
/**
* Returns true if Page has a files path that exists.
*
* This is a way to for `$pages` API functions (or others) to check if they should attempt to use
* a $page's filesManager, thus ensuring directories aren't created for pages that don't need them.
*
* #pw-group-static
*
* @param Page $page
* @return bool True if a path exists for the page, false if not.
*
*/
static public function hasPath(Page $page) {
return is_dir(self::_path($page));
}
/**
* Returns true if Page has a path and files, false if not.
*
* #pw-group-static
*
* @param Page $page
* @return bool True if $page has a path and files
*
*/
static public function hasFiles(Page $page) {
if(!self::hasPath($page)) return false;
$dir = opendir(self::_path($page));
if(!$dir) return false;
$has = false;
while(!$has && ($f = readdir($dir)) !== false) $has = $f !== '..' && $f !== '.';
closedir($dir);
// while(!$has && ($f = readdir($dir)) !== false) $has = $f !== '..' && $f !== '.' && !$f !== self::metaFileName;
return $has;
}
/**
* Does given Page have the given file? Checks in a silent manner, not creating anything.
*
* @param Page $page
* @param string $file Filename (basename) excluding path
* @param bool $getPathname Return the full pathname to the file (rather than true) if it exists? (default=false)
* @return bool|string
* @since 3.0.166
*
*/
static public function hasFile(Page $page, $file, $getPathname = false) {
$path = self::_path($page);
if(!is_dir($path)) return false;
$file = basename($file);
$file = str_replace(array('\\', '/', '..'), '', $file);
$pathname = $path . $file;
if(!file_exists($pathname)) return false;
if($getPathname) return $pathname;
return true;
}
/**
* Get the files path for a given page (whether it exists or not).
*
* #pw-group-static
*
* @param Page $page
* @param bool $extended Whether to force use of extended paths, primarily for recursive use by this function only.
* @return string
*
*/
static public function _path(Page $page, $extended = false) {
$config = $page->wire()->config;
$path = $config->paths->files;
$securePrefix = $config->pagefileSecurePathPrefix;
if(!strlen($securePrefix)) $securePrefix = self::defaultSecurePathPrefix;
if($extended) {
$publicPath = $path . self::_dirExtended($page->id);
$securePath = $path . self::_dirExtended($page->id, $securePrefix);
} else {
$publicPath = $path . $page->id . '/';
$securePath = $path . $securePrefix . $page->id . '/';
}
$secureFiles = $page->secureFiles();
if($secureFiles === false) {
// use the public path, renaming a secure path to public if it exists
if(is_dir($securePath) && !is_dir($publicPath)) {
$page->wire()->files->rename($securePath, $publicPath);
self::$numRenamedPaths++;
}
$filesPath = $publicPath;
} else if($secureFiles === null) {
$filesPath = $publicPath;
} else {
// use the secure path, renaming the public to secure if it exists
$hasSecurePath = is_dir($securePath);
if(is_dir($publicPath) && !$hasSecurePath) {
$page->wire()->files->rename($publicPath, $securePath);
self::$numRenamedPaths++;
} else if(!$hasSecurePath && self::defaultSecurePathPrefix != $securePrefix) {
// we track this just in case the prefix was newly added to config.php, this prevents
// losing track of the original directories
if($extended) {
$securePath2 = $path . self::_dirExtended($page->id, self::defaultSecurePathPrefix);
} else {
$securePath2 = $path . self::defaultSecurePathPrefix . $page->id . '/';
}
if(is_dir($securePath2)) {
// if the secure path prefix has been changed from undefined to defined
$page->wire()->files->rename($securePath2, $securePath);
self::$numRenamedPaths++;
}
}
$filesPath = $securePath;
}
if(!$extended && $config->pagefileExtendedPaths && !is_dir($filesPath)) {
// if directory doesn't exist and extended mode is possible, specify use of the extended one
$filesPath = self::_path($page, true);
}
return $filesPath;
}
/**
* Get all potential disk paths for given Page files (not yet in use)
*
* @todo FOR FUTURE USE
* @param Page $page
* @return string[]
*
static public function _paths(Page $page) {
$config = $page->wire()->config;
$path = $config->paths->files;
$securePrefix = $config->pagefileSecurePathPrefix;
$useSecure = $page->secureFiles();
$useExtended = $config->pagefileExtendedPaths;
$useUnique = $config->pagefileUnique && $page->hasStatus(Page::statusUnique);
if(!strlen($securePrefix)) $securePrefix = self::defaultSecurePathPrefix;
$paths = array(
'current' => '',
'normal' => $path . "$page->id/",
'unique' => $path . "0/$page->name/",
'extended' => $path . self::_dirExtended($page->id),
'secure' => $path . $securePrefix . "$page->id/",
'secureUnique' => $path . "0/$securePrefix$page->name/",
'secureExtended' => $path . self::_dirExtended($page->id, $securePrefix),
);
if($useUnique) {
// use unique page name paths
$paths['current'] = ($useSecure ? $paths['secureUnique'] : $paths['unique']);
} else if($useSecure) {
// use secure files
$paths['current'] = ($useExtended ? $paths['secureExtended'] : $paths['secure']);
} else {
// use normal path
$paths['current'] = ($useExtended ? $paths['extended'] : $paths['normal']);
}
return $paths;
}
*/
/**
* Scan all paths for page and make sure only the correct one exists (not yet in use)
*
* Also crates and moves files when necessary.
*
* @todo FOR FUTURE USE
* @param Page $page
*
private function verifyPaths(Page $page) {
$paths = self::_paths($page);
$current = $paths['current'];
$currentExists = is_dir($current);
unset($paths['current']);
foreach($paths as $path) {
if(!is_dir($path)) continue;
if(!$currentExists) {
$this->_createPath($current);
$currentExists = true;
}
$this->_copyFiles($path, $current);
$this->wire()->files->rmdir($path, true);
self::$numRenamedPaths++;
}
}
*/
/**
* Get quantity of renamed paths to to pagefileSecure changes
*
* #pw-internal
*
* @param bool $reset Also reset to 0?
* @return int
* @since 3.0.166
*
*/
static public function numRenamedPaths($reset = false) {
$num = self::$numRenamedPaths;
if($reset) self::$numRenamedPaths = 0;
return $num;
}
/**
* Generate the directory name (after /site/assets/files/)
*
* #pw-internal
*
* @param int $id
* @param string $securePrefix Optional prefix to use for last segment in path
* @return string
*
*/
static public function _dirExtended($id, $securePrefix = '') {
$len = strlen($id);
if($len > 3) {
if($len % 2 === 0) {
$id = "0$id"; // ensure all segments are 2 chars
$len++;
}
$path = chunk_split(substr($id, 0, $len-3), 2, '/') . $securePrefix . substr($id, $len-3);
} else if($len < 3) {
$path = $securePrefix . str_pad($id, 3, "0", STR_PAD_LEFT);
} else {
$path = $securePrefix . $id;
}
return self::extendedDirName . $path . '/';
}
/**
* Given a dir (URL or path) to a files directory, return the page ID associated with it.
*
* #pw-group-static
*
* @param string $dir May be extended or regular directory, path or URL.
* @return int
*
*/
static public function dirToPageID($dir) {
$parts = explode('/', $dir);
$pageID = '';
$securePrefix = wire()->config->pagefileSecurePathPrefix;
if(!strlen($securePrefix)) $securePrefix = self::defaultSecurePathPrefix;
foreach(array_reverse($parts) as $key => $part) {
$part = ltrim($part, $securePrefix);
if(!ctype_digit($part)) {
if(!$key) continue; // first item, likely a filename, skip it
break; // not first item means end of ID sequence
}
$pageID = $part . $pageID;
}
return (int) $pageID;
}
/**
* Return a path where temporary files can be stored unique to this ProcessWire instance
*
* @return string
*
*/
public function getTempPath() {
static $wtd = null;
if(is_null($wtd)) {
$wtd = new WireTempDir();
$this->wire($wtd);
$wtd->setMaxAge(3600);
$name = $wtd->createName('PFM');
$wtd->init($name);
}
return $wtd->get();
// if(is_null($wtd)) $wtd = $this->wire(new WireTempDir($this->className() . $this->page->id));
// return $wtd->get();
}
/**
* Have this pages files had modifications since last isModified(true) call? (FUTURE USE)
*
* Please note the following:
*
* - This only takes into account files in the actual directory and not subdirectories unless
* the $recursive option is true.
*
* - This method always returns true the first time it has been called on a given path.
*
* @param bool $reset Reset to current time if modified? Ensures future calls return false until modified again. (default=false)
* @param bool $recursive Descend into directories (max 1 level)? (default=false)
* @param string $path Path to check if not default, primarily for internal recursive use. (default='')
* @return bool True if files in directory have been modified since last reset, or false if not
*
public function isModified($reset = false, $recursive = false, $path = '') {
$files = $this->wire('files');
$path = empty($path) ? $this->path() : $files->unixDirName($path);
$file = $path . self::metaFileName;
if(!file_exists($file)) {
touch($file);
$files->chmod($file);
$isModified = true;
} else {
$fileTime = filemtime($file);
$pathTime = filemtime($path);
$isModified = $pathTime > $fileTime;
if($isModified && $reset) touch($file);
}
if($recursive && !$isModified) {
$dirs = array();
foreach(new \DirectoryIterator($path) as $item) {
if($item->isDot() || !$item->isDir()) continue;
$dirs[] = $item->getPathname();
}
foreach($dirs as $dir) {
$isModified = $this->isModified($reset, false, $dir);
if($isModified) break;
}
}
return $isModified;
}
*/
}