Skip to content
Open
1 change: 1 addition & 0 deletions freescout.wordpress.net/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ Context for AI coding agents and developers working on the WordPress.org FreeSco

- `Modules/<Name>/` — 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/`.
Expand Down
15 changes: 15 additions & 0 deletions freescout.wordpress.net/Modules/WPOrgApi/Config/config.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php
/**
* WPOrgApi module configuration.
*
* @package WordPressdotorg\FreeScout\WPOrgApi
*/

declare( strict_types = 1 );

return array(
'name' => 'WPOrgApi',

// Shared with WPOrgSidebar and WPOrgWebhooks: must match FREESCOUT_SECRET on WordPress.org.
'secret' => env( 'WPORG_API_SECRET', '' ),
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
<?php
/**
* Hands WordPress.org a mailbox's saved reply.
*
* @package WordPressdotorg\FreeScout\WPOrgApi
*/

declare( strict_types = 1 );

namespace Modules\WPOrgApi\Http\Controllers;

use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Illuminate\Support\Facades\Schema;
use Modules\WPOrgApi\Services\SignedRequest;

/**
* Serves a mailbox's saved replies by name, from the Saved Replies module.
*/
final class SavedReplyController extends Controller {

/**
* Alias of the Saved Replies module.
*
* @var string
*/
private const MODULE = 'savedreplies';

/**
* The module's table.
*
* @var string
*/
private const TABLE = 'saved_replies';

/**
* Responds with the saved reply named in the request, in the mailbox it names.
*
* @param Request $request Request, with `mailbox_id` and `name`.
* @return JsonResponse `name` and `text`, the reply's HTML.
*/
public function show( Request $request ): JsonResponse {
$payload = SignedRequest::payload( $request, 'saved-reply' );
if ( null === $payload ) {
return response()->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,
)
);
}
}
20 changes: 20 additions & 0 deletions freescout.wordpress.net/Modules/WPOrgApi/Http/routes.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?php
/**
* WPOrgApi routes.
*
* @package WordPressdotorg\FreeScout\WPOrgApi
*/

declare( strict_types = 1 );

// Not in the web group: WordPress.org signs its requests, and has no session or CSRF token.
Route::group(
array(
'middleware' => 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' );
}
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
<?php
/**
* WPOrgApi service provider.
*
* @package WordPressdotorg\FreeScout\WPOrgApi
*/

declare( strict_types = 1 );

namespace Modules\WPOrgApi\Providers;

use Illuminate\Support\ServiceProvider;

/**
* Registers the endpoints WordPress.org calls.
*/
final class WPOrgApiServiceProvider extends ServiceProvider {

/**
* Module alias.
*
* @var string
*/
public const ALIAS = 'wporgapi';

/**
* Registers the module.
*
* @return void
*/
public function register(): void {
$this->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 ) );
}
}
8 changes: 8 additions & 0 deletions freescout.wordpress.net/Modules/WPOrgApi/Public/img/icon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
13 changes: 13 additions & 0 deletions freescout.wordpress.net/Modules/WPOrgApi/README.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
<?php
/**
* A request WordPress.org signed.
*
* @package WordPressdotorg\FreeScout\WPOrgApi
*/

declare( strict_types = 1 );

namespace Modules\WPOrgApi\Services;

use Illuminate\Http\Request;

/**
* Reads a request's JSON body once its signature, freshness, and endpoint check out.
*
* WordPress.org signs the body with HMAC-SHA256 and the shared secret, and stamps it with the endpoint and when it was
* sent.
*/
final class SignedRequest {

/**
* Header with the body's signature.
*
* @var string
*/
public const SIGNATURE_HEADER = 'X-WPOrg-Signature';

/**
* How long a request is good for after it was sent, in seconds.
*
* @var int
*/
private const MAX_AGE = 300;

/**
* How far ahead of FreeScout's clock WordPress.org's may be, in seconds.
*
* @var int
*/
private const MAX_CLOCK_SKEW = 10;

/**
* Signs a body.
*
* @param string $body Request body.
* @param string $secret Shared secret.
* @return string
*/
public static function sign( string $body, string $secret ): string {
return hash_hmac( 'sha256', $body, $secret );
}

/**
* The request's payload, if it's WordPress.org's, for this endpoint, and fresh.
*
* @param Request $request Request.
* @param string $endpoint Endpoint the payload must name.
* @return array|null Null if it isn't.
*/
public static function payload( Request $request, string $endpoint ): ?array {
$secret = (string) config( 'wporgapi.secret' );
$body = (string) $request->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;
}
}
21 changes: 21 additions & 0 deletions freescout.wordpress.net/Modules/WPOrgApi/module.json
Original file line number Diff line number Diff line change
@@ -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": []
}
Loading