diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/api/class-base.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/api/class-base.php index 8e1c9520a8..4ca6e85601 100644 --- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/api/class-base.php +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/api/class-base.php @@ -37,6 +37,7 @@ public static function load_routes() { new Routes\Plugin_Release_Confirmation(); new Routes\Plugin_Categorization(); new Routes\Plugin_Upload(); + new Routes\Plugin_Upload_to_SVN(); new Routes\Plugin_Blueprint(); new Routes\Plugin_Review(); } diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/api/routes/class-plugin-upload-to-svn.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/api/routes/class-plugin-upload-to-svn.php new file mode 100644 index 0000000000..3f0c820ad5 --- /dev/null +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/api/routes/class-plugin-upload-to-svn.php @@ -0,0 +1,130 @@ +[^/]+)/?', array( + 'methods' => WP_REST_Server::EDITABLE, + 'callback' => array( $this, 'upload' ), + 'permission_callback' => array( $this, 'permission_check' ), + 'args' => [ + 'plugin_slug' => [ + 'type' => 'string', + 'required' => true, + 'validate_callback' => array( $this, 'validate_plugin_slug_callback' ), + ], + 'file' => [ + // This field won't actually be used, this is just a placeholder to encourage including a file. + 'required' => false, + ], + 'set_as_stable' => [ + 'type' => 'boolean', + 'required' => false, + 'default' => true, + ] + ], + ) ); + } + + public function permission_check( $request ) { + /** + * Auth should be a 2FA'd user. + */ + if ( ! is_user_logged_in() ) { + return false; + } + + // Check the current user is 2FA'd. + $status = get_revalidation_status(); + if ( ! $status->last_validated ) { + return new WP_Error( 'not_2fa', 'The authorized user does not have 2FA enabled.', 403 ); + } + + // TODO: This API endpoint should not be interactive, it should be a async job creator. + if ( $status->needs_revalidate ) { + // TODO Uhhhh... We kinda need to revalidate, yet we need the ZIP file that they've submitted.. Store it somewhere? + wp_redirect( get_revalidate_url( /* TODO, current rest-api-endpoint url here... */ ) ); + die(); + } + + // User must have confirmed 2FA to get here. + $user = wp_get_current_user(); + + // If no user, bail. + if ( ! $user || ! $user->exists() ) { + return false; + } + + // Check if the user is a committer. + $committers = Tools::get_plugin_committers( $request['plugin_slug'], false ); + if ( $user && in_array( $user->user_login, $committers, true ) ) { + return true; + } + + return new WP_Error( 'not_a_committer', 'The authorized user is not a committer.', 403 ); + } + + /** + * Process a ZIP upload and commit it to SVN. + * + * @param \WP_REST_Request $request The request object. + * @return bool|WP_Error True on success, WP_Error on failure. + */ + public function upload( $request ) { + global $post; + $post = Plugin_Directory::get_plugin_post( $request['plugin_slug'] ); + + // Validate that we expected a ZIP to be uploaded. + $file = reset( $_FILES ); + if ( ! $file ) { + return new WP_Error( 'no_file', 'No file was uploaded.', 400 ); + } + + // Start the automated SVN process. + $svn_automations = new SVN_Automations( $post ); + + // Import the ZIP to the SVN repositories trunk folder. + $result = $svn_automations->import_zip_to_trunk( $file['tmp_name'] ); + if ( ! $result || is_wp_error( $result ) ) { + return $result; + } + + // Tag it, and set as stable. + if ( $request['set_as_stable'] ) { + $svn_automations->create_tag_from_trunk( true ); + } + + // Commit the new version. + $result = $svn_automations->commit(); + if ( ! $result ) { + return new WP_Error( 'commit_failed', 'An error occured during the SVN commit.', 500 ); + } + + return true; + } + +} diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/jobs/class-plugin-zip-import.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/jobs/class-plugin-zip-import.php new file mode 100644 index 0000000000..dc61f0f08b --- /dev/null +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/jobs/class-plugin-zip-import.php @@ -0,0 +1,109 @@ +post_author; + } + + // Local path to the ZIP. + $zip_filepath = get_attached_file( $zip->ID ); + } elseif ( $zip_reference && preg_match( '/^https?:\/\//', $zip_reference ) ) { + require_once ABSPATH . 'wp-admin/includes/file.php'; + + // Download the ZIP. + $zip_filepath = download_url( $zip_reference ); + if ( is_wp_error( $zip_filepath ) ) { + fwrite( STDERR, "[{$plugin_slug}] ZIP Import Failed: " . $zip_filepath->get_error_message() . "\n" ); + return false; + } + + // Cleanup the ZIP on shutdown. + add_action( 'shutdown', function() use ( $zip_filepath ) { + unlink( $zip_filepath ); + } ); + + } else { + fwrite( STDERR, "[{$plugin_slug}] ZIP Import Failed: Invalid ZIP reference.\n" ); + return false; + } + + // Start the automated SVN process. + $svn_automations = new SVN_Automations( $plugin ); + + // Import the ZIP to the SVN repositories trunk folder. + $result = $svn_automations->import_zip_to_trunk( $zip_filepath ); + if ( is_wp_error( $result ) ) { + fwrite( STDERR, "[{$plugin_slug}] ZIP Import Failed: " . $result->get_error_message() . "\n" ); + return false; + } + + // Tag it, and set as stable. + if ( $set_as_stable ) { + $result = $svn_automations->create_tag_from_trunk( true ); + if ( is_wp_error( $result ) ) { + fwrite( STDERR, "[{$plugin_slug}] ZIP Import Failed: " . $result->get_error_message() . "\n" ); + return false; + } + } + + // Commit the new version. + $result = $svn_automations->commit(); + if ( ! $result ) { + fwrite( STDERR, "[{$plugin_slug}] ZIP Import Failed: An error occured during the SVN commit.\n" ); + return false; + } + + return true; + } + +} diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/tools/class-svn-automation.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tools/class-svn-automation.php new file mode 100644 index 0000000000..370ca6cda5 --- /dev/null +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tools/class-svn-automation.php @@ -0,0 +1,241 @@ +plugin = Plugin_Directory::get_plugin_post( $plugin ); + $this->svn_tmp = Filesystem::temp_directory( 'svn-' . $this->plugin->post_name ); + + if ( ! $this->svn_tmp ) { + return; + } + + $result = SVN::checkout( + Import::PLUGIN_SVN_BASE . '/' . $this->plugin->post_name, + $this->svn_tmp, + [ 'depth' => 'empty' ] + ); + if ( ! $result['result'] ) { + $this->svn_tmp = false; + return; + } + + // We've created an empty checkout, populate it with trunk files and available tags. + // Error handling is skipped as this will be caught in the main methods. + SVN::up( $this->svn_tmp . '/trunk/', [ 'set-depth' => 'infinity' ] ); + SVN::up( $this->svn_tmp . '/tags/', [ 'depth' => 'immediates' ] ); + } + + /** + * Import a ZIP file to the SVN repository. + * + * @param string $zip_path The path to the ZIP file. + * @return bool|WP_Error true on success, WP_Error on failure. + */ + public function import_zip_to_trunk( $zip_path ) { + if ( ! $this->svn_tmp ) { + return new WP_Error( 'svn_tmp_not_found', 'SVN temp directory not found.' ); + } + + $trunk_path = $this->svn_tmp . '/trunk/'; + + // Create a temporary folder for the ZIP, and unzip. + $zip_temp = Filesystem::temp_directory( 'zip-' . $this->plugin->post_name . '-' . basename( $zip_path ) ); + $zip_file = Filesystem::unzip( $zip_path, $zip_temp ); + + // Validate the values are expected + $headers = Import::find_plugin_headers( $zip_temp, 2 ); + + if ( ! $headers ) { + return new WP_Error( 'no_plugin', 'No plugin was detected in your ZIP file.', 400 ); + } + $version = $headers->Version ?? '0.0'; + + /* + * Validate that the version is greater than the existing version. + * + * Note: This prevents uploading a security release for a previous branch. Those should be done via SVN directly. + */ + if ( ! $version || ! version_compare( $version, $this->plugin->version, '>' ) ) { + return new WP_Error( + 'version_not_newer', + sprintf( + 'The version in the ZIP file is not newer than the existing version. Please upload a version greater than %s, found %s.', + esc_html( $this->plugin->version ), + esc_html( $headers->Version ) + ), + 400 + ); + } + + $this->default_commit_message = "Importing version {$version}."; + + // Find the base directory of the ZIP + $plugin_root = dirname( $headers->PluginFile ); + + // Remove all files from the SVN folder + Filesystem::rmdir( $trunk_path ); + + // Copy the ZIP files to trunk + Filesystem::copy( $plugin_root, $trunk_path, true ); + + // Add new files to SVN, remove the old ones. + SVN::add_remove( $trunk_path ); + + return true; + } + + /** + * Create a tag from the current version in trunk. + * + * @param bool $update_stable_tag Whether to update the stable tag. + * @return bool|WP_Error true on success, WP_Error on failure. + */ + public function create_tag_from_trunk( $update_stable_tag = true ) { + if ( ! $this->svn_tmp ) { + return new WP_Error( 'svn_tmp_not_found', 'SVN temp directory not found.' ); + } + + // Determine version of trunk + $trunk_path = $this->svn_tmp . '/trunk/'; + $headers = Import::find_plugin_headers( $trunk_path, 2 ); + $version = $headers->Version ?? ''; + + if ( empty( $version ) ) { + return new WP_Error( 'no_plugin', 'No plugin was detected, or an invalid version is specified.', 400 ); + } + + // check no tag exists for that version + $new_tag_path = $this->svn_tmp . '/tags/' . $version . '/'; + if ( is_dir( $new_tag_path ) ) { + return new WP_Error( 'tag_exists', 'A tag already exists for this version.', 400 ); + } + + $this->default_commit_message = "Creating {$version} tag."; + + // update the stable_tag in the readme.xxx file. + if ( $update_stable_tag ) { + $this->default_commit_message = "Creating {$version} tag and marking as stable."; + + $readme_file = Import::find_readme_file( $trunk_path ); + if ( ! $readme_file ) { + return new WP_Error( 'no_readme', 'Unable to find a readme file.', 500 ); + } + + $readme_contents = file_get_contents( $readme_file ); + $readme_parsed = new Parser( $readme_contents ); + + // If there's no stable tag present in the readme, add it. + if ( + '' === $readme_parsed->stable_tag && + ! preg_match( '!^[\s*]*Stable Tag:!i', $readme_contents ) + ) { + // Find the first header.. + $valid_headers = array_keys( $readme_parsed->valid_headers ); + $valid_headers = array_map( function( $header ) { + return preg_quote( $header, '!' ); + }, $valid_headers ); + $valid_headers = implode( '|', $valid_headers ); + + $readme_contents = preg_replace( + $regex = '/^(([\s*]*)(' . $valid_headers . '):.+([\r\n]+))/mi', + // Prepend the Stable Tag line to the first header, using the same line-ending and prefix. + "\\2Stable Tag: {$version}\\4\\1", + $readme_contents, + 1 // Only replace the first header. + ); + } + + // If the version is different, update the stable tag. + if ( $version !== $readme_parsed->stable_tag ) { + $new_contents = preg_replace( + '/^([\s*]*Stable Tag):\s*.+(\r)?$/mi', + "\\1: $version\\2", + $readme_contents, + 1 + ); + file_put_contents( $readme_file, $new_contents ); + } + + // Again, check the readme has the expected version. + $readme_parsed = new Parser( $readme_file ); + if ( $readme_parsed->stable_tag !== $version ) { + return new WP_Error( + 'stable_tag_not_updated', + 'The Stable Tag was not able to be updated in the readme. Please ensure a "Stable Tag: x.y" header exists in your readme.', + 500 + ); + } + } + + // copy trunk to tags + $result = SVN::copy( $trunk_path, $new_tag_path ); + if ( ! $result['result'] ) { + return new WP_Error( 'copy_failed', 'Failed to copy trunk to the tag.', 500 ); + } + + return true; + } + + /** + * Commit the changes to SVN. + * + * @param string $message Optional. The commit message. + * @return bool + */ + public function commit( $message = null ) { + // Write the changes to SVN. + $message ??= $this->default_commit_message; + $username = wp_get_current_user()->user_login; + + /* + * NOTE: This commits as the plugin management user. + * The Author byline is added to the commit message to show the actual actor. + */ + $result = SVN::commit( + $this->svn_tmp, + "{$this->plugin->post_name}: {$message}\nAuthor: {$username}." + ); + + return (bool) $result['result']; + } +} \ No newline at end of file diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/tools/class-svn.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tools/class-svn.php index 7c098be2e1..0d3998bfc3 100644 --- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/tools/class-svn.php +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tools/class-svn.php @@ -232,6 +232,37 @@ public static function add( $file ) { return compact( 'result', 'errors' ); } + /** + * Mark removed files as deleted, and add any new files. + * + * @static + * @param string $path The folder to add/remove files from. + * @return array { + * @type bool $result The result of the operation. + * @type false|array $errors Whether any errors or warnings were encountered. + * } + */ + public static function add_remove( $path ) { + $options = [ + 'no-ignore', + 'non-interactive', + ]; + $esc_options = self::parse_esc_parameters( $options ); + + $output = ''; + $esc_path = escapeshellarg( $path ); + + // Leading ! => Remove file + $output .= self::shell_exec( "svn status {$esc_options} {$esc_path} | grep ^! | cut -c9- | xargs svn rm {}" ); + // Leading ? => Add file + $output .= self::shell_exec( "svn status {$esc_options} {$esc_path} | grep ^? | cut -c9- | xargs svn add {}" ); + + $errors = self::parse_svn_errors( $output ); + $result = ! $errors; + + return compact( 'result', 'errors' ); + } + /** * Commit changes in a SVN checkout. *