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.
*