Skip to content

Commit 15864e0

Browse files
borkweblezamaclaude
authored andcommitted
Jetpack: Add /plugins/replace and /themes/replace REST endpoints (#48293)
## Summary Adds two new JSON API endpoints on the Jetpack plugin that install or overwrite a plugin/theme in place from an uploaded zip, mirroring wp-admin's "Replace current with uploaded" flow via `overwrite_package = true`. - `POST /sites/%s/plugins/replace` (v1, v1.1, v1.2) - `POST /sites/%s/themes/replace` (v1) Both follow the existing `plugins/new` / `themes/new` architecture: the wpcom forwarder intercepts a multipart zip upload, creates an attachment on the Jetpack site, and forwards the API call with `zip[0][id]` pointing to that attachment. ## Why Use case calls for an install-or-replace operation from an uploaded zip that doesn't require the caller to first check whether the plugin/theme is already installed. The existing `plugins/new` / `themes/new` endpoints only install when the slug is unknown — they error out when the target already exists. ## How Extend the existing `*_New_Endpoint` classes, override `install()` to pass `overwrite_package = true`, and require both `install_*` and `update_*` caps. A required `slug` param plus an `upgrader_source_selection` filter reject zips whose top-level folder doesn't match the declared slug, closing the cross-slug overwrite gap. Post-install identifier comes from `Plugin_Upgrader::plugin_info()` / `Theme_Upgrader::theme_info()` rather than `get_plugins()`/`wp_get_themes()` diffs. Shared attachment ownership + zip-mime validation lives in a new `Jetpack_JSON_API_Attachment_Ownership_Trait`. ## Does this pull request change what data or activity we track or use? No. These endpoints wrap the existing WordPress plugin/theme install flow and do not introduce any new tracking, telemetry, or stored data. The attachment lifecycle (create → install → delete) matches what `plugins/new` and `themes/new` already do. ## Testing instructions > **Note for wpcom testing:** The `/plugins/replace` and `/themes/replace` endpoints are forwarded from wpcom. To exercise them end-to-end against a sandbox, the companion wpcom branch `feature/telex-project-push-endpoints` must be active on the sandbox — it contains the forwarder wiring. Without it, requests will not reach these endpoints. ### Automated tests Run the PHPUnit suite covering the new endpoints: ``` jp docker phpunit jetpack -- --filter=Replace_Endpoints ``` 49 tests covering class hierarchy, capability arrays, ownership check branches (missing post, non-attachment, other user, owned, system+site-auth, system+user-auth), install-path branches (missing zip, missing slug, 12 adversarial slug shapes via data provider, non-scalar id, non-zip mime, fileless attachment, slug mismatch via filter, case-insensitive compare, non-ASCII/dotted/whitespace source rejection, unknown error-code collapse, version-code preservation), attachment cleanup on every early-return path, preservation on ownership failure. ### Manual end-to-end (via WP.com API) Replace a plugin on a connected Jetpack site: 1. Obtain a plugin zip (e.g. `jetpack.zip`) whose top-level folder matches the slug you plan to replace (e.g. `jetpack/`). 2. `POST` the zip to `https://public-api.wordpress.com/rest/v1.2/sites/<site-id>/plugins/replace` as multipart/form-data with: - `zip[]` — the plugin zip file - `slug` — the plugin slug to replace (e.g. `jetpack`) - `autoupdate` — optional boolean 3. Verify: - Response is `200 OK` with the installed plugin payload (slug, version, name, etc.). - The plugin on the site is now the uploaded version (check the Plugins screen or `wp plugin status <slug>`). - Re-running with an unrelated slug (e.g. `slug=akismet` while uploading `jetpack.zip`) returns an error and does **not** overwrite Akismet. 4. Repeat for a theme against `/sites/<site-id>/themes/replace` (v1 only). ### Negative cases to spot-check - Upload a non-zip file → error, no partial install. - Omit the `slug` param → error before the upgrader runs. - Request without `install_plugins` + `update_plugins` caps → 403. --------- Co-authored-by: Miguel Lezama <lezama@gmail.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 35247d1 commit 15864e0

6 files changed

Lines changed: 1349 additions & 0 deletions
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
Significance: minor
2+
Type: enhancement
3+
4+
REST API: add `/sites/%s/plugins/replace` and `/sites/%s/themes/replace` endpoints for installing or overwriting a plugin/theme via zip upload.
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
<?php
2+
/**
3+
* Shared attachment-ownership and zip-mime validation for JSON API endpoints
4+
* that consume an uploaded zip (plugin/theme install and replace).
5+
*
6+
* @package automattic/jetpack
7+
*/
8+
9+
if ( ! defined( 'ABSPATH' ) ) {
10+
exit( 0 );
11+
}
12+
13+
/**
14+
* Shared ownership check for endpoints that consume an uploaded attachment
15+
* (plugin/theme zip uploads). Guards against an authorized caller referencing
16+
* an attachment that wasn't part of this upload.
17+
*/
18+
trait Jetpack_JSON_API_Attachment_Ownership_Trait {
19+
20+
/**
21+
* Confirm the attachment exists, is the attachment post type, and is owned
22+
* by the current user when a user is identifiable. Site-auth requests
23+
* (no current user) skip the author check.
24+
*
25+
* @param int $attachment_id Attachment post ID.
26+
* @return true|WP_Error
27+
*/
28+
protected function validate_attachment_ownership( $attachment_id ) {
29+
$post = get_post( $attachment_id );
30+
if ( ! $post || 'attachment' !== $post->post_type ) {
31+
return new WP_Error( 'invalid_attachment', __( 'The referenced upload is not a valid attachment.', 'jetpack' ), 400 );
32+
}
33+
34+
$current_user_id = get_current_user_id();
35+
if ( $current_user_id && (int) $post->post_author !== $current_user_id ) {
36+
return new WP_Error( 'attachment_not_owned', __( 'The referenced upload does not belong to the current user.', 'jetpack' ), 403 );
37+
}
38+
39+
return true;
40+
}
41+
42+
/**
43+
* Confirm the attachment is a zip package. Reject non-zip mime types and
44+
* non-.zip extensions to prevent unrelated uploads (images, PDFs, etc.)
45+
* from being fed to Plugin_Upgrader / Theme_Upgrader.
46+
*
47+
* @param int $attachment_id Attachment post ID.
48+
* @return true|WP_Error
49+
*/
50+
protected function validate_attachment_is_zip( $attachment_id ) {
51+
$mime = get_post_mime_type( $attachment_id );
52+
if ( 'application/zip' !== $mime ) {
53+
return new WP_Error( 'invalid_attachment_mime', __( 'Uploaded attachment is not a zip package.', 'jetpack' ), 400 );
54+
}
55+
56+
$file = get_attached_file( $attachment_id );
57+
if ( $file ) {
58+
$extension = strtolower( (string) pathinfo( $file, PATHINFO_EXTENSION ) );
59+
if ( 'zip' !== $extension ) {
60+
return new WP_Error( 'invalid_attachment_extension', __( 'Uploaded attachment is not a .zip file.', 'jetpack' ), 400 );
61+
}
62+
}
63+
64+
return true;
65+
}
66+
}
Lines changed: 270 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,270 @@
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 plugin via zip upload. Passes overwrite_package=true to
14+
* Plugin_Upgrader so an existing plugin at the same slug is replaced in place,
15+
* mirroring wp-admin's "Replace current with uploaded" confirmation flow.
16+
*
17+
* POST /sites/%s/plugins/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_plugins` AND `update_plugins`. 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_Plugins_New_Endpoint::validate_call` deletes the referenced
39+
* attachment on capability-check failure without verifying the caller owns it.
40+
* This Replace endpoint overrides `validate_call` to run the ownership check
41+
* first, so a low-privilege caller cannot use it to hard-delete another user's
42+
* attachment as a side-effect of the cap check failing. The parent's behavior
43+
* is unchanged for the legitimate case (caller's own attachment is cleaned up
44+
* when their cap check fails).
45+
*
46+
* @phan-constructor-used-for-side-effects
47+
*/
48+
class Jetpack_JSON_API_Plugins_Replace_Endpoint extends Jetpack_JSON_API_Plugins_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_plugins', 'update_plugins' );
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 Plugin_Upgrader internals.
62+
*
63+
* Kept aligned with codes actually emitted by `WP_Upgrader` /
64+
* `Plugin_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_no_plugins',
81+
'incompatible_archive_empty',
82+
'incompatible_php_required_version',
83+
'incompatible_wp_required_version',
84+
'unable_to_connect_to_filesystem',
85+
'fs_unavailable',
86+
'fs_error',
87+
'fs_no_plugins_dir',
88+
'fs_no_folder',
89+
'fs_no_root_dir',
90+
'fs_no_content_dir',
91+
'fs_no_temp_backup_dir',
92+
'fs_temp_backup_mkdir',
93+
'fs_temp_backup_move',
94+
);
95+
96+
/**
97+
* Install, replacing any existing plugin at the same slug.
98+
*
99+
* @return bool|WP_Error
100+
*/
101+
public function install() {
102+
$args = $this->input();
103+
104+
if ( ! isset( $args['zip'][0]['id'] ) || ! is_scalar( $args['zip'][0]['id'] ) ) {
105+
return new WP_Error( 'no_plugin_installed', __( 'No plugin zip file was provided.', 'jetpack' ), 400 );
106+
}
107+
108+
$expected_slug = isset( $args['slug'] ) && is_scalar( $args['slug'] )
109+
? strtolower( (string) $args['slug'] )
110+
: '';
111+
if ( ! preg_match( '/^[a-z0-9][a-z0-9_-]*$/', $expected_slug ) ) {
112+
return new WP_Error( 'missing_slug', __( 'A valid plugin slug is required; the replace endpoint refuses to overwrite a plugin whose slug the caller has not declared.', 'jetpack' ), 400 );
113+
}
114+
115+
$plugin_attachment_id = (int) $args['zip'][0]['id'];
116+
117+
// Re-checked here so direct invocations of install() (notably the test stubs)
118+
// can't accidentally bypass the ownership guard that validate_call() runs on
119+
// the live request path.
120+
$ownership = $this->validate_attachment_ownership( $plugin_attachment_id );
121+
if ( is_wp_error( $ownership ) ) {
122+
return $ownership;
123+
}
124+
125+
$zip_check = $this->validate_attachment_is_zip( $plugin_attachment_id );
126+
if ( is_wp_error( $zip_check ) ) {
127+
wp_delete_attachment( $plugin_attachment_id, true );
128+
return $zip_check;
129+
}
130+
131+
$local_file = get_attached_file( $plugin_attachment_id );
132+
if ( ! $local_file ) {
133+
wp_delete_attachment( $plugin_attachment_id, true );
134+
return new WP_Error( 'local-file-does-not-exist', __( 'Uploaded plugin zip could not be found on disk.', 'jetpack' ), 400 );
135+
}
136+
137+
$skin = new Automatic_Install_Skin();
138+
$upgrader = new Plugin_Upgrader( $skin );
139+
140+
$result = $upgrader->install(
141+
$local_file,
142+
array( 'overwrite_package' => true )
143+
);
144+
145+
wp_delete_attachment( $plugin_attachment_id, true );
146+
147+
if ( is_wp_error( $result ) ) {
148+
return $this->sanitize_upgrader_error( $result );
149+
}
150+
151+
if ( ! $result ) {
152+
$error_code = $skin->get_main_error_code();
153+
if ( 'download_failed' === $error_code ) {
154+
$error_code = 'no_package';
155+
}
156+
if ( empty( $error_code ) || ! in_array( $error_code, self::$allowed_error_codes, true ) ) {
157+
$error_code = 'install_failed';
158+
}
159+
return new WP_Error( $error_code, __( 'Plugin installation failed.', 'jetpack' ), 400 );
160+
}
161+
162+
$plugin_file = $upgrader->plugin_info();
163+
if ( empty( $plugin_file ) ) {
164+
return new WP_Error( 'plugin_replace_info_missing', __( 'Plugin was installed but its identifier could not be determined.', 'jetpack' ), 500 );
165+
}
166+
167+
// `overwrite_package=true` trusts the zip's own folder name, so a zip whose
168+
// top-level folder differs from the declared slug would clobber an unrelated
169+
// plugin. Verify the post-install identifier matches the caller's contract.
170+
$installed_slug = dirname( $plugin_file );
171+
if ( '.' === $installed_slug || strtolower( $installed_slug ) !== $expected_slug ) {
172+
return new WP_Error( 'slug_mismatch', __( 'The installed plugin does not match the declared slug.', 'jetpack' ), 400 );
173+
}
174+
175+
$this->plugins = array( $plugin_file );
176+
$this->log[ $plugin_file ] = $upgrader->skin->get_upgrade_messages();
177+
178+
return true;
179+
}
180+
181+
/**
182+
* See class docblock — runs the attachment-ownership check before delegating
183+
* to the parent's validate_call(), which deletes the referenced attachment
184+
* on capability-check failure without verifying ownership.
185+
*
186+
* @param int $_blog_id Blog ID.
187+
* @param string $capability Capability.
188+
* @param bool $check_manage_active Whether to check manage-is-active.
189+
* @return bool|WP_Error
190+
*/
191+
protected function validate_call( $_blog_id, $capability, $check_manage_active = true ) {
192+
$args = $this->input();
193+
if ( isset( $args['zip'][0]['id'] ) && is_scalar( $args['zip'][0]['id'] ) ) {
194+
$ownership = $this->validate_attachment_ownership( (int) $args['zip'][0]['id'] );
195+
if ( is_wp_error( $ownership ) ) {
196+
return $ownership;
197+
}
198+
}
199+
return parent::validate_call( $_blog_id, $capability, $check_manage_active );
200+
}
201+
202+
/**
203+
* Collapse unknown error codes and strip potentially path-leaking messages
204+
* from WP_Error instances returned by Plugin_Upgrader.
205+
*
206+
* @param WP_Error $error Raw upgrader error.
207+
* @return WP_Error
208+
*/
209+
protected function sanitize_upgrader_error( WP_Error $error ) {
210+
$code = $error->get_error_code();
211+
if ( empty( $code ) || ! in_array( $code, self::$allowed_error_codes, true ) ) {
212+
return new WP_Error( 'install_failed', __( 'Plugin installation failed.', 'jetpack' ), 400 );
213+
}
214+
return new WP_Error( $code, __( 'Plugin installation failed.', 'jetpack' ), 400 );
215+
}
216+
}
217+
218+
// POST /sites/%s/plugins/replace
219+
new Jetpack_JSON_API_Plugins_Replace_Endpoint(
220+
array(
221+
'description' => 'Install or replace a plugin on a Jetpack site by uploading a zip file. If a plugin with the same slug is already installed, its destination folder is replaced in place, mirroring wp-admin\'s "Replace current with uploaded" upload flow.',
222+
'group' => '__do_not_document',
223+
'stat' => 'plugins:replace',
224+
'min_version' => '1',
225+
'max_version' => '1.1',
226+
'method' => 'POST',
227+
'path' => '/sites/%s/plugins/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 plugin package zip file.',
233+
'slug' => '(string) The plugin 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_Plugins_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/plugins/replace',
243+
)
244+
);
245+
246+
new Jetpack_JSON_API_Plugins_Replace_Endpoint(
247+
array(
248+
'description' => 'Install or replace a plugin on a Jetpack site by uploading a zip file. If a plugin with the same slug is already installed, its destination folder is replaced in place, mirroring wp-admin\'s "Replace current with uploaded" upload flow.',
249+
'group' => '__do_not_document',
250+
'stat' => 'plugins:replace',
251+
'min_version' => '1.2',
252+
'method' => 'POST',
253+
'path' => '/sites/%s/plugins/replace',
254+
'path_labels' => array(
255+
'$site' => '(int|string) Site ID or domain',
256+
),
257+
'request_format' => array(
258+
'zip' => '(array) Reference to an uploaded plugin package zip file.',
259+
'slug' => '(string) The plugin slug the uploaded zip must resolve to. Required; the endpoint rejects zips whose top-level folder does not match.',
260+
),
261+
'response_format' => Jetpack_JSON_API_Plugins_Endpoint::$_response_format_v1_2,
262+
'allow_jetpack_site_auth' => true,
263+
'example_request_data' => array(
264+
'headers' => array(
265+
'authorization' => 'Bearer YOUR_API_TOKEN',
266+
),
267+
),
268+
'example_request' => 'https://public-api.wordpress.com/rest/v1.2/sites/example.wordpress.org/plugins/replace',
269+
)
270+
);

0 commit comments

Comments
 (0)