PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3-a.1
Jetpack – WP Security, Backup, Speed, & Growth v16.3-a.1
16.3-a.3 16.3-a.1 16.2 16.2-beta 12.0.3 12.1.3 12.2.3 12.3.2 12.4.2 12.5.2 12.6.4 12.7.3 12.8.3 12.9.5 13.0.2 13.1.5 13.2.4 13.3.3 13.4.5 13.5.2 13.6.2 13.7.2 13.8.3 13.9.2 14.0.1 All 504 releases
jetpack / json-endpoints / jetpack / class.jetpack-json-api-themes-replace-endpoint.php

class.jetpack-json-api-themes-replace-endpoint.php in Jetpack – WP Security, Backup, Speed, & Growth 16.3-a.1, at json-endpoints/jetpack/class.jetpack-json-api-themes-replace-endpoint.php

245 lines 9.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php // phpcs:ignore WordPress.Files.FileName.InvalidClassFileName
2
3 use Automattic\Jetpack\Automatic_Install_Skin;
4
5 if ( ! defined( 'ABSPATH' ) ) {
6 exit( 0 );
7 }
8
9 require_once ABSPATH . 'wp-admin/includes/class-wp-upgrader.php';
10 require_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 */
48 class 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
221 new 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 );
245