diff --git a/freescout.wordpress.net/AGENTS.md b/freescout.wordpress.net/AGENTS.md index 24eebc6b38..f764728593 100644 --- a/freescout.wordpress.net/AGENTS.md +++ b/freescout.wordpress.net/AGENTS.md @@ -10,6 +10,7 @@ Context for AI coding agents and developers working on the WordPress.org FreeSco - `Modules//` — one directory per module (`module.json`, `Providers/`, `Http/`, `Resources/`, `Database/Migrations/`, `Public/`, `tests/`). - `WPOrgAkismet` — checks new conversations from senders with Akismet (`rest.akismet.com`, its own key), and reports agents' corrections. + - `WPOrgApi` — endpoints WordPress.org calls, signed with the same secret the other way around (`X-WPOrg-Signature`, see `Services/SignedRequest.php`), outside the `web` middleware. So far, a mailbox's saved reply by name, for the plugin directory's scripts. - `WPOrgHelpScoutImport` — imports HelpScout mailboxes' conversations, with their tags and custom fields, and their saved replies, from HelpScout's API, page by page on the queue, without the events live email fires, and points WordPress.org's copy of conversations at the imported ones as each mailbox switches. Administrators run it under Manage » HelpScout Import. Remove it once every mailbox has moved. - `WPOrgSearch` — gives searches without other filters core's After filter, a year ago unless the agent picks a date, and runs core's conversation search through its `search.conversations.perform` hook in one query with `COUNT(*) OVER()`, stopped after 20 seconds on MariaDB. - `WPOrgSidebar` — WordPress.org panels in the conversation sidebar, loaded over AJAX from `api.wordpress.org/dotorg/freescout/`. diff --git a/freescout.wordpress.net/Modules/WPOrgApi/Config/config.php b/freescout.wordpress.net/Modules/WPOrgApi/Config/config.php new file mode 100644 index 0000000000..7359cc99cd --- /dev/null +++ b/freescout.wordpress.net/Modules/WPOrgApi/Config/config.php @@ -0,0 +1,15 @@ + 'WPOrgApi', + + // Shared with WPOrgSidebar and WPOrgWebhooks: must match FREESCOUT_SECRET on WordPress.org. + 'secret' => env( 'WPORG_API_SECRET', '' ), +); diff --git a/freescout.wordpress.net/Modules/WPOrgApi/Http/Controllers/SavedReplyController.php b/freescout.wordpress.net/Modules/WPOrgApi/Http/Controllers/SavedReplyController.php new file mode 100644 index 0000000000..338ee8d2e5 --- /dev/null +++ b/freescout.wordpress.net/Modules/WPOrgApi/Http/Controllers/SavedReplyController.php @@ -0,0 +1,75 @@ +json( array( 'error' => 'invalid_signature' ), 403 ); + } + + try { + if ( ! \App\Module::isActive( self::MODULE ) || ! Schema::hasTable( self::TABLE ) ) { + return response()->json( array( 'error' => 'saved_replies_unavailable' ), 503 ); + } + + $reply = \DB::table( self::TABLE ) + ->where( 'mailbox_id', (int) ( $payload['mailbox_id'] ?? 0 ) ) + ->where( 'name', (string) ( $payload['name'] ?? '' ) ) + ->first( array( 'name', 'text' ) ); + } catch ( \Throwable $e ) { + \Log::error( '[WPOrgApi] Could not read a saved reply: ' . $e->getMessage() ); + + return response()->json( array( 'error' => 'saved_replies_unavailable' ), 503 ); + } + + if ( ! $reply ) { + return response()->json( array( 'error' => 'not_found' ), 404 ); + } + + return response()->json( + array( + 'name' => (string) $reply->name, + 'text' => (string) $reply->text, + ) + ); + } +} diff --git a/freescout.wordpress.net/Modules/WPOrgApi/Http/routes.php b/freescout.wordpress.net/Modules/WPOrgApi/Http/routes.php new file mode 100644 index 0000000000..8b1f037470 --- /dev/null +++ b/freescout.wordpress.net/Modules/WPOrgApi/Http/routes.php @@ -0,0 +1,20 @@ + array( 'throttle:60,1' ), + 'prefix' => \Helper::getSubdirectory(), + 'namespace' => 'Modules\WPOrgApi\Http\Controllers', + ), + static function (): void { + Route::post( '/wporgapi/saved-reply', 'SavedReplyController@show' )->name( 'wporgapi.saved_reply' ); + } +); diff --git a/freescout.wordpress.net/Modules/WPOrgApi/Providers/WPOrgApiServiceProvider.php b/freescout.wordpress.net/Modules/WPOrgApi/Providers/WPOrgApiServiceProvider.php new file mode 100644 index 0000000000..f9114e025a --- /dev/null +++ b/freescout.wordpress.net/Modules/WPOrgApi/Providers/WPOrgApiServiceProvider.php @@ -0,0 +1,63 @@ +hide_secret_from_debug_pages(); + } + + /** + * Boots the module. + * + * @return void + */ + public function boot(): void { + $this->mergeConfigFrom( __DIR__ . '/../Config/config.php', self::ALIAS ); + $this->loadRoutesFrom( __DIR__ . '/../Http/routes.php' ); + } + + /** + * Keeps the signing secret off the Whoops error pages that APP_DEBUG shows, which list the environment. + * + * @return void + */ + private function hide_secret_from_debug_pages(): void { + $hidden = (array) config( 'app.debug_blacklist', array() ); + + foreach ( array( '_ENV', '_SERVER' ) as $key ) { + $hidden[ $key ] = (array) ( $hidden[ $key ] ?? array() ); + + if ( ! in_array( 'WPORG_API_SECRET', $hidden[ $key ], true ) ) { + $hidden[ $key ][] = 'WPORG_API_SECRET'; + } + } + + config( array( 'app.debug_blacklist' => $hidden ) ); + } +} diff --git a/freescout.wordpress.net/Modules/WPOrgApi/Public/img/icon.svg b/freescout.wordpress.net/Modules/WPOrgApi/Public/img/icon.svg new file mode 100644 index 0000000000..d5b413d551 --- /dev/null +++ b/freescout.wordpress.net/Modules/WPOrgApi/Public/img/icon.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/freescout.wordpress.net/Modules/WPOrgApi/README.md b/freescout.wordpress.net/Modules/WPOrgApi/README.md new file mode 100644 index 0000000000..2ea56c4e40 --- /dev/null +++ b/freescout.wordpress.net/Modules/WPOrgApi/README.md @@ -0,0 +1,13 @@ +# WP.org API + +Answers requests from WordPress.org, which reads from FreeScout what only FreeScout has. The other modules talk to WordPress.org; this one is talked to. + +## Endpoints + +- **`POST /wporgapi/saved-reply`:** a mailbox's saved reply, by name: `{"mailbox_id": 3, "name": "Closed: Security Exploit"}` gets `{"name": …, "text": …}`, the reply's HTML, from the Saved Replies module. The plugin directory's scripts write their emails from them, so the plugins team keeps the texts up to date in FreeScout. It answers 404 when the mailbox has no such reply, and 503 while the Saved Replies module is off. + +Requests are JSON, signed with an HMAC-SHA256 of the body in `X-WPOrg-Signature`, with the secret the modules share with WordPress.org. They name the endpoint, as `endpoint`, and when they were sent, as `sent_at`, and are refused after 5 minutes. They aren't in FreeScout's `web` middleware, so they need no session or CSRF token, and they're throttled to 60 a minute. + +## Setup + +Needs `WPORG_API_SECRET`, which must match `FREESCOUT_SECRET` on WordPress.org; see [configuration](../../README.md#deployment). Without it, every request is refused. diff --git a/freescout.wordpress.net/Modules/WPOrgApi/Services/SignedRequest.php b/freescout.wordpress.net/Modules/WPOrgApi/Services/SignedRequest.php new file mode 100644 index 0000000000..e9fe6f1dcb --- /dev/null +++ b/freescout.wordpress.net/Modules/WPOrgApi/Services/SignedRequest.php @@ -0,0 +1,81 @@ +getContent(); + + if ( '' === $secret || ! hash_equals( self::sign( $body, $secret ), (string) $request->header( self::SIGNATURE_HEADER ) ) ) { + return null; + } + + $payload = json_decode( $body, true ); + if ( ! is_array( $payload ) || ( $payload['endpoint'] ?? '' ) !== $endpoint ) { + return null; + } + + $age = time() - (int) ( $payload['sent_at'] ?? 0 ); + if ( $age < -self::MAX_CLOCK_SKEW || $age > self::MAX_AGE ) { + return null; + } + + return $payload; + } +} diff --git a/freescout.wordpress.net/Modules/WPOrgApi/module.json b/freescout.wordpress.net/Modules/WPOrgApi/module.json new file mode 100644 index 0000000000..0c7df94d46 --- /dev/null +++ b/freescout.wordpress.net/Modules/WPOrgApi/module.json @@ -0,0 +1,21 @@ +{ + "name": "WP.org API", + "alias": "wporgapi", + "img": "/modules/wporgapi/img/icon.svg", + "description": "Answers WordPress.org's signed requests, like for a mailbox's saved replies.", + "version": "1.0.0", + "detailsUrl": "https://github.com/WordPress/wordpress.org/tree/trunk/freescout.wordpress.net/Modules/WPOrgApi", + "author": "WordPress.org", + "authorUrl": "https://wordpress.org/", + "requiredAppVersion": "1.8.243", + "license": "GPL-2.0-or-later", + "keywords": [], + "active": 0, + "order": 0, + "providers": [ + "Modules\\WPOrgApi\\Providers\\WPOrgApiServiceProvider" + ], + "aliases": {}, + "files": [], + "requires": [] +} diff --git a/freescout.wordpress.net/Modules/WPOrgApi/tests/SavedReplyTest.php b/freescout.wordpress.net/Modules/WPOrgApi/tests/SavedReplyTest.php new file mode 100644 index 0000000000..e6ec991125 --- /dev/null +++ b/freescout.wordpress.net/Modules/WPOrgApi/tests/SavedReplyTest.php @@ -0,0 +1,185 @@ +increments( 'id' ); + $table->integer( 'mailbox_id' ); + $table->string( 'name', 75 ); + $table->longText( 'text' )->nullable(); + $table->integer( 'user_id' ); + $table->timestamps(); + } + ); + } + + /** + * Registers the module, and switches the Saved Replies module on. + * + * @return void + */ + protected function setUp(): void { + parent::setUp(); + + $this->app->register( WPOrgApiServiceProvider::class ); + $this->app['router']->getRoutes()->refreshNameLookups(); + + \App\Module::clearModulesCache(); + \App\Module::setActive( 'savedreplies', true ); + \App\Module::clearModulesCache(); + + // After DatabaseTransactions' rollback, which was registered first. + $this->beforeApplicationDestroyed( + static function (): void { + Schema::dropIfExists( 'saved_replies' ); + \App\Module::clearModulesCache(); + } + ); + } + + /** + * The reply is the named one of the mailbox asked about, not another mailbox's of the same name. + * + * @return void + */ + public function test_hands_over_the_mailboxs_saved_reply(): void { + $plugins = $this->create_mailbox( 'Plugins' ); + $themes = $this->create_mailbox( 'Themes' ); + $this->create_reply( (int) $themes->id, 'Closed: Security Exploit', '

