artabro/wire/modules/Process/ProcessModule/ProcessModuleInstall.php
2024-08-27 11:35:37 +02:00

578 lines
18 KiB
PHP

<?php namespace ProcessWire;
/**
* Installation helper for ProcessModule
*
* Provides methods for internative module installation for ProcessModule
*
* ProcessWire 3.x, Copyright 2020 by Ryan Cramer
* https://processwire.com
*
*/
class ProcessModuleInstall extends Wire {
/**
* @var WireTempDir
*
*/
private $tempDir = null;
/**
* Returns a temporary directory (path) for use by this object
*
* @return string|bool Returns false if you specified $create=false, and the dir doesn't exist
* @throws WireException If can't create temporary dir
*
*/
public function getTempDir() {
if(empty($this->tempDir)) $this->tempDir = $this->wire(new WireTempDir($this));
return $this->tempDir->get();
}
/**
* Check that the system supports direct upload and download of modules
*
* This primarily checks that needed dirs are writable and ZipArchive is available.
*
* @param bool $notify Specify true to make it queue the relevant reason/error message if upload/download not supported. (default=false)
* @param string $type One of 'upload' or 'download' or omit for general check
* @return bool
*
*/
public function canUploadDownload($notify = true, $type = '') {
$config = $this->wire()->config;
if($type) {
$a = $config->moduleInstall;
$allow = is_array($a) && isset($a[$type]) ? $a[$type] : false;
if($allow === 'debug' && !$config->debug) $allow = false;
if(!$allow) {
if($notify) $this->error(
sprintf($this->_('Module install option “%s”'), $type) . ' - ' .
$this->installDisabledLabel($type)
);
return false;
}
}
$can = true;
if(!is_writable($config->paths->cache)) {
if($notify) $this->error($this->_('Make sure /site/assets/cache/ directory is writeable for PHP.'));
$can = false;
}
if(!is_writable($config->paths->siteModules)) {
if($notify) $this->error($this->_('Make sure your site modules directory (/site/modules/) is writeable for PHP.'));
$can = false;
}
if(!class_exists('ZipArchive')) {
if($notify) $this->error($this->_('ZipArchive class is required and your PHP does not appear to have it.'));
$can = false;
}
return $can;
}
/**
* Module upload allowed?
*
* @param bool $notify
* @return bool
*
*
*/
public function canInstallFromFileUpload($notify = true) {
return $this->canUploadDownload($notify, 'upload');
}
/**
* Module download from URL allowed?
*
* @param bool $notify
* @return bool
*
*/
public function canInstallFromDownloadUrl($notify = true) {
return $this->canUploadDownload($notify, 'download');
}
/**
* Module install/upgrade from directory allowed?
*
* @param bool $notify
* @return bool
*
*/
public function canInstallFromDirectory($notify = true) {
return $this->canUploadDownload($notify, 'directory');
}
/**
* Find all module files, recursively in Path
*
* @param string $path Omit to use the default (/site/modules/)
* @param int $maxLevel Max depth to pursue module files in (recursion level)
* @return array of module classname => full pathname to module file
*
*/
public function findModuleFiles($path = '', $maxLevel = 4) {
static $level = 0;
$level++;
$files = array();
if(!$path) $path = $this->wire()->config->paths->siteModules;
// find the names of all existing module files, so we can defer to their dirs
// if a module is being installed that already exists
foreach(new \DirectoryIterator($path) as $file) {
if($file->isDot()) continue;
if(substr($file->getBasename(), 0, 1) == '.') continue;
if($file->isDir() && $level < $maxLevel) {
$_files = $this->findModuleFiles($file->getPathname());
$files = array_merge($_files, $files);
} else if($file->isFile()) {
$basename = $file->getBasename();
if(!strpos($basename, '.module')) continue;
if(!preg_match('/^([A-Z][a-zA-Z0-9_]+)\.module(?:\.php)?$/', $basename, $matches)) continue;
$className = $matches[1];
$files[$className] = $file->getPathname();
}
}
$level--;
return $files;
}
/**
* Given a list of files from a module (and their temp dir) return the recommended directory name for it to live in
*
* i.e. /site/modules/[ModuleDir]/
*
* @param array $files Files found in the module's ZIP file
* @param string $modulePath Path where module will live
* @return bool|string Returns false if no module files found. Otherwise returns string with module path.
*
*/
public function determineDestinationDir(array $files, $modulePath = '') {
$moduleFiles = array(); // all module files found
$moduleFiles1 = array(); // level1 module files (those in closest dir or subdir)
$moduleDirs = array(); // all module dirs found
$moduleDir = ''; // recommended name for module dir (returned by this method)
if(!$modulePath) $modulePath = $this->wire()->config->paths->siteModules;
$tempDir = $this->getTempDir();
foreach($files as $f) {
// determine which file should be responsible for the name
if(strpos($f, '/') !== false) {
$dir = dirname($f);
if($dir != '.') $moduleDirs[$dir] = $dir;
}
if(preg_match('{^(.*?/|)(([A-Z][a-zA-Z0-9_]+)\.module(?:\.php)?)$}', $f, $matches)) {
$path = $matches[1];
$name = $matches[3];
$file = $matches[2];
$moduleFiles[$name] = $path . $file;
}
}
if(!count($moduleFiles)) {
return false;
}
// determine which module files are at lowest level in dir tree
$numSlashes = 0;
while(!count($moduleFiles1) && $numSlashes < 5) {
foreach($moduleFiles as $name => $file) {
if(substr_count($file, '/') == $numSlashes) {
$moduleFiles1[$name] = $name;
}
}
$numSlashes++;
}
if(count($moduleFiles1) == 1) {
// if only 1 module file, use that as the dir name
reset($moduleFiles1);
$moduleDir = key($moduleFiles1);
$dir = $modulePath . $moduleDir . '/';
//$this->message("Determined destination dir to be (1): $dir", Notice::debug);
return $dir;
}
// see if any of the module files match up with one already on the file system,
// in which case we'll defer to that module for the destination dir
$moduleFilesAll = $this->findModuleFiles($modulePath);
foreach($moduleFiles1 as $name) {
if(isset($moduleFilesAll[$name])) {
$moduleDir = dirname($moduleFilesAll[$name]);
$moduleDir = basename($moduleDir);
if($moduleDir == 'modules') $moduleDir = '';
}
}
if($moduleDir) {
$dir = $modulePath . $moduleDir . '/';
//$this->message("Determined destination dir to be (2): $dir", Notice::debug);
return $dir;
}
// sort by length
$sorted = array();
foreach($moduleFiles1 as $name => $file) {
$len = strlen($name);
while(isset($sorted[$len])) $len++;
$sorted[$len] = $name;
}
krsort($sorted); // sort by longest to shortest
$moduleFiles1 = array();
foreach($sorted as $name) {
$moduleFiles1[$name] = $name;
}
$extractedDir = trim($files[0], '/');
if(is_dir($tempDir . $extractedDir) && $extractedDir[0] != '.') {
// ensure it follows class name format
if(preg_match('/^(A-Z[a-zA-Z0-9_]+)(-[a-z0-9]*)?$/', $extractedDir, $matches)) {
// reduced to class name, with optional "-text" at the end removed (i.e. GitHub branch name)
$extractedDir = $matches[1];
// extractedDir follows the name format of a module
// determine if it lines up with any of the found modules
foreach($moduleFiles1 as $name => $file) {
if($name == $extractedDir) $moduleDir = $name; // FOUND IT
}
// if not yet found, determine if they start the same
if(!$moduleDir) foreach($moduleFiles1 as $name => $file) {
if(strpos($name, $extractedDir) === 0) {
$moduleDir = $extractedDir;
break;
}
}
// if we haven't been able to match to a moduleName,
// just use the extractedDir since it follows a class name format
if(!$moduleDir) {
$dir = $modulePath . $extractedDir . '/';
//$this->message("Determined destination dir to be (3): $dir", Notice::debug);
return $dir;
}
}
}
// if we reach this point, use the shortest module name as the dirname
$moduleDir = end($moduleFiles1);
$dir = $modulePath . $moduleDir . '/';
//$this->message("Determined destination dir to be (4): $dir", Notice::debug);
return $dir;
}
/**
* Unzip the module file to tempDir and then copy to destination directory
*
* @param string $zipFile File to unzip
* @param string $destinationDir Directory to copy completed files into. Optionally omit to determine automatically.
* @return bool|string Returns destinationDir on success, false on failure
* @throws WireException
*
*/
public function unzipModule($zipFile, $destinationDir = '') {
$config = $this->wire()->config;
$success = false;
$tempDir = $this->getTempDir();
$mkdirDestination = false;
$fileTools = $this->wire()->files;
try {
$files = $fileTools->unzip($zipFile, $tempDir);
if(is_file($zipFile)) $fileTools->unlink($zipFile, true);
$qty = count($files);
if($qty < 100 && $config->debug) {
foreach($files as $f) {
$this->message(sprintf($this->_('Extracted: %s'), $f));
}
} else {
$this->message(sprintf($this->_n('Extracted %d file', 'Extracted %d files', $qty), $qty));
}
} catch(\Exception $e) {
$this->error($e->getMessage());
if(is_file($zipFile)) $fileTools->unlink($zipFile, true);
return false;
}
if(!$destinationDir) {
$destinationDir = $this->determineDestinationDir($files);
if(!$destinationDir) throw new WireException($this->_('Unable to find any module files'));
}
$this->message("Destination directory: $destinationDir", Notice::debug);
$files0 = trim($files[0], '/');
$extractedDir = is_dir("$tempDir/$files0") && substr($files0, 0, 1) != '.' ? "$files0/" : "";
// now create module directory and copy files over
if(is_dir($destinationDir)) {
// destination dir already there, perhaps an older version of same module?
// create a backup of it
$hasBackup = $this->backupDir($destinationDir);
if($hasBackup) $fileTools->mkdir($destinationDir, true);
} else {
if($fileTools->mkdir($destinationDir, true)) $mkdirDestination = true;
$hasBackup = false;
}
// label to identify destinationDir in messages and errors
$dirLabel = str_replace($config->paths->root, '/', $destinationDir);
if(is_dir($destinationDir)) {
$from = $tempDir . $extractedDir;
if($fileTools->copy($from, $destinationDir)) {
$this->message($this->_('Successfully copied files to new directory:') . ' ' . $dirLabel);
$fileTools->chmod($destinationDir, true);
$success = true;
} else {
$this->error($this->_('Unable to copy files to new directory:') . ' ' . $dirLabel);
if($hasBackup) $this->restoreDir($destinationDir);
}
} else {
$this->error($this->_('Could not create directory:') . ' ' . $dirLabel);
}
if(!$success) {
$this->error($this->_('Unable to copy module files:') . ' ' . $dirLabel);
if($mkdirDestination && !$fileTools->rmdir($destinationDir, true)) {
$this->error($this->_('Could not delete failed module dir:') . ' ' . $destinationDir, Notice::log);
}
}
return $success ? $destinationDir : false;
}
/**
* Create a backup of a module directory
*
* @param string $moduleDir
* @return bool
* @throws WireException
*
*/
protected function backupDir($moduleDir) {
$files = $this->wire()->files;
$config = $this->wire()->config;
$dir = rtrim($moduleDir, "/");
$name = basename($dir);
$parentDir = dirname($dir);
$backupDir = "$parentDir/.$name/";
if(is_dir($backupDir)) $files->rmdir($backupDir, true); // if there's already an old backup copy, remove it
$success = false;
if(is_link(rtrim($moduleDir, '/'))) {
// module directory is a symbolic link
// copy files from symlink dir to real backup dir
$success = $files->copy($moduleDir, $backupDir);
// remove symbolic link
unlink(rtrim($moduleDir, '/'));
$dir = str_replace($config->paths->root, '/', $moduleDir);
$this->warning(sprintf(
$this->_('Please note that %s was a symbolic link and has been converted to a regular directory'), $dir
));
} else {
// module is a regular directory
// just rename it to become the new backup dir
if($files->rename($moduleDir, $backupDir)) $success = true;
}
if($success) {
$this->message(sprintf($this->_('Backed up existing %s'), $name) . " => " . str_replace($config->paths->root, '/', $backupDir));
return true;
} else {
return false;
}
}
/**
* Restore a module directory
*
* @param string $moduleDir
* @return bool
* @throws WireException
*
*/
protected function restoreDir($moduleDir) {
$dir = rtrim($moduleDir, "/");
$name = basename($dir);
$parentDir = dirname($dir);
$backupDir = "$parentDir/.$name/";
if(is_dir($backupDir)) {
$this->wire()->files->rmdir($moduleDir, true); // if there's already an old backup copy, remove it
if(rename($backupDir, $moduleDir)) {
$this->message(sprintf($this->_('Restored backup of %s'), $name) . " => $moduleDir");
}
}
return false;
}
/**
* Process a module upload
*
* @param string $inputName Optionally specify the name of the $_FILES input to look for (default=upload_module)
* @param string $destinationDir Optionally specify destination path for completed unzipped files
* @return bool|string Returns destinationDir on success, false on failure.
*
*/
public function uploadModule($inputName = 'upload_module', $destinationDir = '') {
if(!$this->canInstallFromFileUpload()) {
$this->error($this->_('Unable to complete upload'));
return false;
}
$tempDir = $this->getTempDir();
/** @var WireUpload $ul */
$ul = $this->wire(new WireUpload($inputName));
$ul->setValidExtensions(array('zip'));
$ul->setMaxFiles(1);
$ul->setOverwrite(true);
$ul->setDestinationPath($tempDir);
$ul->setExtractArchives(false);
$ul->setLowercase(false);
$files = $ul->execute();
if(count($files)) {
$file = $tempDir . reset($files);
$destinationDir = $this->unzipModule($file, $destinationDir);
if($destinationDir) $this->modules->refresh();
} else {
$this->error($this->_('No uploads found'));
$destinationDir = false;
}
return $destinationDir;
}
/**
* Given a URL to a ZIP file, download it, unzip it, and move to /site/modules/[ModuleName]
*
* @param string $url Download URL
* @param string $destinationDir Optional destination path for files (omit to auto-determine)
* @param string $type Specify type of 'download' or 'directory'
* @return bool|string Returns destinationDir on success, false on failure.
*
*/
public function downloadModule($url, $destinationDir = '', $type = 'download') {
if($type === 'directory') {
if(!$this->canInstallFromDirectory()) return false;
} else {
if(!$this->canInstallFromDownloadUrl()) return false;
}
if(!preg_match('{^https?://}i', $url)) {
$this->error($this->_('Invalid download URL specified'));
return false;
}
$tempDir = $this->getTempDir();
$tempName = 'module-temp.zip';
// if there is a recognizable ZIP filename in the URL, use that rather than module-temp.zip
if(preg_match('/([-._a-z0-9]+\.zip)$/i', $url, $matches)) $tempName = $matches[1];
$tempZIP = $tempDir . $tempName;
// download the zip file and save it in assets directory
$success = false;
$http = $this->wire(new WireHttp()); /** @var WireHttp $http */
try {
$file = $http->download($url, $tempZIP); // throws exceptions on any error
$this->message(sprintf($this->_('Downloaded ZIP file: %s (%d bytes)'), $url, filesize($file)));
$destinationDir = $this->unzipModule($file, $destinationDir);
if($destinationDir) {
$success = true;
$this->modules->refresh();
}
} catch(\Exception $e) {
$this->error($e->getMessage());
$this->wire()->files->unlink($tempZIP);
}
return $success ? $destinationDir : false;
}
/**
* Download module from URL
*
* @param string $url
* @param string $destinationDir
* @return bool|string
* @since 3.0.162
*
*/
public function downloadModuleFromUrl($url, $destinationDir = '') {
return $this->downloadModule($url, $destinationDir, 'download');
}
/**
* Download module from directory
*
* @param string $url
* @param string $destinationDir
* @return bool|string
* @since 3.0.162
*
*/
public function downloadModuleFromDirectory($url, $destinationDir = '') {
return $this->downloadModule($url, $destinationDir, 'directory');
}
/**
* Return label to indicate option is disabled and how to enable it
*
* @param string $type
* @return string
* @since 3.0.162
*
*/
public function installDisabledLabel($type) {
$config = $this->wire()->config;
$a = $config->moduleInstall;
if(!is_writable($config->paths->siteModules)) {
return
sprintf($this->_('Your %s path is currently not writable.'), $config->urls->siteModules) . ' ' .
$this->_('It must be made writable to ProcessWire before you can enable this module installation option.');
}
$debug = !empty($a[$type]) && $a[$type] === 'debug';
$opt1 = "`\$config->moduleInstall('$type', true);` " . $this->_('to enable always');
$opt2 = "`\$config->debug = true;` " . $this->_('temporarily');
$opt3 = "`\$config->moduleInstall('$type', 'debug');` " . $this->_('to enable in debug mode only');
$file = $config->urls->site . 'config.php';
$inst = $this->_('To enable, edit file %1$s and specify: %2$s …or… %3$s');
if($debug) {
return
$this->_('This install option is configured to be available only in debug mode.') . ' ' .
sprintf($inst, "$file", "\n$opt2", "\n$opt1");
} else {
return
$this->_('This install option is currently disabled.') . ' ' .
sprintf($inst, "$file", "\n$opt1", "\n$opt3");
}
}
}