From 0d7c0069f268c2ffd99f3497e59772cb04c8ea62 Mon Sep 17 00:00:00 2001 From: Diogo Cordeiro Date: Sat, 17 Aug 2019 02:33:31 +0100 Subject: [PATCH] [MODULES] Allow to upload third party plugins Fixed some bugs --- actions/pluginenable.php | 4 +- actions/plugininstall.php | 198 ++++++++++++++++++++++++++++++++++ actions/pluginsadminpanel.php | 51 ++++++++- classes/File.php | 17 ++- lib/deletetree.php | 53 +++++++++ lib/mediafile.php | 46 ++++---- lib/router.php | 2 + 7 files changed, 338 insertions(+), 33 deletions(-) create mode 100644 actions/plugininstall.php create mode 100644 lib/deletetree.php diff --git a/actions/pluginenable.php b/actions/pluginenable.php index 5aa452c302..020ecd48f3 100644 --- a/actions/pluginenable.php +++ b/actions/pluginenable.php @@ -60,7 +60,7 @@ class PluginenableAction extends Action if ($_SERVER['REQUEST_METHOD'] != 'POST') { // TRANS: Client error displayed when trying to use another method than POST. // TRANS: Do not translate POST. - $this->clientError(_('This action only accepts POST requests.')); + $this->clientError(_m('This action only accepts POST requests.')); } // CSRF protection @@ -99,7 +99,7 @@ class PluginenableAction extends Action /** * Handle request * - * Does the subscription and returns results. + * Enables the plugin and returns results. * * @return void * @throws ClientException diff --git a/actions/plugininstall.php b/actions/plugininstall.php new file mode 100644 index 0000000000..4f2d8a41a5 --- /dev/null +++ b/actions/plugininstall.php @@ -0,0 +1,198 @@ +. + +defined('STATUSNET') || die(); + +require_once INSTALLDIR . '/lib/deletetree.php'; + +/** + * Plugin install action. + * + * Uploads a third party plugin to the right directories. + * + * Takes parameters: + * + * - pluginfile: plugin file + * - token: session token to prevent CSRF attacks + * - ajax: bool; whether to return Ajax or full-browser results + * + * Only works if the current user is logged in. + * + * @category Action + * @package GNUsocial + * @author Diogo Cordeiro + * @copyright 2019 Free Software Foundation, Inc http://www.fsf.org + * @license https://www.gnu.org/licenses/agpl.html GNU AGPL v3 or later + */ +class PlugininstallAction extends Action +{ + public $pluginfile = null; + + /** + * @param array $args + * @return bool + * @throws ClientException + */ + public function prepare(array $args = []) + { + parent::prepare($args); + + // @fixme these are pretty common, should a parent class factor these out? + + // Only allow POST requests + + if ($_SERVER['REQUEST_METHOD'] != 'POST') { + // TRANS: Client error displayed when trying to use another method than POST. + // TRANS: Do not translate POST. + $this->clientError(_('This action only accepts POST requests.')); + } + + // CSRF protection + + $token = $this->trimmed('token'); + + if (!$token || $token != common_session_token()) { + // TRANS: Client error displayed when the session token does not match or is not given. + $this->clientError(_m('There was a problem with your session token.'. + ' Try again, please.')); + } + + // Only for logged-in users + + $this->user = common_current_user(); + + if (empty($this->user)) { + // TRANS: Error message displayed when trying to perform an action that requires a logged in user. + $this->clientError(_m('Not logged in.')); + } + + if (!AdminPanelAction::canAdmin('plugins')) { + // TRANS: Client error displayed when trying to enable or disable a plugin without access rights. + $this->clientError(_m('You cannot administer plugins.')); + } + + if (!is_writable(INSTALLDIR . '/local/plugins/') || + !is_writable(PUBLICDIR . '/local/plugins/')) { + $this->clientError(_m("No permissions to write on the third party plugin upload directory(ies).")); + } + + + return true; + } + + /** + * Only handle if we receive an upload request + * + * @throws ClientException + * @throws NoUploadedMediaException + */ + protected function handle() + { + if ($this->trimmed('upload')) { + $this->uploadPlugin(); + $url = common_local_url('pluginsadminpanel'); + common_redirect($url, 303); + } else { + // TRANS: Unexpected validation error on plugin upload form. + throw new ClientException(_('Unexpected form submission.')); + } + } + + /** + * Handle a plugin upload + * + * Does all the magic for handling a plugin upload. + * + * @return void + * @throws ClientException + * @throws NoUploadedMediaException + */ + public function uploadPlugin(): void + { + // The existence of the "error" element means PHP has processed it properly even if it was ok. + $form_name = 'pluginfile'; + if (!isset($_FILES[$form_name]) || !isset($_FILES[$form_name]['error'])) { + throw new NoUploadedMediaException($form_name); + } + + if ($_FILES[$form_name]['error'] != UPLOAD_ERR_OK) { + throw new ClientException(_m('System error uploading file.')); + } + + $filename = basename($_FILES[$form_name]['name']); + $ext = null; + if (preg_match('/^.+?\.([A-Za-z0-9]+)$/', $filename, $matches) === 1) { + // we matched on a file extension, so let's see if it means something. + $ext = mb_strtolower($matches[1]); + $plugin_name = basename($filename, '.'.$ext); + $temp_path = INSTALLDIR.DIRECTORY_SEPARATOR.ltrim($_FILES[$form_name]['tmp_name'], '/tmp/').'.'.$ext; + if (!in_array($ext, ['tar', 'zip'])) { // IF not a Phar extension + $ext = null; // Let it throw exception below + } + } + if (is_null($ext)) { + // garbage collect + @unlink($_FILES[$form_name]['tmp_name']); + throw new ClientException(_m('Invalid plugin package extension. Must be either tar or zip.')); + } + + move_uploaded_file($_FILES[$form_name]['tmp_name'], $temp_path); + $this->extractPlugin($temp_path, $plugin_name); + } + + /** + * Plugin extractor + * The file should have the plugin_name (plus the extension) and inside two directories, the `includes` which will + * be unpacked to local/plugins/:filename and a `public` which will go to public/local/plugins/:filename + * + * @param $temp_path string Current location of the plugin package (either a tarball or a zip archive) + * @param $plugin_name string see uploadPlugin() + * @throws ClientException If anything goes wrong + */ + public function extractPlugin($temp_path, $plugin_name): void + { + $phar = new PharData($temp_path); + $dest_installdir = INSTALLDIR . '/local/plugins/'.$plugin_name; + $dest_publicdir = PUBLICDIR . '/local/plugins/'.$plugin_name; + try { + $phar->extractTo($dest_installdir, 'includes', false); + $phar->extractTo($dest_publicdir, 'public', false); + } catch (PharException $e) { + // garbage collect + @unlink($temp_path); + // Rollback + deleteTree($dest_installdir); + deleteTree($dest_publicdir); + // Warn about the failure + throw new ClientException($e->getMessage()); + } + foreach ([$dest_installdir.'includes', + $dest_publicdir.'public'] as $source) { + $files = scandir("source"); + foreach ($files as $file) { + if (in_array($file, ['.', '..'])) { + continue; + } + rename($source . $file, dirname($source) . $file); + } + } + rmdir($dest_installdir.'includes'); + rmdir($dest_publicdir.'public'); + + // garbage collect + @unlink($temp_path); + } +} diff --git a/actions/pluginsadminpanel.php b/actions/pluginsadminpanel.php index a01afea93f..4d3045599e 100644 --- a/actions/pluginsadminpanel.php +++ b/actions/pluginsadminpanel.php @@ -36,7 +36,7 @@ class PluginsadminpanelAction extends AdminPanelAction function title() { // TRANS: Tab and title for plugins admin panel. - return _m('TITLE','Plugins'); + return _m('TITLE', 'Plugins'); } /** @@ -48,8 +48,8 @@ class PluginsadminpanelAction extends AdminPanelAction { // TRANS: Instructions at top of plugin admin page. return _m('Additional plugins can be enabled and configured manually. ' . - 'See the online plugin ' . - 'documentation for more details.'); + 'See the online plugin ' . + 'documentation for more details.'); } /** @@ -59,6 +59,46 @@ class PluginsadminpanelAction extends AdminPanelAction */ function showForm() { + $this->elementStart('form', [ + 'enctype' => 'multipart/form-data', + 'method' => 'post', + 'id' => 'form_install_plugin', + 'class' => 'form_settings', + 'action' => + common_local_url('plugininstall') + ]); + $this->elementStart('fieldset'); + // TRANS: Avatar upload page form legend. + $this->element('legend', null, _('Install Plugin')); + $this->hidden('token', common_session_token()); + + $this->elementStart('ul', 'form_data'); + + $this->elementStart('li', ['id' => 'settings_attach']); + $this->element('input', [ + 'name' => 'MAX_FILE_SIZE', + 'type' => 'hidden', + 'id' => 'MAX_FILE_SIZE', + 'value' => common_config('attachments', 'file_quota') + ]); + $this->element('input', [ + 'name' => 'pluginfile', + 'type' => 'file', + 'id' => 'pluginfile' + ]); + $this->elementEnd('li'); + $this->elementEnd('ul'); + + $this->elementStart('ul', 'form_actions'); + $this->elementStart('li'); + // TRANS: Button on avatar upload page to upload an avatar. + $this->submit('upload', _m('BUTTON', 'Upload')); + $this->elementEnd('li'); + $this->elementEnd('ul'); + + $this->elementEnd('fieldset'); + $this->elementEnd('form'); + $this->elementStart('fieldset', ['id' => 'settings_plugins_default']); // TRANS: Admin form section header @@ -74,4 +114,9 @@ class PluginsadminpanelAction extends AdminPanelAction $list = new PluginList($this); $list->show(); } + + function saveSettings() + { + parent::saveSettings(); + } } diff --git a/classes/File.php b/classes/File.php index 739a00aa50..7420eee977 100644 --- a/classes/File.php +++ b/classes/File.php @@ -261,7 +261,14 @@ class File extends Managed_DataObject $file = new File; - $query = "select sum(size) as total from file join file_to_post on file_to_post.file_id = file.id join notice on file_to_post.post_id = notice.id where profile_id = {$scoped->id} and file.url like '%/notice/%/file'"; + $query = "SELECT sum(size) AS total + FROM file + INNER JOIN file_to_post + ON file_to_post.file_id = file.id + INNER JOIN notice + ON file_to_post.post_id = notice.id + WHERE profile_id = {$scoped->id} AND + file.url LIKE '%/notice/%/file'"; $file->query($query); $file->fetch(); $total = $file->total + $fileSize; @@ -279,7 +286,7 @@ class File extends Managed_DataObject ) ); } - $query .= ' AND EXTRACT(month FROM file.modified) = EXTRACT(month FROM now()) and EXTRACT(year FROM file.modified) = EXTRACT(year FROM now())'; + $query .= ' AND EXTRACT(month FROM file.modified) = EXTRACT(month FROM now()) AND EXTRACT(year FROM file.modified) = EXTRACT(year FROM now())'; $file->query($query); $file->fetch(); $total = $file->total + $fileSize; @@ -381,10 +388,10 @@ class File extends Managed_DataObject // If we can't recognize the extension from the MIME, we try // to guess based on filename, if one was supplied. if (!is_null($filename)) { - $ext = getSafeExtension($filename); + $ext = self::getSafeExtension($filename); if ($ext === false) { // we don't have a safe replacement extension - throw new ClientException(_('Blacklisted file extension.')); + throw new ClientException(_m('Blacklisted file extension.')); } else { return $ext; } @@ -645,7 +652,7 @@ class File extends Managed_DataObject if ($info !== false) { return $info['mime']; } else { - throw new UnsupportedMediaException(_("Thumbnail is not an image.")); + throw new UnsupportedMediaException(_m("Thumbnail is not an image.")); } } diff --git a/lib/deletetree.php b/lib/deletetree.php new file mode 100644 index 0000000000..300cdaa1a6 --- /dev/null +++ b/lib/deletetree.php @@ -0,0 +1,53 @@ +. + */ + +/** + * Recursively deletes a directory tree. + * + * @param string $folder The directory path. + * @param bool $keepRootFolder Whether to keep the top-level folder. + * @return bool TRUE on success, otherwise FALSE. + */ +function deleteTree( + $folder, + $keepRootFolder = false +) { + // Handle bad arguments. + if (empty($folder) || !file_exists($folder)) { + return true; // No such file/folder exists. + } elseif (is_file($folder) || is_link($folder)) { + return @unlink($folder); // Delete file/link. + } + + // Delete all children. + $files = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($folder, RecursiveDirectoryIterator::SKIP_DOTS), + RecursiveIteratorIterator::CHILD_FIRST + ); + + foreach ($files as $fileinfo) { + $action = ($fileinfo->isDir() ? 'rmdir' : 'unlink'); + if (!@$action($fileinfo->getRealPath())) { + return false; // Abort due to the failure. + } + } + + // Delete the root folder itself? + return (!$keepRootFolder ? @rmdir($folder) : true); +} diff --git a/lib/mediafile.php b/lib/mediafile.php index 7f81351f9b..3fa94335bd 100644 --- a/lib/mediafile.php +++ b/lib/mediafile.php @@ -181,7 +181,7 @@ class MediaFile if ($file_id===false) { common_log_db_error($file, "INSERT", __FILE__); // TRANS: Client exception thrown when a database error was thrown during a file upload operation. - throw new ClientException(_('There was a database error while saving your file. Please try again.')); + throw new ClientException(_m('There was a database error while saving your file. Please try again.')); } // Set file geometrical properties if available @@ -244,7 +244,7 @@ class MediaFile public static function encodeFilename($original_name, string $filehash, $ext = null) : string { if (empty($original_name)) { - $original_name = _('Untitled attachment'); + $original_name = _m('Untitled attachment'); } // If we're given an extension explicitly, use it, otherwise... @@ -253,7 +253,7 @@ class MediaFile // null if no extension File::getSafeExtension($original_name); if ($ext === false) { - throw new ClientException(_('Blacklisted file extension.')); + throw new ClientException(_m('Blacklisted file extension.')); } if (!empty($ext)) { @@ -315,7 +315,7 @@ class MediaFile * This format should be respected. Notice the dash, which is important to distinguish it from the previous * format ("{$hash}.{$ext}") * - * @param string $param + * @param string $param Form name * @param Profile|null $scoped * @return ImageFile|MediaFile * @throws ClientException @@ -328,7 +328,7 @@ class MediaFile public static function fromUpload(string $param='media', Profile $scoped=null) { // The existence of the "error" element means PHP has processed it properly even if it was ok. - if (!isset($_FILES[$param]) || !isset($_FILES[$param]['error'])) { + if (!(isset($_FILES[$param]) && isset($_FILES[$param]['error']))) { throw new NoUploadedMediaException($param); } @@ -340,29 +340,29 @@ class MediaFile // TRANS: Exception thrown when too large a file is uploaded. // TRANS: %s is the maximum file size, for example "500b", "10kB" or "2MB". throw new ClientException(sprintf( - _('That file is too big. The maximum file size is %s.'), + _m('That file is too big. The maximum file size is %s.'), self::maxFileSize() )); case UPLOAD_ERR_PARTIAL: @unlink($_FILES[$param]['tmp_name']); // TRANS: Client exception. - throw new ClientException(_('The uploaded file was only partially uploaded.')); + throw new ClientException(_m('The uploaded file was only partially uploaded.')); case UPLOAD_ERR_NO_FILE: // No file; probably just a non-AJAX submission. throw new NoUploadedMediaException($param); case UPLOAD_ERR_NO_TMP_DIR: // TRANS: Client exception thrown when a temporary folder is not present to store a file upload. - throw new ClientException(_('Missing a temporary folder.')); + throw new ClientException(_m('Missing a temporary folder.')); case UPLOAD_ERR_CANT_WRITE: // TRANS: Client exception thrown when writing to disk is not possible during a file upload operation. - throw new ClientException(_('Failed to write file to disk.')); + throw new ClientException(_m('Failed to write file to disk.')); case UPLOAD_ERR_EXTENSION: // TRANS: Client exception thrown when a file upload operation has been stopped by an extension. - throw new ClientException(_('File upload stopped by extension.')); + throw new ClientException(_m('File upload stopped by extension.')); default: common_log(LOG_ERR, __METHOD__ . ": Unknown upload error " . $_FILES[$param]['error']); // TRANS: Client exception thrown when a file upload operation has failed with an unknown reason. - throw new ClientException(_('System error uploading file.')); + throw new ClientException(_m('System error uploading file.')); } $filehash = strtolower(self::getHashOfFile($_FILES[$param]['tmp_name'])); @@ -386,19 +386,19 @@ class MediaFile $basename = basename($_FILES[$param]['name']); - if ($media === 'image') { + if ($media == 'image') { // Use -1 for the id to avoid adding this temporary file to the DB $img = new ImageFile(-1, $_FILES[$param]['tmp_name']); - // Validate the image by reencoding it. Additionally normalizes old formats to PNG, + // Validate the image by re-encoding it. Additionally normalizes old formats to PNG, // keeping JPEG and GIF untouched $outpath = $img->resizeTo($img->filepath); $ext = image_type_to_extension($img->preferredType(), false); } + $filename = self::encodeFilename($basename, $filehash, isset($ext) ? $ext : File::getSafeExtension($basename)); - $filename = self::encodeFilename($basename, $filehash, $ext); $filepath = File::path($filename); - if ($media === 'image') { + if ($media == 'image') { $result = rename($outpath, $filepath); } else { $result = move_uploaded_file($_FILES[$param]['tmp_name'], $filepath); @@ -407,10 +407,10 @@ class MediaFile // TRANS: Client exception thrown when a file upload operation fails because the file could // TRANS: not be moved from the temporary folder to the permanent file location. // UX: too specific - throw new ClientException(_('File could not be moved to destination directory.')); + throw new ClientException(_m('File could not be moved to destination directory.')); } - if ($media === 'image') { + if ($media == 'image') { return new ImageFile(null, $filepath); } } @@ -443,7 +443,7 @@ class MediaFile if (false === file_put_contents($e->path, fread($fh, filesize($stream['uri'])))) { // TRANS: Client exception thrown when a file upload operation fails because the file could // TRANS: not be moved from the temporary folder to the permanent file location. - throw new ClientException(_('File could not be moved to destination directory.')); + throw new ClientException(_m('File could not be moved to destination directory.')); } if (!chmod($e->path, 0664)) { common_log(LOG_ERR, 'Could not chmod uploaded file: '._ve($e->path)); @@ -467,7 +467,7 @@ class MediaFile common_log(LOG_ERR, 'File could not be moved (or chmodded) from '._ve($stream['uri']) . ' to ' . _ve($filepath)); // TRANS: Client exception thrown when a file upload operation fails because the file could // TRANS: not be moved from the temporary folder to the permanent file location. - throw new ClientException(_('File could not be moved to destination directory.')); + throw new ClientException(_m('File could not be moved to destination directory.')); } } @@ -614,12 +614,12 @@ class MediaFile // TRANS: Client exception thrown trying to upload a forbidden MIME type. // TRANS: %1$s is the file type that was denied, %2$s is the application part of // TRANS: the MIME type that was denied. - $hint = sprintf(_('"%1$s" is not a supported file type on this server. ' . + $hint = sprintf(_m('"%1$s" is not a supported file type on this server. ' . 'Try using another %2$s format.'), $mimetype, $media); } else { // TRANS: Client exception thrown trying to upload a forbidden MIME type. // TRANS: %s is the file type that was denied. - $hint = sprintf(_('"%s" is not a supported file type on this server.'), $mimetype); + $hint = sprintf(_m('"%s" is not a supported file type on this server.'), $mimetype); } throw new ClientException($hint); } @@ -632,7 +632,7 @@ class MediaFile */ public static function getDisplayName(File $file) : string { if (empty($file->filename)) { - return _('Untitled attachment'); + return _m('Untitled attachment'); } // New file name format is "{bin2hex(original_name.ext)}-{$hash}" @@ -650,7 +650,7 @@ class MediaFile $ret = preg_match('/^.+?\.+?(.+)$/', $file->filename, $matches); if ($ret !== 1) { common_log(LOG_ERR, $log_error_msg); - return _('Untitled attachment'); + return _m('Untitled attachment'); } $ext = $matches[1]; // There's a blacklisted extension array, which could have an alternative diff --git a/lib/router.php b/lib/router.php index 161b198318..3164563883 100644 --- a/lib/router.php +++ b/lib/router.php @@ -811,6 +811,8 @@ class Router $m->connect('panel/plugins/disable/:plugin', ['action' => 'plugindisable'], ['plugin' => '[A-Za-z0-9_]+']); + $m->connect('panel/plugins/install', + ['action' => 'plugininstall']); // Common people-tag stuff