Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
72.73% covered (warning)
72.73%
40 / 55
40.00% covered (danger)
40.00%
4 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
Reprint_Exporter
72.73% covered (warning)
72.73%
40 / 55
40.00% covered (danger)
40.00%
4 / 10
35.68
0.00% covered (danger)
0.00%
0 / 1
 init
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 is_available
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 register_rest_routes
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 handle_request
75.86% covered (warning)
75.86%
22 / 29
0.00% covered (danger)
0.00%
0 / 1
14.03
 is_export_window_open
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 open_export_window
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 verify_hmac
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 serve_export
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 error
81.82% covered (warning)
81.82%
9 / 11
0.00% covered (danger)
0.00%
0 / 1
2.02
 terminate
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2/**
3 * Reprint export support for Jetpack on Pressable and WordPress.com (Atomic).
4 *
5 * Mirrors the behavior shipped in wpcomsh for WordPress.com on Atomic, but
6 * hosted in Jetpack so it also serves Pressable and can eventually replace the
7 * wpcomsh copy. It exposes an HMAC-authenticated, time-limited full-site export
8 * endpoint backed by the wp-php-toolkit/reprint-exporter package.
9 *
10 * On Atomic this runs alongside the wpcomsh copy without colliding: it uses a
11 * distinct query var (?reprint-api-v2) and REST namespace (jetpack/v4/*), so
12 * clients can migrate off the old ?reprint-api / wpcomsh/v1 surface at their
13 * own pace.
14 *
15 * Gating, in two phases that use different auth and network paths:
16 *
17 * 1. Secret rotation via the generic Jetpack REST proxy. The
18 *    /jetpack/v4/reprint/rotate-export-secret route only accepts
19 *    Jetpack-signed requests, so it can only be invoked through the
20 *    WordPress.com public API proxy. On success the site generates a random
21 *    secret, stores it in the `reprint_exporter_secret` option, opens the
22 *    export window, and returns the secret.
23 *
24 * 2. Export streaming — the client (now holding the shared secret) talks
25 *    directly to the site at ?reprint-api-v2 using HMAC-signed requests. This
26 *    bypasses the public API entirely because public-api does not support
27 *    streaming and extra hops add latency and complexity.
28 *
29 * The whole feature is gated behind the host check (overridable via the
30 * `jetpack_reprint_export_available` filter), so generic self-hosted Jetpack
31 * sites never register or expose any of it.
32 *
33 * @package automattic/jetpack
34 */
35
36namespace Automattic\Jetpack\Reprint_Export;
37
38use Automattic\Jetpack\Status\Host;
39
40/**
41 * Reprint exporter for Jetpack (Pressable and WordPress.com/Atomic).
42 */
43class Reprint_Exporter {
44
45    /**
46     * Option holding the per-site HMAC shared secret.
47     *
48     * @var string
49     */
50    const SECRET_OPTION = 'reprint_exporter_secret';
51
52    /**
53     * Option holding the unix timestamp of the last time the export window
54     * was opened. The window is a sliding 60-minute one.
55     *
56     * @var string
57     */
58    const ENABLED_OPTION = 'reprint_exporter_enabled';
59
60    /**
61     * Clock-skew tolerance, in seconds, allowed for HMAC signatures.
62     *
63     * @var int
64     */
65    const HMAC_CLOCK_SKEW = 300;
66
67    /**
68     * Registers the WordPress hooks. Only ever called on sites where
69     * is_available() is true (see Jetpack bootstrap).
70     */
71    public static function init() {
72        add_action( 'parse_request', array( new self(), 'handle_request' ), 0 );
73        add_action( 'rest_api_init', array( __CLASS__, 'register_rest_routes' ) );
74    }
75
76    /**
77     * Whether Reprint export support is available on the current site.
78     *
79     * Defaults to true on Pressable and WordPress.com (Atomic) hosts, false
80     * everywhere else. The filter acts as both an override for testing and an
81     * emergency kill switch.
82     *
83     * On Atomic this coexists with the copy shipped in wpcomsh: the two use
84     * different query vars (?reprint-api-v2 here vs ?reprint-api there) and
85     * REST namespaces (jetpack/v4 vs wpcomsh/v1), so both can run side by side
86     * while clients migrate to the v2 surface. Atomic detection uses
87     * is_atomic_platform() rather than is_woa_site() so it keeps working once
88     * wpcomsh (and its reprint copy) is removed.
89     *
90     * @return bool
91     */
92    public static function is_available() {
93        $host      = new Host();
94        $available = $host->is_pressable() || $host->is_atomic_platform();
95
96        /**
97         * Filters whether Jetpack Reprint export support is available on the
98         * current site.
99         *
100         * Default: true on Pressable and WordPress.com (Atomic), false elsewhere.
101         *
102         * @since $$next-version$$
103         *
104         * @param bool $available Whether Reprint export support is available.
105         */
106        return (bool) apply_filters( 'jetpack_reprint_export_available', $available );
107    }
108
109    /**
110     * Registers the secret-rotation REST route.
111     */
112    public static function register_rest_routes() {
113        ( new REST_Controller() )->register_routes();
114    }
115
116    /**
117     * Handles the ?reprint-api-v2 request.
118     *
119     * Hooked on `parse_request` at priority 0 so we run before WordPress
120     * resolves the query and long before any template output (important on
121     * Private Sites, whose template_redirect hooks redirect + exit).
122     *
123     * @param \WP $wp The WordPress environment instance.
124     */
125    public function handle_request( $wp ) {
126        // phpcs:ignore WordPress.Security.NonceVerification.Recommended
127        if ( ! isset( $_GET['reprint-api-v2'] ) ) {
128            return;
129        }
130
131        // Defense in depth: the hook is only registered when available, but
132        // re-check so the filter kill switch also short-circuits live requests.
133        if ( ! self::is_available() ) {
134            return;
135        }
136
137        // Only respond on the root path, matching the wpcomsh behavior.
138        if ( '' !== $wp->request ) {
139            return;
140        }
141
142        // Sliding activation window: the export stays open only for 60
143        // minutes since the last accepted request, so an idle site
144        // auto-closes the gate. HMAC verification happens separately below.
145        if ( ! self::is_export_window_open() ) {
146            return;
147        }
148
149        // -- CORS -------------------------------------------------------------
150        // Allow CORS from any origin. The export client (e.g. Playground)
151        // runs on many different deployments and new ones appear regularly.
152        // Since every export request requires a dedicated HMAC secret, the
153        // origin header is not a meaningful security boundary — an attacker
154        // without the secret cannot export anything regardless of origin.
155        //
156        // Must run before authentication: browsers send the OPTIONS preflight
157        // without credentials, so auth must not be required for that method.
158        if ( ! headers_sent() ) {
159            header( 'Access-Control-Allow-Origin: *' );
160            header( 'Access-Control-Allow-Methods: GET, POST, OPTIONS' );
161            header( 'Access-Control-Allow-Headers: *' );
162        }
163
164        // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash
165        $request_method = isset( $_SERVER['REQUEST_METHOD'] ) ? strtoupper( $_SERVER['REQUEST_METHOD'] ) : '';
166        if ( 'OPTIONS' === $request_method ) {
167            if ( ! headers_sent() ) {
168                header( 'Allow: GET, POST, OPTIONS' );
169            }
170            $this->terminate();
171            return;
172        }
173
174        // -- Authenticate via HMAC --------------------------------------------
175        $secret = get_option( self::SECRET_OPTION, '' );
176        if ( ! is_string( $secret ) || '' === $secret ) {
177            $this->error( 503, 'Export not configured. Please rotate the shared secret via POST /jetpack/v4/reprint/rotate-export-secret.' );
178            return;
179        }
180
181        $auth_error = $this->verify_hmac( $secret );
182        if ( null !== $auth_error ) {
183            $this->error( 403, $auth_error );
184            return;
185        }
186
187        // Bump the timestamp now that we know this request is legit.
188        self::open_export_window();
189
190        // WordPress is already loaded at this point. Run Reprint!
191        $this->serve_export();
192        $this->terminate();
193    }
194
195    /**
196     * Gate for the export handler: the enabled option must hold a unix
197     * timestamp within the last 60 minutes.
198     *
199     * @return bool
200     */
201    public static function is_export_window_open() {
202        $enabled_at = (int) get_option( self::ENABLED_OPTION, 0 );
203        return $enabled_at > 0 && ( time() - $enabled_at ) <= HOUR_IN_SECONDS;
204    }
205
206    /**
207     * Opens (or slides forward) the 60-minute export window by stamping the
208     * enabled option with the current time.
209     *
210     * Shared by the REST enable/rotate routes and the request handler's
211     * post-auth bump, so the window is opened the same way everywhere.
212     *
213     * @return int The unix timestamp the window was opened at.
214     */
215    public static function open_export_window() {
216        $now = time();
217        update_option( self::ENABLED_OPTION, $now );
218        return $now;
219    }
220
221    /**
222     * Verifies the HMAC signature of the current request.
223     *
224     * Seam for tests to override without instantiating the real server.
225     *
226     * @param string $secret The per-site shared secret.
227     * @return string|null Error message on failure, null on success.
228     */
229    protected function verify_hmac( $secret ) {
230        $hmac_server = new \Site_Export_HMAC_Server( $secret, self::HMAC_CLOCK_SKEW );
231        return $hmac_server->verify_globals();
232    }
233
234    /**
235     * Streams the export response.
236     *
237     * Seam for tests to override so they don't perform a real export.
238     */
239    protected function serve_export() {
240        \Site_Export_HTTP_Server::serve( array( 'default_directory' => ABSPATH ) );
241    }
242
243    /**
244     * Sends a JSON error response and terminates.
245     *
246     * @param int    $code    HTTP status code.
247     * @param string $message Error description.
248     */
249    protected function error( $code, $message ) {
250        if ( ! headers_sent() ) {
251            http_response_code( $code );
252            header( 'Content-Type: application/json' );
253        }
254        // phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode
255        echo json_encode(
256            array(
257                'error' => $message,
258                'code'  => $code,
259            ),
260            JSON_FORCE_OBJECT
261        );
262        $this->terminate();
263    }
264
265    /**
266     * Terminates the request.
267     *
268     * Seam wrapping exit() so tests (which redefine exit via patchwork) can
269     * assert termination without killing the process.
270     */
271    protected function terminate() {
272        exit;
273    }
274}