Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
34.94% covered (danger)
34.94%
29 / 83
33.33% covered (danger)
33.33%
1 / 3
CRAP
0.00% covered (danger)
0.00%
0 / 1
Jetpack_JSON_API_Themes_Replace_Endpoint
52.73% covered (warning)
52.73%
29 / 55
33.33% covered (danger)
33.33%
1 / 3
84.85
0.00% covered (danger)
0.00%
0 / 1
 install
44.44% covered (danger)
44.44%
20 / 45
0.00% covered (danger)
0.00%
0 / 1
66.55
 validate_call
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
4.07
 sanitize_upgrader_error
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
1<?php // phpcs:ignore WordPress.Files.FileName.InvalidClassFileName
2
3use Automattic\Jetpack\Automatic_Install_Skin;
4
5if ( ! defined( 'ABSPATH' ) ) {
6    exit( 0 );
7}
8
9require_once ABSPATH . 'wp-admin/includes/class-wp-upgrader.php';
10require_once ABSPATH . 'wp-admin/includes/file.php';
11
12/**
13 * Install-or-replace a theme via zip upload. Passes overwrite_package=true to
14 * Theme_Upgrader so an existing theme at the same slug is replaced in place,
15 * mirroring wp-admin's "Replace current with uploaded" confirmation flow.
16 *
17 * POST /sites/%s/themes/replace
18 *
19 * ## Authentication trust model
20 *
21 * Two auth modes are supported:
22 *
23 * 1. User auth — the request is mapped to a WordPress user who must hold
24 *    `install_themes` AND `update_themes`. The ownership check verifies the
25 *    referenced attachment was authored by that same user, preventing the
26 *    endpoint from being used to touch another user's attachments.
27 *
28 * 2. Site-based auth (`allow_jetpack_site_auth => true`) — no user is
29 *    identified; capability and ownership checks are skipped. The wpcom
30 *    upload forwarder is expected to (a) vouch for the caller, (b) create the
31 *    attachment as part of the same request's upload-intercept pipeline, and
32 *    (c) pass the resulting ID through unchanged. If that contract is broken
33 *    on the wpcom side, any trusted site credential becomes install-anything
34 *    on the site.
35 *
36 * ## Cross-user attachment-deletion guard
37 *
38 * `Jetpack_JSON_API_Themes_New_Endpoint::validate_call` cleans up the referenced
39 * attachment on capability-check failure, and now guards that cleanup with an
40 * ownership check of its own. This Replace endpoint additionally overrides
41 * `validate_call` to run the ownership check *before* delegating to the parent,
42 * so an unowned attachment is rejected with `attachment_not_owned` rather than
43 * the capability error. Both paths leave the legitimate case unchanged: the
44 * caller's own attachment is cleaned up when their cap check fails.
45 *
46 * @phan-constructor-used-for-side-effects
47 */
48class Jetpack_JSON_API_Themes_Replace_Endpoint extends Jetpack_JSON_API_Themes_New_Endpoint {
49    use Jetpack_JSON_API_Attachment_Ownership_Trait;
50
51    /**
52     * Replace is destructive, so require both install and update caps.
53     *
54     * @var array
55     */
56    protected $needed_capabilities = array( 'install_themes', 'update_themes' );
57
58    /**
59     * Error codes we are willing to surface to the caller. Anything outside
60     * this list is collapsed to 'install_failed' with a generic message to
61     * avoid leaking filesystem paths from Theme_Upgrader internals.
62     *
63     * Kept aligned with codes actually emitted by `WP_Upgrader` /
64     * `Theme_Upgrader` — $this->strings[] message keys are NOT error codes
65     * and do not belong here.
66     *
67     * @var array
68     */
69    protected static $allowed_error_codes = array(
70        'no_package',
71        'bad_request',
72        'files_not_writable',
73        'copy_dir_failed',
74        'remove_old_failed',
75        'source_read_failed',
76        'new_source_read_failed',
77        'mkdir_failed_destination',
78        'folder_exists',
79        'incompatible_archive',
80        'incompatible_archive_empty',
81        'incompatible_archive_theme_no_style',
82        'incompatible_archive_theme_no_name',
83        'incompatible_archive_theme_no_index',
84        'incompatible_php_required_version',
85        'incompatible_wp_required_version',
86        'unable_to_connect_to_filesystem',
87        'fs_unavailable',
88        'fs_error',
89        'fs_no_themes_dir',
90        'fs_no_folder',
91        'fs_no_root_dir',
92        'fs_no_content_dir',
93        'fs_no_temp_backup_dir',
94        'fs_temp_backup_mkdir',
95        'fs_temp_backup_move',
96    );
97
98    /**
99     * Install, replacing any existing theme at the same slug.
100     *
101     * @return bool|WP_Error
102     */
103    public function install() {
104        $args = $this->input();
105
106        if ( ! isset( $args['zip'][0]['id'] ) || ! is_scalar( $args['zip'][0]['id'] ) ) {
107            return new WP_Error( 'no_theme_installed', __( 'No theme zip file was provided.', 'jetpack' ), 400 );
108        }
109
110        $expected_slug = isset( $args['slug'] ) && is_scalar( $args['slug'] )
111            ? strtolower( (string) $args['slug'] )
112            : '';
113        if ( ! preg_match( '/^[a-z0-9][a-z0-9_-]*$/', $expected_slug ) ) {
114            return new WP_Error( 'missing_slug', __( 'A valid theme slug is required; the replace endpoint refuses to overwrite a theme whose slug the caller has not declared.', 'jetpack' ), 400 );
115        }
116
117        $attachment_id = (int) $args['zip'][0]['id'];
118
119        // Re-checked here so direct invocations of install() (notably the test stubs)
120        // can't accidentally bypass the ownership guard that validate_call() runs on
121        // the live request path.
122        $ownership = $this->validate_attachment_ownership( $attachment_id );
123        if ( is_wp_error( $ownership ) ) {
124            return $ownership;
125        }
126
127        $zip_check = $this->validate_attachment_is_zip( $attachment_id );
128        if ( is_wp_error( $zip_check ) ) {
129            wp_delete_attachment( $attachment_id, true );
130            return $zip_check;
131        }
132
133        $local_file = get_attached_file( $attachment_id );
134        if ( ! $local_file ) {
135            wp_delete_attachment( $attachment_id, true );
136            return new WP_Error( 'local-file-does-not-exist', __( 'Uploaded theme zip could not be found on disk.', 'jetpack' ), 400 );
137        }
138
139        $skin     = new Automatic_Install_Skin();
140        $upgrader = new Theme_Upgrader( $skin );
141
142        $result = $upgrader->install(
143            $local_file,
144            array( 'overwrite_package' => true )
145        );
146
147        wp_delete_attachment( $attachment_id, true );
148
149        if ( is_wp_error( $result ) ) {
150            return $this->sanitize_upgrader_error( $result );
151        }
152
153        if ( ! $result ) {
154            $error_code = $skin->get_main_error_code();
155            if ( 'download_failed' === $error_code ) {
156                $error_code = 'no_package';
157            }
158            if ( empty( $error_code ) || ! in_array( $error_code, self::$allowed_error_codes, true ) ) {
159                $error_code = 'install_failed';
160            }
161            return new WP_Error( $error_code, __( 'Theme installation failed.', 'jetpack' ), 400 );
162        }
163
164        $theme_info = $upgrader->theme_info();
165        $theme_slug = $theme_info ? $theme_info->get_stylesheet() : '';
166        if ( empty( $theme_slug ) ) {
167            return new WP_Error( 'theme_replace_info_missing', __( 'Theme was installed but its identifier could not be determined.', 'jetpack' ), 500 );
168        }
169
170        // `overwrite_package=true` trusts the zip's own folder name, so a zip whose
171        // top-level folder differs from the declared slug would clobber an unrelated
172        // theme. Verify the post-install identifier matches the caller's contract.
173        if ( strtolower( $theme_slug ) !== $expected_slug ) {
174            return new WP_Error( 'slug_mismatch', __( 'The installed theme does not match the declared slug.', 'jetpack' ), 400 );
175        }
176
177        $this->themes             = array( $theme_slug );
178        $this->log[ $theme_slug ] = $upgrader->skin->get_upgrade_messages();
179
180        return true;
181    }
182
183    /**
184     * See class docblock — runs the attachment-ownership check before delegating
185     * to the parent's validate_call(), so an unowned attachment is rejected with
186     * attachment_not_owned rather than the capability error.
187     *
188     * @param int    $_blog_id            Blog ID.
189     * @param string $capability          Capability.
190     * @param bool   $check_manage_active Whether to check manage-is-active.
191     * @return bool|WP_Error
192     */
193    protected function validate_call( $_blog_id, $capability, $check_manage_active = true ) {
194        $args = $this->input();
195        if ( isset( $args['zip'][0]['id'] ) && is_scalar( $args['zip'][0]['id'] ) ) {
196            $ownership = $this->validate_attachment_ownership( (int) $args['zip'][0]['id'] );
197            if ( is_wp_error( $ownership ) ) {
198                return $ownership;
199            }
200        }
201        return parent::validate_call( $_blog_id, $capability, $check_manage_active );
202    }
203
204    /**
205     * Collapse unknown error codes and strip potentially path-leaking messages
206     * from WP_Error instances returned by Theme_Upgrader.
207     *
208     * @param WP_Error $error Raw upgrader error.
209     * @return WP_Error
210     */
211    protected function sanitize_upgrader_error( WP_Error $error ) {
212        $code = $error->get_error_code();
213        if ( empty( $code ) || ! in_array( $code, self::$allowed_error_codes, true ) ) {
214            return new WP_Error( 'install_failed', __( 'Theme installation failed.', 'jetpack' ), 400 );
215        }
216        return new WP_Error( $code, __( 'Theme installation failed.', 'jetpack' ), 400 );
217    }
218}
219
220// POST /sites/%s/themes/replace
221new Jetpack_JSON_API_Themes_Replace_Endpoint(
222    array(
223        'description'             => 'Install or replace a theme on a Jetpack site by uploading a zip file. If a theme with the same slug is already installed, its destination folder is replaced in place, mirroring wp-admin\'s "Replace current with uploaded" upload flow.',
224        'group'                   => '__do_not_document',
225        'stat'                    => 'themes:replace',
226        'method'                  => 'POST',
227        'path'                    => '/sites/%s/themes/replace',
228        'path_labels'             => array(
229            '$site' => '(int|string) Site ID or domain',
230        ),
231        'request_format'          => array(
232            'zip'  => '(array) Reference to an uploaded theme package zip file.',
233            'slug' => '(string) The theme slug the uploaded zip must resolve to. Required; the endpoint rejects zips whose top-level folder does not match.',
234        ),
235        'response_format'         => Jetpack_JSON_API_Themes_Endpoint::$_response_format,
236        'allow_jetpack_site_auth' => true,
237        'example_request_data'    => array(
238            'headers' => array(
239                'authorization' => 'Bearer YOUR_API_TOKEN',
240            ),
241        ),
242        'example_request'         => 'https://public-api.wordpress.com/rest/v1/sites/example.wordpress.org/themes/replace',
243    )
244);