Themes’ text.

' ); + $this->create_reply( (int) $plugins->id, 'Closed: Security Exploit', '

Your plugin PLUGIN_LINK was closed.

' ); + + $response = $this->request( + array( + 'mailbox_id' => $plugins->id, + 'name' => 'Closed: Security Exploit', + ) + ); + + $response->assertStatus( 200 ); + $this->assertSame( + array( + 'name' => 'Closed: Security Exploit', + 'text' => '

Your plugin PLUGIN_LINK was closed.

', + ), + $response->json() + ); + } + + /** + * Requests WordPress.org didn't sign, for this endpoint, just now, are refused. + * + * @dataProvider data_unsigned_requests + * + * @param string $secret Secret they're signed with. + * @param string $endpoint Endpoint they name. + * @param int $age How long ago they were sent, in seconds. + * @return void + */ + public function test_refuses_requests_wordpress_org_did_not_sign( string $secret, string $endpoint, int $age ): void { + $mailbox = $this->create_mailbox(); + $this->create_reply( (int) $mailbox->id, 'Welcome', '

Hi

' ); + + $response = $this->request( + array( + 'mailbox_id' => $mailbox->id, + 'name' => 'Welcome', + ), + $secret, + $endpoint, + $age + ); + + $response->assertStatus( 403 ); + } + + /** + * Requests to refuse. + * + * @return array[] + */ + public static function data_unsigned_requests(): array { + return array( + 'another secret' => array( 'not-the-secret', 'saved-reply', 0 ), + 'another endpoint' => array( 'test-secret', 'something-else', 0 ), + 'too old' => array( 'test-secret', 'saved-reply', 301 ), + ); + } + + /** + * Adds a saved reply. + * + * @param int $mailbox_id Mailbox ID. + * @param string $name Name. + * @param string $text HTML. + * @return void + */ + private function create_reply( int $mailbox_id, string $name, string $text ): void { + \DB::table( 'saved_replies' )->insert( + array( + 'mailbox_id' => $mailbox_id, + 'name' => $name, + 'text' => $text, + 'user_id' => 1, + ) + ); + } + + /** + * Sends a request the way WordPress.org does. + * + * @param array $payload Payload. + * @param string $secret Secret to sign it with; the configured one by default. + * @param string $endpoint Endpoint it names. + * @param int $age How long ago it was sent, in seconds. + * @return TestResponse + */ + private function request( array $payload, string $secret = 'test-secret', string $endpoint = 'saved-reply', int $age = 0 ): TestResponse { + $payload['endpoint'] = $endpoint; + $payload['sent_at'] = time() - $age; + $body = (string) json_encode( $payload ); + + return $this->call( + 'POST', + route( 'wporgapi.saved_reply' ), + array(), + array(), + array(), + array( + 'CONTENT_TYPE' => 'application/json', + 'HTTP_X_WPORG_SIGNATURE' => SignedRequest::sign( $body, $secret ), + ), + $body + ); + } +} diff --git a/freescout.wordpress.net/README.md b/freescout.wordpress.net/README.md index 55968c9457..77c5f50225 100644 --- a/freescout.wordpress.net/README.md +++ b/freescout.wordpress.net/README.md @@ -5,6 +5,7 @@ | Module | What it does | |---|---| | [WPOrgAkismet](Modules/WPOrgAkismet) | Checks new conversations from senders with Akismet, and teaches it from what agents mark as spam. | +| [WPOrgApi](Modules/WPOrgApi) | Answers WordPress.org's signed requests for what only FreeScout has, like a mailbox's saved replies, which the plugin directory's scripts write their emails from. | | [WPOrgHelpScoutImport](Modules/WPOrgHelpScoutImport) | Imports HelpScout mailboxes' conversations, so their history moves to FreeScout. Administrators run it under Manage » HelpScout Import. | | [WPOrgSearch](Modules/WPOrgSearch) | Speeds up conversation search: an After date for searches without other filters, and one query instead of two. | | [WPOrgSidebar](Modules/WPOrgSidebar) | Shows the sender's WordPress.org profile, forum notes, plugins and themes, and privacy requests next to each conversation. | @@ -12,7 +13,7 @@ | [WPOrgSSO](Modules/WPOrgSSO) | Agents log in with their WordPress.org account, through login.wordpress.org; there's no other way in. Every user is connected to a WordPress.org account, and new users are created from one. Name, email, and avatar are updated from it at every login. | | [WPOrgWebhooks](Modules/WPOrgWebhooks) | Sends conversation events to WordPress.org, which records them as contributor stats. | -WPOrgSidebar, WPOrgWebhooks, and WPOrgSSO talk to [`api.wordpress.org/dotorg/freescout/`](../api.wordpress.org/public_html/dotorg/freescout), which does the WordPress.org lookups. +WPOrgSidebar, WPOrgWebhooks, and WPOrgSSO talk to [`api.wordpress.org/dotorg/freescout/`](../api.wordpress.org/public_html/dotorg/freescout), which does the WordPress.org lookups. WPOrgApi is the other way around: WordPress.org talks to it. ## Development @@ -41,7 +42,7 @@ Configuration, in FreeScout's `.env`. Required: | Variable | Value | |---|---| -| `WPORG_API_SECRET` | Shared secret; must match `FREESCOUT_SECRET` on api.wordpress.org. | +| `WPORG_API_SECRET` | Shared secret; must match `FREESCOUT_SECRET` on api.wordpress.org, and on WordPress.org, which signs its requests to `WPOrgApi` with it. | | `WPORG_SSO_IDP_CERT` | The identity provider's signing certificate, on one line, with or without the BEGIN/END lines; line breaks written as `\n` work too. Until it and `WPORG_API_SECRET` are set, logins stay as they are, so users can be connected first. Once they are, new logins go through WordPress.org, and sessions from before go on for a day; Manage » System » Tools » Logout Users ends them sooner. After that day, switching enforcement off and on again ends every session from while it was off, including a password login of whoever switches it back on: have break-glass or a connected account ready. | | `WPORG_AKISMET_KEY` | FreeScout's own Akismet API key. Without it, `WPOrgAkismet` checks nothing, and spam comes in like any other email. | | `APP_CUSTOM_NUMBER` | `true`. FreeScout's own setting (Manage » Settings » General » Conversation Number » Custom), which saves it here. Conversations imported from HelpScout keep HelpScout's numbers, which people quote; without it, FreeScout shows and searches its internal IDs instead. Before the first live email, also set the **Next Conversation #** there well above HelpScout's numbers, like 2,000,000. | diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/bin/bounced-emails.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/bin/bounced-emails.php index 32ff58e38b..44b52977ee 100644 --- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/bin/bounced-emails.php +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/bin/bounced-emails.php @@ -5,14 +5,15 @@ use WordPressdotorg\Plugin_Directory\Admin\Metabox\Author_Notice; use WordPressdotorg\Plugin_Directory\Admin\Status_Transitions; -use WordPressdotorg\MU_Plugins\Utilities\HelpScout; // NOTE: NOT the same as the HelpScout class in this plugin. +use WordPressdotorg\Plugin_Directory\Clients\FreeScout; +use WordPressdotorg\Plugin_Directory\Tools\Helpdesk; -use function WordPressdotorg\API\HelpScout\{ get_user_email_for_email }; +use function WordPressdotorg\API\FreeScout\{ get_user_email_for_email }; // phpcs:disable WordPress.Security.EscapeOutput /** - * This script will process all the bounced emails in HelpScout and take the appropriate action. + * This script will process all the bounced emails in FreeScout's Plugins mailbox and take the appropriate action. * * It will: * - Fetch plugins tagged 'auto-bounce' @@ -23,7 +24,7 @@ * - In both cases, an Audit log entry is added referencing this script. * - In the case of revoke, the audit log entry contains the bounce message. * - In the case of a plugin closure, the Author notice is set to the next steps for the user AND a copy of the bounce is included. - * - If an action is taken, the HS ticket is closed. (This action will show as 'Systems'). + * - If an action is taken, the conversation is closed, as wordpressdotorg. */ // This script should only be called in a CLI environment. @@ -69,27 +70,118 @@ } -// Load the HelpScout API helper methods. -require_once API_WPORGPATH . '/dotorg/helpscout/common.php'; +if ( ! Helpdesk::is_freescout() || ! FreeScout::api_key() ) { + fwrite( STDERR, "Error! Define FREESCOUT_PLUGINS_MAILBOX_ID and FREESCOUT_API_KEY first.\n" ); + die(); +} + +require_once API_WPORGPATH . '/dotorg/freescout/common.php'; // Set a user to run as. wp_set_current_user( get_user_by( 'slug', 'wordpressdotorg' )->ID ); /** - * Fetch the bounce email threads. + * Fetch the bounce conversations, with their threads, from every page. + * + * @return array[] */ function get_bounces() { - $api = HelpScout::instance(); + $bounces = []; + $page = 1; + + do { + $response = FreeScout::api( + 'conversations', + [ + 'mailboxId' => Helpdesk::freescout_mailbox_id(), + 'embed' => 'threads', + 'status' => 'pending', + 'tag' => 'auto-bounce', + 'page' => $page, + ], + 'GET', + $response_code + ); + + if ( 200 !== $response_code ) { + fwrite( STDERR, "Error! Couldn't list the bounces: $response_code\n" ); + die(); + } + + $bounces = array_merge( $bounces, $response['_embedded']['conversations'] ?? [] ); + } while ( $page++ < (int) ( $response['page']['totalPages'] ?? 0 ) ); + + return $bounces; +} + +/** + * Shape a bounce like FreeScout's sidebar payload, which the bounce helpers read. + * + * Attachments that may hold the bounced email are downloaded, as the API only links them. + * + * @param array $bounce A conversation, with its threads. + * @return object + */ +function get_bounce_request( $bounce ) { + $threads = []; + foreach ( $bounce['_embedded']['threads'] ?? [] as $thread ) { + $attachments = []; + foreach ( $thread['_embedded']['attachments'] ?? [] as $attachment ) { + $mime_type = (string) ( $attachment['mimeType'] ?? '' ); + $size = (int) ( $attachment['size'] ?? 0 ); + $content = null; + + if ( $size <= 100 * KB_IN_BYTES && preg_match( '/message|text|rfc/', $mime_type ) ) { + $response = wp_remote_get( (string) ( $attachment['fileUrl'] ?? '' ), [ 'timeout' => FreeScout::TIMEOUT ] ); + $content = 200 === wp_remote_retrieve_response_code( $response ) ? wp_remote_retrieve_body( $response ) : null; + } + + $attachments[] = (object) [ + 'mime_type' => $mime_type, + 'size' => $size, + 'content' => $content, + ]; + } + + $threads[] = (object) [ + 'type' => (string) ( $thread['type'] ?? '' ), + 'body' => (string) ( $thread['body'] ?? '' ), + 'attachments' => $attachments, + ]; + } - return $api->get_paged( - '/v2/conversations', + return (object) [ + 'conversation' => (object) [ 'subject' => (string) ( $bounce['subject'] ?? '' ) ], + 'sender' => (object) [ + 'email' => (string) ( $bounce['customer']['email'] ?? '' ), + 'first_name' => (string) ( $bounce['customer']['firstName'] ?? '' ), + 'last_name' => (string) ( $bounce['customer']['lastName'] ?? '' ), + ], + 'threads' => $threads, + // A bounce is about whoever the email bounced for, not its sender. + 'related' => true, + ]; +} + +/** + * Close a bounce's conversation. + * + * @param int $conversation_id Conversation ID. + */ +function close_conversation( $conversation_id ) { + FreeScout::api( + 'conversations/' . $conversation_id, [ - 'mailbox' => $api->get_mailbox_id( 'plugins' ), - 'embed' => 'threads', - 'status' => 'pending', - 'tag' => 'auto-bounce', - ] + 'status' => 'closed', + 'byUser' => FreeScout::USER_ID, + ], + 'PUT', + $response_code ); + + if ( $response_code < 200 || $response_code >= 300 ) { + echo "\tCouldn't close " . Helpdesk::conversation_url( $conversation_id, 0, true ) . ": $response_code\n"; + } } /** @@ -102,16 +194,16 @@ function extract_bounce_message( $email ) { $body = ''; $message = ''; - foreach ( $email->_embedded->threads as $reply ) { - if ( 'customer' != $reply->type ) { + foreach ( $email['_embedded']['threads'] ?? [] as $reply ) { + if ( 'customer' != $reply['type'] ) { continue; } - $body = $reply->body; + $body = $reply['body']; break; } - $body = preg_replace( '#
#i', "\n", $body ); + $body = preg_replace( '##i', "\n", $body ); $body = str_replace( "\r", '', $body ); // Trim the body to header-like lines. @@ -160,11 +252,11 @@ function extract_bounce_message( $email ) { } $stats = [ - 'processed' => 0, - 'error' => 0, - 'closed' => 0, - 'revoked-commit' => 0, - 'hs-closed' => 0, + 'processed' => 0, + 'error' => 0, + 'closed' => 0, + 'revoked-commit' => 0, + 'conversations-closed' => 0, ]; $actions_to_take = [ @@ -178,16 +270,16 @@ function extract_bounce_message( $email ) { ], */ ]; -$just_close_it = []; // The HS tickets to just close. +$just_close_it = []; // The conversations to just close. -foreach ( get_bounces()->_embedded->conversations as $bounce ) { - $helpscout_url = "https://secure.helpscout.net/conversation/{$bounce->id}/{$bounce->number}"; +foreach ( get_bounces() as $bounce ) { + $conversation_url = Helpdesk::conversation_url( (int) $bounce['id'], (int) $bounce['number'], true ); $stats['processed']++; - echo "Processing {$helpscout_url}\n"; + echo "Processing {$conversation_url}\n"; - $email = get_user_email_for_email( $bounce ); + $email = get_user_email_for_email( get_bounce_request( $bounce ) ); $user = get_user_by( 'email', $email ); $slugs = Tools::get_users_write_access_plugins( $user ) ?: []; @@ -250,12 +342,12 @@ function( $plugin ) use( $user ) { foreach ( $plugins as $plugin ) { $actions_to_take[ $plugin->ID ] ??= [ 'id' => [], 'users' => [], 'action' => '' ]; - $actions_to_take[ $plugin->ID ]['id'][] = $bounce->id; + $actions_to_take[ $plugin->ID ]['id'][] = (int) $bounce['id']; $actions_to_take[ $plugin->ID ]['users'][ $user->ID ] = $bounce_message; } if ( ! $plugins && $closed_plugins ) { - $just_close_it[] = $bounce->id; + $just_close_it[] = (int) $bounce['id']; } echo "\n"; @@ -284,11 +376,15 @@ function( $plugin ) use( $user ) { $plugin = get_post( $post_id ); - $helpscout_url = ''; - foreach ( $data['id'] as $hs_id ) { - $helpscout_url .= " https://secure.helpscout.net/conversation/{$hs_id}"; - } - $helpscout_url = trim( $helpscout_url ); + $conversation_urls = implode( + ' ', + array_map( + function ( $conversation_id ) { + return Helpdesk::conversation_url( $conversation_id, 0, true ); + }, + $data['id'] + ) + ); echo "Plugin: {$plugin->post_name}\n"; echo "\t" . get_permalink( $plugin ) . "\n"; @@ -305,7 +401,7 @@ function( $plugin ) use( $user ) { } Tools::audit_log( - "Removing {$user->user_login} as a committer due to Email bounce. See {$helpscout_url}.\n{$bounce_message}Automated by bin/bounced-emails.php", + "Removing {$user->user_login} as a committer due to Email bounce. See {$conversation_urls}.\n{$bounce_message}Automated by bin/bounced-emails.php", $plugin ); Tools::revoke_plugin_committer( $plugin, $user ); @@ -313,18 +409,9 @@ function( $plugin ) use( $user ) { $stats['revoked-commit']++; } - // Close those HS tickets. if ( 'live' == OPERATION_MODE ) { - foreach ( $data['id'] as $hs_id ) { - HelpScout::instance()->api( - '/v2/conversations/' . $hs_id, - [ - 'op' => 'replace', - 'path' => '/status', - 'value' => 'closed', - ], - 'PATCH' - ); + foreach ( $data['id'] as $conversation_id ) { + close_conversation( $conversation_id ); } } @@ -339,11 +426,15 @@ function( $plugin ) use( $user ) { $plugin = get_post( $post_id ); - $helpscout_url = ''; - foreach ( $data['id'] as $hs_id ) { - $helpscout_url .= " https://secure.helpscout.net/conversation/{$hs_id}"; - } - $helpscout_url = trim( $helpscout_url ); + $conversation_urls = implode( + ' ', + array_map( + function ( $conversation_id ) { + return Helpdesk::conversation_url( $conversation_id, 0, true ); + }, + $data['id'] + ) + ); echo "Plugin: {$plugin->post_name}\n"; echo "\t" . get_permalink( $plugin ) . "\n"; @@ -358,7 +449,7 @@ function( $plugin ) use( $user ) { } Tools::audit_log( - "Closing due to Email bounce. See {$helpscout_url}.\n{$bounce_message}Automated by bin/bounced-emails.php", + "Closing due to Email bounce. See {$conversation_urls}.\n{$bounce_message}Automated by bin/bounced-emails.php", $plugin ); @@ -396,17 +487,8 @@ function( $plugin ) use( $user ) { 'post_status' => 'closed', ] ); - // Close those HS tickets. - foreach ( $data['id'] as $hs_id ) { - HelpScout::instance()->api( - '/v2/conversations/' . $hs_id, - [ - 'op' => 'replace', - 'path' => '/status', - 'value' => 'closed', - ], - 'PATCH' - ); + foreach ( $data['id'] as $conversation_id ) { + close_conversation( $conversation_id ); } $stats['closed']++; @@ -414,22 +496,13 @@ function( $plugin ) use( $user ) { echo "\n"; } -// Close those HS tickets. -foreach ( $just_close_it as $hs_id ) { - $stats['hs-closed']++; +foreach ( $just_close_it as $conversation_id ) { + $stats['conversations-closed']++; - echo "Marking https://secure.helpscout.net/conversation/{$hs_id} as closed as all plugins are closed.\n"; + echo 'Marking ' . Helpdesk::conversation_url( $conversation_id, 0, true ) . " as closed as all plugins are closed.\n"; if ( 'live' == OPERATION_MODE ) { - HelpScout::instance()->api( - '/v2/conversations/' . $hs_id, - [ - 'op' => 'replace', - 'path' => '/status', - 'value' => 'closed', - ], - 'PATCH' - ); + close_conversation( $conversation_id ); } } diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/bin/email-bulk-security-vulnerabilities.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/bin/email-bulk-security-vulnerabilities.php index 63e2599e29..34d25e1d42 100644 --- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/bin/email-bulk-security-vulnerabilities.php +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/bin/email-bulk-security-vulnerabilities.php @@ -3,13 +3,14 @@ use WordPressdotorg\Plugin_Directory\Plugin_Directory; use WordPressdotorg\Plugin_Directory\Tools; -use WordPressdotorg\MU_Plugins\Utilities\HelpScout; // NOTE: NOT the same as the HelpScout class in this plugin. +use WordPressdotorg\Plugin_Directory\Clients\FreeScout; +use WordPressdotorg\Plugin_Directory\Tools\Helpdesk; /** * This script will process a given CSV and close/email plugin authors. * - * NOTE: The emails created in HelpScout will not be sent. We must also send it (Which this script does). - * As a result, replies to the email will NOT thread properly. + * The emails are sent from FreeScout's Plugins mailbox, as new conversations, so replies thread there. + * Their texts are the mailbox's saved replies, which the WPOrgApi module hands over. * * The CSV should contain multiple fields. * - At least one must be the 'slug', this is the plugin it will be looking for. @@ -31,7 +32,7 @@ // Comment out once you're sure you know what you're doing :) die( 'Please check the source, and validate this will work as you anticipate.' ); -$opts = getopt( '', array( 'url:', 'abspath:', 'csv:', 'only-warn', 'doit' ) ); +$opts = getopt( '', array( 'url:', 'abspath:', 'csv:', 'only-warn', 'doit', 'from:', 'assign:' ) ); if ( empty( $opts['url'] ) ) { $opts['url'] = 'https://wordpress.org/plugins/'; @@ -62,6 +63,10 @@ die( "Need a --csv param." ); } +if ( ! Helpdesk::is_freescout() || ! FreeScout::api_key() ) { + die( "Define FREESCOUT_PLUGINS_MAILBOX_ID and FREESCOUT_API_KEY first.\n" ); +} + if ( 'live' == OPERATION_MODE ) { echo "Running in live mode. Changes will be made.\nProceeding in 10s...\n"; sleep( 10 ); @@ -70,28 +75,35 @@ echo "Running in dry-run mode. No changes will be made. Pass --doit parameter to make changes.\n"; } -// Load the HelpScout API helper methods. -require_once API_WPORGPATH . '/dotorg/helpscout/common.php'; - // Set a user to run as. wp_set_current_user( get_user_by( 'slug', 'wordpressdotorg' )->ID ); -const ATTRIBUTE_TO_USER = 544543; // dd32 - This is who should show as having sent it. Fetch it from your HS profile edit url. -const ASSIGN_TO_USER = 433937; // plugin security team - this is who should be assigned for responses -const WARN_THRESHOLD = 10000; // active installs to go from instant close => warn w/ 30days. +foreach ( array( 'from', 'assign' ) as $opt ) { + if ( isset( $opts[ $opt ] ) && ! ctype_digit( (string) $opts[ $opt ] ) ) { + // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- CLI script; the php_sapi_name() guard above exits for web requests and this is console output. + die( "--$opt must be a numeric FreeScout ID.\n" ); + } +} + +// FreeScout agent the emails come from; wordpressdotorg's by default. +define( __NAMESPACE__ . '\ATTRIBUTE_TO_USER', (int) ( $opts['from'] ?? FreeScout::USER_ID ) ); +// FreeScout user or team to assign the conversations to; unassigned by default. +define( __NAMESPACE__ . '\ASSIGN_TO_USER', (int) ( $opts['assign'] ?? 0 ) ); +const WARN_THRESHOLD = 10000; // active installs to go from instant close => warn w/ 30days. /** - * Fetch the email template from HelpScout. + * Fetch the email template, a saved reply of FreeScout's Plugins mailbox. */ function get_email_template( $name ) { - static $ids; static $cache = []; - $api = HelpScout::instance(); - $ids ??= wp_list_pluck( $api->get( '/v2/mailboxes/' . $api->get_mailbox_id( 'plugins' ) . '/saved-replies' ), 'id', 'name' ); - $cache[ $name ] ??= $api->get( '/v2/mailboxes/' . $api->get_mailbox_id( 'plugins' ) . '/saved-replies/' . $ids[ $name ] ); + $cache[ $name ] ??= FreeScout::saved_reply( $name ); + if ( null === $cache[ $name ] ) { + // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- CLI script; the php_sapi_name() guard above exits for web requests and this is console output. + die( "Couldn't fetch the saved reply \"$name\" from FreeScout.\n" ); + } - return $cache[ $name ]->text ?? false; + return $cache[ $name ]; } function fetch_all_from_csv() { @@ -140,64 +152,49 @@ function format_vulns_for_email( $vulns, $plugin ) { } /** - * Send an email, both by wp_mail() and log it into HelpScout. - * - * HelpScout allows creation of emails through the API, but does not actually allow you to send an email. - * Therefor, we send the email, and create it within HS. - * Unfortunately, replies do not thread to the created email.. but at least we have a log.. + * Send an email, as a new conversation in FreeScout's Plugins mailbox, which sends it. */ function send_email( $args ) { global $opts; - $filter = function() { return 'text/html'; }; - add_filter( 'wp_mail_content_type', $filter ); - - wp_mail( - $args['to'] ?? '', - $args['subject'] ?? '', - str_replace( "
", "
\n", $args['body'] ?? '' ), - array_filter( [ - 'From: WordPress.org Plugin Directory ', - 'Return-Path: ', - ( $args['cc'] ? 'CC: ' . implode( ', ', $args['cc'] ) : '' ), - ( $args['bcc'] ? 'BCC: ' . implode( ', ', (array) $args['bcc'] ) : '' ), - ] ) - ); - remove_filter( 'wp_mail_content_type', $filter ); - - $api = HelpScout::instance(); $payload = [ 'type' => 'email', - 'assignTo' => ASSIGN_TO_USER, - 'mailboxId' => $api->get_mailbox_id( 'plugins' ), + 'mailboxId' => Helpdesk::freescout_mailbox_id(), 'status' => 'closed', - 'imported' => true, 'customer' => array_filter( [ 'email' => $args['to'] ?? '', 'firstName' => explode( ' ', $args['name'] ?? '', 2 )[0] ?? '', 'lastName' => explode( ' ', $args['name'] ?? '', 2 )[1] ?? '', ] ), 'subject' => $args['subject'] ?? '', + // The API takes threads newest first. 'threads' => [ [ 'type' => 'note', - 'text' => 'This was automated by bin/email-bulk-security-vulnerabilities.php processing a CSV file: ' . ( $opts['csv'] ?? '' ) . '', - 'user' => ATTRIBUTE_TO_USER + 'text' => 'This was automated by bin/email-bulk-security-vulnerabilities.php processing a CSV file: ' . esc_html( $opts['csv'] ?? '' ) . '', + 'user' => ATTRIBUTE_TO_USER, ], [ - 'type' => 'reply', - 'cc' => (array) ( $args['cc'] ?? [] ), - 'bcc' => (array) ( $args['bcc'] ?? [] ), - 'text' => $args['body'] ?? '', - 'customer' => [ - 'email' => 'plugins@wordpress.org', - ], - 'user' => ATTRIBUTE_TO_USER - ] - ] + 'type' => 'message', + 'cc' => (array) ( $args['cc'] ?? [] ), + 'bcc' => (array) ( $args['bcc'] ?? [] ), + 'text' => $args['body'] ?? '', + 'user' => ATTRIBUTE_TO_USER, + ], + ], ]; - return $api->post( '/v2/conversations', $payload ); + if ( ASSIGN_TO_USER ) { + $payload['assignTo'] = ASSIGN_TO_USER; + } + + $result = FreeScout::api( 'conversations', $payload, 'POST', $response_code ); + if ( 201 !== $response_code ) { + // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- CLI script; the php_sapi_name() guard above exits for web requests and this is console output. + echo "ERROR: FreeScout didn't create the conversation: $response_code " . wp_json_encode( $result ) . "\n"; + } + + return 201 === $response_code; } $stats = [ @@ -266,7 +263,8 @@ function send_email( $args ) { $body = $email_template; $body = str_replace( 'PLUGIN_LINK', get_permalink( $plugin ), $body ); - $body = str_replace( 'LINK

