Index: modules/update/update.api.php =================================================================== RCS file: /cvs/drupal/drupal/modules/update/update.api.php,v retrieving revision 1.1 diff -u -p -r1.1 update.api.php --- modules/update/update.api.php 25 Nov 2008 02:37:33 -0000 1.1 +++ modules/update/update.api.php 9 Apr 2009 23:13:03 -0000 @@ -41,5 +41,53 @@ function hook_update_status_alter(&$proj } /** + * Define additional backends to be supported for installing/updating modules + * and themes from drupal.org. + * + * By default, ftp and ssh are enabled if the appropriate PHP extensions are + * installed. These backends are handled by system.module. Any additional + * backends defined here should also define the following callbacks: + * - "update_NAME_add_extension($files, $settings)": + * NAME here corresponds to the keys of the return array; for this example, + * this function would be update_btp_add_extension(). This function should + * install the files specified by $files to the server specified by the + * array $settings, which may contain data such as the password, username, + * or server location. Should return TRUE on success, or FALSE on failure. + * + * - "update_NAME_remove_extension($files, $settings)": + * NAME here corresponds to the keys of the return array; for this example, + * this function would be update_btp_remove_extension(). This function + * should remove the files specified by $files from the server specified by + * the array $settings, which may contain data such as the password, + * username, or server location. Should return TRUE on success, or FALSE on + * failure. + * + * - "update_NAME_settings_form($form)": + * NAME here corresponds to the keys of the return array; for this example, + * this function would be update_btp_settings_form(). This function is + * optional, and should be used only if the module needs to make any + * modifications or additions to the default form values, which are + * "username", "password", "host", and "root" (the path to the root drupal + * installation). The settings entered by the user will be passed back to + * the two above functions as the $settings array. + * + * @return + * An associative array where the keys correspond to the internal name of the + * backend, used for function calls, and the values correspond to the human + * readable name of the backend. + */ +function hook_update_backend() { + $backends = array(); + + // BTP (Banana Transfer Protocol) will only be available if the function + // btp_unpeel() exists. + if (function_exists('btp_unpeel')) { + $backends['btp'] = t('BTP'); + } + + return $backends; +} + +/** * @} End of "addtogroup hooks". */ Index: modules/update/update.ftp_extension.inc =================================================================== RCS file: modules/update/update.ftp_extension.inc diff -N modules/update/update.ftp_extension.inc --- /dev/null 1 Jan 1970 00:00:00 -0000 +++ modules/update/update.ftp_extension.inc 9 Apr 2009 23:13:04 -0000 @@ -0,0 +1,155 @@ + $file) { + ftp_chdir($connect, '/'); + if (trim($file, '\\/') != $file) { + if (!@ftp_mkdir($connect, "$root/$file") && !@ftp_chdir($connect, "$root/$file")) { + drupal_set_message(t('Unable to create directory @directory.', array('@directory' => $file)),'error'); + return FALSE; + } + } + else { + if (!@ftp_put($connect, "$root/$file", $extract_directory . $file, FTP_BINARY)) { + drupal_set_message(t('Unable to upload @file.', array('@file' => $file)), 'error'); + return FALSE; + } + } + } + return TRUE; +} + +/** + * Remove the supplied files. + * + * @param $files + * An array of files to be removed by the specified backend. + * @param $settings + * An array of settings, which will vary among different backends, but may + * include data such as the username, password, and host. + * @return + * TRUE on success, or FALSE on failure. + */ +function update_ftp_extension_remove_extension($files, $settings) { + $root = $settings['root']; + $host = $settings['host']; + $username = $settings['username']; + $password = $settings['password']; + + // Connect to the local ftp server. + $connect = @ftp_connect($host); + if (!$connect) { + drupal_set_message(t('No ftp server could be found.'), 'error'); + return FALSE; + } + + // Login to the local ftp server. + if (!@ftp_login($connect, $username, $password)) { + drupal_set_message(t('Could not login to the ftp server.'), 'error'); + return FALSE; + } + + // If we can't change to the proper directory, abort. + if (!@ftp_chdir($connect, $root)) { + drupal_set_message(t('Could not reach the proper directory.'), 'error'); + return FALSE; + } + + ftp_chdir($connect, '/'); + + // Process each of the files. + foreach ($files as $file) { + if (trim($file, '\\/') != $file) { + if (!update_ftp_extension_remove_extension_rmdir($connect, "$root/$file")) { + return FALSE; + } + } + } + return TRUE; +} + +/** + * Helper function for ftp_remove_extension_library(). + * + * @param $connect + * The FTP connection to use to remove the directory. + * @param $directory + * The directory to remove. + * @return + * TRUE on success, or FALSE on failure. + */ +function update_ftp_extension_remove_extension_rmdir($connect, $directory){ + $pwd = ftp_pwd($connect); + if (!@ftp_chdir($connect, $directory)) { + return FALSE; + } + $list = @ftp_nlist($connect, '.'); + foreach ($list as $item){ + if ($item == '.' || $item == '..') { + continue; + } + if (@ftp_chdir($connect, $item)){ + ftp_chdir($connect, '..'); + if (!update_ftp_extension_remove_extension_rmdir($connect, $item)) { + return FALSE; + } + } + elseif (!ftp_delete($connect, $item)) { + return FALSE; + } + } + ftp_chdir($connect, $pwd); + if (!ftp_rmdir($connect, $directory)) { + return FAlSE; + } + return TRUE; +} Index: modules/update/update.ftp_wrapper.inc =================================================================== RCS file: modules/update/update.ftp_wrapper.inc diff -N modules/update/update.ftp_wrapper.inc --- /dev/null 1 Jan 1970 00:00:00 -0000 +++ modules/update/update.ftp_wrapper.inc 9 Apr 2009 23:13:04 -0000 @@ -0,0 +1,128 @@ + $file) { + if (trim($file, "\\/") != $file) { + @mkdir("$ftp_path/$file"); + } + else { + @copy($extract_directory . $file, "$ftp_path/$file"); + } + } + return TRUE; +} + +/** + * Remove the supplied files. + * + * @param $files + * An array of files to be removed by the specified backend. + * @param $settings + * An array of settings, which will vary among different backends, but may + * include data such as the username, password, and host. + * @return + * TRUE on success, or FALSE on failure. + */ +function update_ftp_wrapper_remove_extension($files, $settings) { + $root = $settings['root']; + $host = $settings['host']; + $username = $settings['username']; + $password = $settings['password']; + + // Write the common part of the url + $ftp_base_dir = 'ftp://' . urlencode($username) . ':' . urlencode($password) . "@$host/"; + + if (!@is_dir($ftp_base_dir)) { + drupal_set_message(t('The supplied username/password combination was not accepted, or no ftp server is running.'), 'error'); + return FALSE; + } + + $ftp_path = $ftp_base_dir . $root; + // If we the FTP path is not valid, abort. + if (!@is_dir($ftp_path)) { + drupal_set_message(t('Could not reach the proper directory.'), 'error'); + return FALSE; + } + // Process each of the files. + foreach ($files as $file) { + if (trim($file, '\\/') != $file) { + if (!update_ftp_wrapper_remove_extension_rmdir("$ftp_path/$file")) { + return FALSE; + } + } + } + return TRUE; +} + +/** + * Helper function for ftp_remove_extension_wrapper(). Removes a directory + * through the FTP stream. + * + * @param $target + * The target folder to remove. + * @return + * TRUE on success, or FALSE on failure. + */ +function update_ftp_wrapper_remove_extension_rmdir($target) { + if (is_dir($target)) { + $directory = opendir($target); + while (($resource = readdir($directory)) !== FALSE) { + if ($resource == '.' || $resource == '..') { + continue; + } + if (is_file($target . $resource)) { + unlink($target . $resource); + } + elseif (is_dir($target . $resource)) { + update_ftp_wrapper_remove_extension_rmdir($target . $resource . '/'); + } + } + closedir($directory); + if (rmdir($target)) { + return TRUE; + } + } + return FALSE; +} Index: modules/update/update.info =================================================================== RCS file: /cvs/drupal/drupal/modules/update/update.info,v retrieving revision 1.5 diff -u -p -r1.5 update.info --- modules/update/update.info 11 Oct 2008 02:33:12 -0000 1.5 +++ modules/update/update.info 9 Apr 2009 23:13:04 -0000 @@ -4,9 +4,12 @@ description = Checks the status of avail version = VERSION package = Core core = 7.x -files[] = update.module files[] = update.compare.inc files[] = update.fetch.inc +files[] = update.ftp_extension.inc +files[] = update.ftp_wrapper.inc +files[] = update.install +files[] = update.module files[] = update.report.inc files[] = update.settings.inc -files[] = update.install +files[] = update.ssh.inc Index: modules/update/update.module =================================================================== RCS file: /cvs/drupal/drupal/modules/update/update.module,v retrieving revision 1.30 diff -u -p -r1.30 update.module --- modules/update/update.module 22 Jan 2009 03:11:54 -0000 1.30 +++ modules/update/update.module 9 Apr 2009 23:13:04 -0000 @@ -286,6 +286,31 @@ function update_cron() { } /** + * Implementation of hook_update_backend(). + */ +function update_update_backend() { + $backends = array(); + + // SSH2 lib backend is only available if the proper PHP extension is + // installed. + if (function_exists('ssh2_connect')) { + $backends['ssh'] = t('SSH'); + } + // FTP backend is only available if the proper PHP extension is installed or + // allow_url_fopen is on. + if (function_exists('ftp_connect')) { + $backends['ftp_extension'] = t('FTP'); + } + // Only present the ftp wrapper option as a fallback if the ftp extension + // isn't available. + elseif (ini_get('allow_url_fopen')) { + $backends['ftp_wrapper'] = t('FTP'); + } + + return $backends; +} + +/** * Implementation of hook_form_FORM_ID_alter(). * * Adds a submit handler to the system modules and themes forms, so that if a @@ -515,3 +540,200 @@ function _update_project_status_sort($a, $b_status = $b['status'] > 0 ? $b['status'] : (-10 * $b['status']); return $a_status - $b_status; } + +/** + * Attempts to get a file using drupal_http_request and to store it locally. + * + * @param $path + * The URL of the file to grab. + * @return + * On success the address the files was saved to, FALSE on failure. + */ +function update_get_file($path) { + // Get each of the specified files. + $parsed_url = parse_url($path); + $local = file_directory_temp() . '/update_cache/' . basename($parsed_url['path']); + if (!file_exists(file_directory_temp() . '/update_cache/')) { + mkdir(file_directory_temp() . '/update_cache/'); + } + + // Check the cache and download the file if needed. + if (!file_exists($local)) { + // $result->data is the actual contents of the downloaded file. This saves + // it into a local file, whose path is stored in $local. $local is stored + // relative to the drupal installation. + $result = drupal_http_request($path); + if ($result->code != 200 || !file_save_data($result->data, $local)) { + drupal_set_message(t('@remote could not be saved.', array('@remote' => $path)), 'error'); + return FALSE; + } + } + return $local; +} + +/** + * Untars a file. + * + * @param $file + * The file to untar. + * @param $directory + * The destination directory of the file; for example, 'sites/all/modules'. + * @return + * An array containing the locations of the extracted files, or FALSE on + * failure. + */ +function update_untar($file, $directory) { + $directory_parts = explode('/', $directory); + $directory = file_directory_temp() . '/update_extraction/'; + if (!file_exists($directory)) { + mkdir($directory); + } + foreach ($directory_parts as $directory_part) { + $directory .= "/$directory_part"; + if (!file_exists($directory)) { + mkdir($directory); + } + } + $file_safe = escapeshellarg($file); + $directory_safe = escapeshellarg($directory); + $file_list = array(); + + // Try to use tar to extract the files. + if (function_exists('popen')) { + $handle = popen("tar -zvxf $file_safe -C $directory_safe", 'r'); + while ($line = fgets($handle)) { + $file_list[] = trim($line); + } + pclose($handle); + } + + // If tar returned something, then it is present, so return it. + if (!empty($file_list)) { + return $file_list; + } + + return FALSE; +} + +/** + * Get the information about each extension. + * + * @param $projects + * The project or projects on which information is desired. + * @return + * An array containing info on the supplied projects. + */ +function update_get_release_history($projects) { + $version = DRUPAL_CORE_COMPATIBILITY; + $results = array(); + + // If projects isn't an array, turn it into one. + if (!is_array($projects)) { + $projects = array($projects); + } + + // Look up the data for every project requested. + foreach ($projects as $project) { + $file = drupal_http_request(UPDATE_DEFAULT_URL . "/$project/$version"); + $xml = simplexml_load_string($file->data); + + // If it failed, then quit. + if ($xml == FALSE) { + drupal_set_message(t('Downloading the release history failed for @project.', array('@project' => $project)), "error"); + return FALSE; + } + + // Get the title, release_link and download_link. + $results[$project]['title'] = (string)$xml->title; + + // Get information about every release. + foreach ($xml->releases->release AS $release) { + $release_version = (string)$release->version; + $results[$project]['release'][] = array( + 'release_link' => (string)$release->release_link, + 'download_link' => (string)$release->download_link, + 'date' => (string)$release->date, + 'version' => $release_version, + ); + $results[$project]['version'][] = $release_version; + } + } + + // Order them and then return the results. + ksort($results); + return $results; +} + +/** + * See if everything that is needed to use the extension manager is available. + * + * @return + * TRUE if all of the required dependencies are available, FALSE otherwise. + */ +function update_extensions_runnable() { + // See if we have a way to untar the files. + $handle = popen('tar --version', 'r'); + if (!fgets($handle)) { + pclose($handle); + return FALSE; + } + pclose($handle); + + // See if we have any available backends. + $backends = update_list_backends(); + return !empty($backends); +} + +/** + * Add an extension to the file system. + * + * @param $backend + * The machine-readable name of the backend handling the addition to the file + * system; for example, 'ftp'. + * @param $files + * An array of files to be added by the specified backend. + * @param $settings + * An array of settings, which will vary among different backends, but may + * include data such as the username, password, and host. + * @return + * TRUE on success, or FALSE on failure. + */ +function update_add_extension($backend, $files, $settings) { + $function = 'update_' . $backend . '_add_extension'; + if (drupal_function_exists($function)) { + return $function($files, $settings); + } + return FALSE; +} + +/** + * Remove an extension from the file system. + * + * @param $backend + * The machine-readable name of the backend handling the removal from the + * file system; for example, 'ftp'. + * @param $settings + * An array of settings, which will vary among different backends, but may + * include data such as the username, password, and host. + * @return + * TRUE on success, or FALSE on failure. + */ +function update_remove_extension($backend, $files, $settings) { + $function = 'update_' . $backend . '_remove_extension'; + if (drupal_function_exists($function)) { + return $function($files, $settings); + } + return FALSE; +} + +/** + * Get a list of all available backends that can upload files onto the system. + * + * @return + * Array containing the names of all available backends. + */ +function update_list_backends() { + $backends = module_invoke_all('update_backend'); + asort($backends); + return $backends; +} Index: modules/update/update.ssh.inc =================================================================== RCS file: modules/update/update.ssh.inc diff -N modules/update/update.ssh.inc --- /dev/null 1 Jan 1970 00:00:00 -0000 +++ modules/update/update.ssh.inc 9 Apr 2009 23:13:04 -0000 @@ -0,0 +1,108 @@ + t('FTP extension update functionality'), + 'description' => t('Test adding and removing a module using the FTP extension. The FTP credentials assumed are host: localhost, path from root to drupal: "drupal", username: "root", and password "password". For other values, you will need to override the settings in settings.php.'), + 'group' => t('Update') + ); + } + + function setUp() { + global $conf; + foreach (array('username', 'password', 'host', 'root') as $key) { + if (isset($conf["ftp_$key"])) { + $this->$key = $conf["ftp_$key"]; + } + } + if (is_null($this->root)) { + $this->root = getcwd(); + } + parent::setUp('update'); + } + + /** + * Helper function to determine whether or not FTP is enabled with the given + * credentials on the given server. + * + * @return + * TRUE if FTP is enabled and working, or FALSE if Drupal cannot connect to + * FTP for any reason. + */ + function FTPEnabled() { + if (!function_exists('ftp_connect')) { + $this->pass(t('The FTP extension is not currently available.')); + return FALSE; + } + $parts = explode(':', $this->host); + if (count($parts) == 2) { + $connect = ftp_connect($parts[0], (int)$parts[1]); + } + else { + $connect = ftp_connect($this->host); + } + if (!$connect) { + $this->pass(t('No FTP server was found at the given location.')); + return FALSE; + } + + // Login to the local ftp server. + if (!@ftp_login($connect, $this->username, $this->password)) { + $this->pass(t('Could not login to the ftp server with the given credentials.')); + return FALSE; + } + + if (!@ftp_chdir($connect, $this->root)) { + $this->pass(t('The provided drupal install directory is invalid.')); + return FALSE; + } + $this->pass(t('Connected successfully to FTP')); + return TRUE; + } + + function testUpdateFTPExtension() { + if ($this->FTPEnabled()) { + // Upload the module. + $path = 'http://ftp.drupal.org/files/projects/coder-7.x-1.x-dev.tar.gz'; + $local = update_get_file($path); + $this->assertTrue($local, t('Tarball fetched from drupal.org without any problems.')); + if (!$local) { + return; + } + $files = update_untar($local, 'sites/all/modules'); + $this->assertTrue(is_array($files) && count($files), t('Module untarred successfully.')); + if (!is_array($files) || !count($files)) { + return; + } + foreach ($files as &$file) { + $file = 'sites/all/modules/' . $file; + } + $settings = array( + 'username' => $this->username, + 'password' => $this->password, + 'host' => $this->host, + 'root' => $this->root, + ); + $result = update_add_extension('ftp_extension', $files, $settings); + $this->assertTrue($result, t('Module uploaded successfully.')); + if (!$result) { + return; + } + + // Now, remove the same module. + $files = array('sites/all/modules/coder/'); + $result = update_remove_extension('ftp_extension', $files, $settings); + $this->assertTrue($result, t('Module removed successfully.')); + } + } +} + +class UpdateFTPWrapperTestCase extends DrupalWebTestCase { + protected $username = 'root'; + protected $password = 'password'; + protected $host = 'localhost'; + protected $root = NULL; + + function getInfo() { + return array( + 'name' => t('FTP wrapper update functionality'), + 'description' => t('Test adding and removing a module using the FTP wrapper. The FTP credentials assumed are host: localhost, path from root to drupal: "drupal", username: "root", and password "password". For other values, you will need to override the settings in settings.php.'), + 'group' => t('Update') + ); + } + + function setUp() { + global $conf; + foreach (array('username', 'password', 'host', 'root') as $key) { + if (isset($conf["ftp_$key"])) { + $this->$key = $conf["ftp_$key"]; + } + } + if (is_null($this->root)) { + $this->root = getcwd(); + } + parent::setUp('update'); + } + + /** + * Helper function to determine whether or not FTP is enabled with the given + * credentials on the given server. + * + * @return + * TRUE if FTP is enabled and working, or FALSE if Drupal cannot connect to + * FTP for any reason. + */ + function FTPEnabled() { + if (!ini_get('allow_url_fopen')) { + $this->pass(t('PHP cannot open FTP connections using the FTP wrapper.')); + return FALSE; + } + + // Write the common part of the url. + $ftp_base_directory = 'ftp://' . urlencode($this->username) . ':' . urlencode($this->password) . "@$this->host/"; + + if (!@is_dir($ftp_base_directory)) { + $this->pass(t('The supplied username/password combination was not accepted, or no local ftp server is running.')); + return FALSE; + } + + $ftp_path = $ftp_base_directory . $this->root; + + // If it's not a valid path, then quit. + if (!is_dir($ftp_path)) { + $this->pass(t('Could not find the ftp directory for drupal.')); + return FALSE; + } + + $this->pass(t('Connected successfully to FTP')); + return TRUE; + } + + function testUpdateFTPWrapper() { + if ($this->FTPEnabled()) { + // Upload the module. + $path = 'http://ftp.drupal.org/files/projects/coder-7.x-1.x-dev.tar.gz'; + $local = update_get_file($path); + $this->assertTrue($local, t('Tarball fetched from drupal.org without any problems.')); + if (!$local) { + return; + } + $files = update_untar($local, 'sites/all/modules'); + $this->assertTrue(is_array($files) && count($files), t('Module untarred successfully.')); + if (!is_array($files) || !count($files)) { + return; + } + foreach ($files as &$file) { + $file = 'sites/all/modules/' . $file; + } + $settings = array( + 'username' => $this->username, + 'password' => $this->password, + 'host' => $this->host, + 'root' => $this->root, + ); + $result = update_add_extension('ftp_wrapper', $files, $settings); + $this->assertTrue($result, t('Module uploaded successfully.')); + if (!$result) { + return; + } + + // Now, remove the same module. + $files = array('sites/all/modules/coder/'); + $result = update_remove_extension('ftp_wrapper', $files, $settings); + $this->assertTrue($result, t('Module removed successfully.')); + } + } +} + +class UpdateSSHTestCase extends DrupalWebTestCase { + protected $username = 'root'; + protected $password = 'password'; + protected $host = 'localhost'; + protected $root = NULL; + + function getInfo() { + return array( + 'name' => t('SSH update functionality'), + 'description' => t('Test adding and removing a module using SSH. The SSH credentials assumed are host: localhost, path from root to drupal: "drupal", username: "root", and password "password". For other values, you will need to override the settings in settings.php.'), + 'group' => t('Update') + ); + } + + function setUp() { + global $conf; + foreach (array('username', 'password', 'host', 'root') as $key) { + if (isset($conf["ssh_$key"])) { + $this->$key = $conf["ssh_$key"]; + } + } + if (is_null($this->root)) { + $this->root = getcwd(); + } + parent::setUp('update'); + } + + /** + * Helper function to determine whether or not SSH is enabled with the given + * credentials on the given server. + * + * @return + * TRUE if SSH is enabled and working, or FALSE if Drupal cannot connect to + * SSH for any reason. + */ + function SSHEnabled() { + if (!function_exists('ssh2_connect')) { + $this->pass(t('PHP cannot open SSH connections.')); + return FALSE; + } + + $parts = explode(':', $this->host); + if (count($parts) == 2) { + $connection = ssh2_connect($parts[0], (int)$parts[1]); + } + else { + $connection = ssh2_connect($this->host); + } + + if (!$connection) { + $this->pass(t('Could not connect to ssh on this host.')); + return FALSE; + } + + // Login to the local ftp server. + if (!ssh2_auth_password($connection, $this->username, $this->password)) { + $this->pass(t('The supplied username/password combination was not accepted.')); + return FALSE; + } + + $this->pass(t('Connect successfully to the server using SSH.')); + return TRUE; + } + + function testUpdateSSH() { + if ($this->SSHEnabled()) { + // Upload the module. + $path = 'http://ftp.drupal.org/files/projects/coder-7.x-1.x-dev.tar.gz'; + $local = update_get_file($path); + $this->assertTrue($local, t('Tarball fetched from drupal.org without any problems.')); + if (!$local) { + return; + } + $files = update_untar($local, 'sites/all/modules'); + $this->assertTrue(is_array($files) && count($files), t('Module untarred successfully.')); + if (!is_array($files) || !count($files)) { + return; + } + foreach ($files as &$file) { + $file = 'sites/all/modules/' . $file; + } + $settings = array( + 'username' => $this->username, + 'password' => $this->password, + 'host' => $this->host, + 'root' => $this->root, + ); + $result = update_add_extension('ssh', $files, $settings); + $this->assertTrue($result, t('Module uploaded successfully.')); + if (!$result) { + return; + } + + // Now, remove the same module. + $files = array('sites/all/modules/coder/'); + $result = update_remove_extension('ssh', $files, $settings); + $this->assertTrue($result, t('Module removed successfully.')); + } + } +}