Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
50.00% covered (danger)
50.00%
7 / 14
83.33% covered (warning)
83.33%
5 / 6
CRAP
n/a
0 / 0
wpcomsh_reprint_handle_request
n/a
0 / 0
n/a
0 / 0
9
wpcomsh_reprint_rest_init
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
wpcomsh_reprint_inject_enabled_setting
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
wpcomsh_reprint_update_enabled_setting
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
_should_expose_reprint_exporter_on_this_site
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
_reprint_exporter_error
n/a
0 / 0
n/a
0 / 0
1
1<?php
2/**
3 * Reprint Exporter API — wpcomsh integration for the reprint-exporter package.
4 *
5 * Exposes export endpoints at ?reprint-api.
6 *
7 * Gating:
8 *
9 * * rotate-secret REST route is always registered; its permission
10 *   callback only accepts Jetpack-signed requests, so it can only be
11 *   invoked through the WPCOM public API proxy.
12 * * ?reprint-api export requires the reprint_exporter_enabled site
13 *   option to be a unix timestamp within the last 60 minutes, AND a
14 *   valid HMAC signature produced with the per-site shared secret.
15 *   Each accepted request bumps the timestamp, so an idle site
16 *   auto-closes the gate.
17 *
18 * Data flow has two phases that use different auth and network paths:
19 *
20 * 1. Secret rotation via the generic Jetpack REST proxy.
21 *    Studio uses the pass-through proxy that ships with Jetpack.
22 *    There is no dedicated /wpcom/v2/sites/{id}/reprint/... public-api
23 *    endpoint:
24 *
25 *        POST https://public-api.wordpress.com/rest/v1.1/jetpack-blogs/{site_id}/rest-api?http_envelope=1
26 *        Authorization: Bearer <WPCOM OAuth token>
27 *        Content-Type: application/json
28 *
29 *        { "path": "/wpcomsh/v1/reprint/rotate-export-secret" }
30 *
31 *    WPCOM then verifies the OAuth token, maps the caller to a user on the
32 *    target site, and re-issues the request internally against
33 *    /wpcomsh/v1/reprint/rotate-export-secret. The route's permission
34 *    callback (is_super_admin()) runs against the mapped user. On
35 *    success the site generates a random secret, stores it in the
36 *    reprint_exporter_secret option, and returns it.
37 *
38 *    That secret is later used to authenticate export requests via HMAC.
39 *
40 * 2. Export streaming — the client (now holding the shared secret)
41 *    talks directly to the site at ?reprint-api using HMAC-signed requests.
42 *
43 *    This exchange bypasses the public API entirely because:
44 *       - public-api doesn't support streaming
45 *       - more hops = more complexity, more latency, more request serving
46 *         policies to satisfy
47 *
48 * @package wpcomsh
49 */
50
51// -- WordPress hooks ----------------------------------------------------------
52
53/**
54 * Handles the ?reprint-api request.
55 *
56 * Hooked on `parse_request` so we run before WordPress resolves the
57 * query and long before any template output (important on Private
58 * Sites, whose template_redirect hooks redirect + exit).
59 *
60 * @param WP $wp The WordPress environment instance.
61 *
62 * @codeCoverageIgnore — calls exit().
63 */
64function wpcomsh_reprint_handle_request( $wp ) {
65    // phpcs:ignore WordPress.Security.NonceVerification.Recommended
66    if ( ! isset( $_GET['reprint-api'] ) ) {
67        return;
68    }
69
70    if ( '' !== $wp->request ) {
71        return;
72    }
73
74    if ( ! _should_expose_reprint_exporter_on_this_site() ) {
75        return;
76    }
77
78    // -- CORS -----------------------------------------------------------------
79    // Allow CORS from any origin. Playground runs on many different
80    // deployments (playground.wordpress.net, wasm.wordpress.net, local
81    // dev servers, self-hosted instances, etc.) and new ones appear
82    // regularly. Since every export request requires a dedicated HMAC
83    // secret, the origin header adds no meaningful security boundary —
84    // an attacker without the secret cannot export anything regardless
85    // of origin.
86    //
87    // Emitted inline (not via Site_Export_HTTP_Server) so a site missing
88    // the exporter package still returns usable CORS headers alongside
89    // its 500 — otherwise the browser would block the 500 with a CORS
90    // error and the admin would never see the underlying problem.
91    //
92    // Must run before authentication — browsers send OPTIONS preflight
93    // without credentials, so auth must not be required for that method.
94    header( 'Access-Control-Allow-Origin: *' );
95    header( 'Access-Control-Allow-Methods: GET, POST, OPTIONS' );
96    header( 'Access-Control-Allow-Headers: *' );
97    // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash
98    $request_method = isset( $_SERVER['REQUEST_METHOD'] ) ? strtoupper( $_SERVER['REQUEST_METHOD'] ) : '';
99    if ( 'OPTIONS' === $request_method ) {
100        header( 'Allow: GET, POST, OPTIONS' );
101        exit;
102    }
103
104    // -- Authenticate via HMAC ------------------------------------------------
105    $secret = get_option( 'reprint_exporter_secret', '' );
106    if ( ! is_string( $secret ) || '' === $secret ) {
107        _reprint_exporter_error( 503, 'Export not configured. Please rotate the shared secret via POST /wpcomsh/v1/reprint/rotate-export-secret.' );
108    }
109
110    // HMAC signatures tolerate up to 5 minutes of clock skew.
111    $hmac_server = new Site_Export_HMAC_Server( $secret, 300 );
112    $auth_error  = $hmac_server->verify_globals();
113    if ( null !== $auth_error ) {
114        _reprint_exporter_error( 403, $auth_error );
115    }
116
117    // Sliding activation window — the reprint_exporter_enabled option
118    // only keeps the feature open for 60 minutes since the last accepted
119    // request, so an idle site auto-closes the gate. Bump the timestamp
120    // now that we know this request is legit.
121    update_option( 'reprint_exporter_enabled', time() );
122
123    // WordPress is already loaded at this point.
124    // Let's run Reprint!
125    Site_Export_HTTP_Server::serve( array( 'default_directory' => ABSPATH ) );
126    exit;
127}
128add_action( 'parse_request', 'wpcomsh_reprint_handle_request', 0 );
129
130/**
131 * Registers the reprint REST route. Always on — auth is enforced in the
132 * controller's permission callback, which only accepts Jetpack-signed
133 * requests (i.e. calls coming through the public API).
134 */
135function wpcomsh_reprint_rest_init() {
136    require_once __DIR__ . '/class-reprint-exporter-rest-controller.php';
137    ( new Reprint_Exporter_Rest_Controller() )->register_routes();
138}
139add_action( 'rest_api_init', 'wpcomsh_reprint_rest_init' );
140
141/**
142 * Inject reprint_exporter_enabled into the site settings update if
143 * the caller sent it.
144 *
145 * This lives in wpcomsh, not in the Jetpack site-settings-endpoint.php,
146 * because setting this option doesn't make sense in Jetpack context. It
147 * is only meaningful in context of wpcomsh's reprint integration.
148 *
149 * @param array $input            Whitelisted/cast settings.
150 * @param array $unfiltered_input Raw input from the request.
151 * @return array
152 */
153function wpcomsh_reprint_inject_enabled_setting( $input, $unfiltered_input ) {
154    if ( isset( $unfiltered_input['reprint_exporter_enabled'] ) ) {
155        $input['reprint_exporter_enabled'] = (int) $unfiltered_input['reprint_exporter_enabled'];
156    }
157    return $input;
158}
159add_filter( 'rest_api_update_site_settings', 'wpcomsh_reprint_inject_enabled_setting', 10, 2 );
160
161/**
162 * Persist reprint_exporter_enabled when the settings endpoint processes
163 * it. The default case in update_settings() does NOT call update_option
164 * when a per-key filter is registered — the filter is expected to
165 * handle persistence itself.
166 *
167 * @param mixed $value The value from the request.
168 * @return int The persisted value (returned for the response body).
169 */
170function wpcomsh_reprint_update_enabled_setting( $value ) {
171    $value = (int) $value;
172    update_option( 'reprint_exporter_enabled', $value );
173    return $value;
174}
175add_filter( 'site_settings_endpoint_update_reprint_exporter_enabled', 'wpcomsh_reprint_update_enabled_setting' );
176
177// -- Helpers ------------------------------------------------------------------
178
179/**
180 * Gate for the ?reprint-api export handler: the reprint_exporter_enabled
181 * option must hold a unix timestamp within the last 60 minutes. HMAC
182 * verification happens separately in the request handler.
183 *
184 * @return bool
185 */
186function _should_expose_reprint_exporter_on_this_site(): bool {
187    $enabled_at = (int) get_option( 'reprint_exporter_enabled', 0 );
188    return $enabled_at > 0 && ( time() - $enabled_at ) <= HOUR_IN_SECONDS;
189}
190
191/**
192 * Sends a JSON error response and terminates.
193 *
194 * @param int    $code    HTTP status code.
195 * @param string $message Error description.
196 * @return never
197 *
198 * @codeCoverageIgnore — calls exit().
199 */
200function _reprint_exporter_error( int $code, string $message ): never {
201    http_response_code( $code );
202    header( 'Content-Type: application/json' );
203    // phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode
204    echo json_encode(
205        array(
206            'error' => $message,
207            'code'  => $code,
208        ),
209        JSON_FORCE_OBJECT
210    );
211    exit;
212}