DETAILS', '
' . $vuln_desc . '
', $body ); + // FreeScout's editor may write the line breaks differently; a callback, as the details may hold a "$1". + $body = preg_replace_callback( '#LINK(\s*\s*)+DETAILS#i', fn() => '
' . $vuln_desc . '
', $body ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- CLI script; the php_sapi_name() guard above exits for web requests and this is console output. echo "Subject: $subject\n\n"; @@ -281,7 +279,10 @@ function send_email( $args ) { continue; } - send_email( compact( 'subject', 'to', 'name', 'cc', 'bcc', 'body' ) ); + if ( ! send_email( compact( 'subject', 'to', 'name', 'cc', 'bcc', 'body' ) ) ) { + $stats['error']++; + continue; + } $stats['warned']++; @@ -296,7 +297,7 @@ function send_email( $args ) { $body = $email_template; $body = str_replace( 'PLUGIN_LINK', get_permalink( $plugin ), $body ); - $body = str_replace( 'REPORT

OPTIONAL_ADDITIONAL', '
' . $vuln_desc . '
', $body ); + $body = preg_replace_callback( '#REPORT(\s*\s*)+OPTIONAL_ADDITIONAL#i', fn() => '
' . $vuln_desc . '
', $body ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- CLI script; the php_sapi_name() guard above exits for web requests and this is console output. echo "Subject: $subject\n\n"; @@ -311,7 +312,11 @@ function send_email( $args ) { continue; } - send_email( compact( 'subject', 'to', 'cc', 'bcc', 'body' ) ); + // Don't close a plugin without telling its author. + if ( ! send_email( compact( 'subject', 'to', 'cc', 'bcc', 'body' ) ) ) { + $stats['error']++; + continue; + } // Record why it's closed update_post_meta( $plugin->ID, '_close_reason', 'security-issue' ); diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/clients/class-freescout.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/clients/class-freescout.php index f3dc668eeb..e62491b9f9 100644 --- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/clients/class-freescout.php +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/clients/class-freescout.php @@ -94,4 +94,55 @@ public static function api( string $path, array $args = array(), string $method return is_array( $data ) ? $data : null; } + + /** + * A saved reply of the Plugins mailbox, by name, from the WPOrgApi module. + * + * The Saved Replies module has no API of its own; WPOrgApi reads it for requests signed with `FREESCOUT_SECRET`. + * + * @param string $name The saved reply's name. + * @return string|null Its HTML; null if there's no such reply, or it couldn't be read. + */ + public static function saved_reply( string $name ): ?string { + $secret = defined( 'FREESCOUT_SECRET' ) ? (string) FREESCOUT_SECRET : ''; + + /** + * Filters the secret the plugin directory signs its requests to FreeScout's WPOrgApi module with. + * + * @param string $secret Shared secret. + */ + $secret = (string) apply_filters( 'wporg_plugins_freescout_secret', $secret ); + if ( '' === $secret ) { + return null; + } + + $body = (string) wp_json_encode( + array( + 'mailbox_id' => Helpdesk::freescout_mailbox_id(), + 'name' => $name, + 'endpoint' => 'saved-reply', + 'sent_at' => time(), + ) + ); + + $response = wp_remote_post( + Helpdesk::freescout_url() . 'wporgapi/saved-reply', + array( + 'timeout' => self::TIMEOUT, + 'headers' => array( + 'Accept' => 'application/json', + 'Content-Type' => 'application/json', + 'X-WPOrg-Signature' => hash_hmac( 'sha256', $body, $secret ), + ), + 'body' => $body, + ) + ); + + $data = json_decode( (string) wp_remote_retrieve_body( $response ), true ); + if ( 200 !== (int) wp_remote_retrieve_response_code( $response ) || ! isset( $data['text'] ) ) { + return null; + } + + return (string) $data['text']; + } }