Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
50.00% |
7 / 14 |
|
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% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
| wpcomsh_reprint_inject_enabled_setting | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
| wpcomsh_reprint_update_enabled_setting | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
2 | |||
| _should_expose_reprint_exporter_on_this_site | |
100.00% |
2 / 2 |
|
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 | */ |
| 64 | function 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 | } |
| 128 | add_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 | */ |
| 135 | function wpcomsh_reprint_rest_init() { |
| 136 | require_once __DIR__ . '/class-reprint-exporter-rest-controller.php'; |
| 137 | ( new Reprint_Exporter_Rest_Controller() )->register_routes(); |
| 138 | } |
| 139 | add_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 | */ |
| 153 | function 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 | } |
| 159 | add_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 | */ |
| 170 | function wpcomsh_reprint_update_enabled_setting( $value ) { |
| 171 | $value = (int) $value; |
| 172 | update_option( 'reprint_exporter_enabled', $value ); |
| 173 | return $value; |
| 174 | } |
| 175 | add_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 | */ |
| 186 | function _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 | */ |
| 200 | function _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 | } |