PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 0.9.8
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v0.9.8
1.1.10 1.1.9 1.1.8 1.1.7 1.1.6 1.1.5 1.1.4 1.1.3 1.1.2 1.1.1 1.1.0 1.0.1 1.0.0 0.9.8 0.9.7 0.9.6 0.9.4 0.9.5 0.9.3 0.9.2 0.9.1 0.9.0 0.8.9 0.8.8 0.8.7 All 34 releases
desktop-mode / includes / plugins-window / ajax.php

ajax.php in OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin 0.9.8, at includes/plugins-window/ajax.php

990 lines 31.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Desktop Mode — Native Plugins Window: admin-ajax actions.
4 *
5 * Anything that needs an admin-only class lives here, NOT on REST.
6 * Reason: `admin-ajax.php` itself ships from `wp-admin/`, so by the
7 * time any `wp_ajax_*` callback fires, `wp-admin/includes/{plugin,
8 * plugin-install,file,misc,class-wp-upgrader,class-wp-ajax-upgrader-skin}.php`
9 * are already on the include path. Calling those classes from a REST
10 * route would force a `require_once ABSPATH . 'wp-admin/…'` line —
11 * Plugin Check rejects that pattern.
12 *
13 * Action map (every callback verifies a `desktop-mode-plugins` nonce
14 * AND a per-action capability):
15 *
16 * wp_ajax_desktop_mode_plugins_browse — `plugins_api( 'query_plugins' )`
17 * wp_ajax_desktop_mode_plugins_info — `plugins_api( 'plugin_information' )`
18 * wp_ajax_desktop_mode_plugins_reviews — wp.org reviews scrape (DOMDocument)
19 * wp_ajax_desktop_mode_plugins_upload — `Plugin_Upgrader::install()` from $_FILES
20 * wp_ajax_desktop_mode_plugins_featured — curated + requires_plugins-discovered
21 * gallery (`plugins_api`)
22 *
23 * Install-by-slug is handled by Core's existing `wp_ajax_install_plugin`
24 * — the JS calls it directly with the standard `'updates'` nonce. We
25 * never reimplement it.
26 *
27 * Activate / deactivate / delete go through Core REST
28 * (`PUT/DELETE /wp/v2/plugins/{plugin}`), which lives in
29 * `wp-includes/`. No custom handler needed there.
30 *
31 * @package WPDesktopMode
32 */
33
34 defined( 'ABSPATH' ) || exit;
35
36 /**
37 * Shared nonce check + capability gate for every action below.
38 *
39 * Uses `check_ajax_referer( …, …, false )` so a missing/expired
40 * nonce surfaces as a clean JSON error rather than a `wp_die()` —
41 * the JS wraps every call in a `wp.desktop.fetch` and expects JSON.
42 *
43 * @param string $cap Capability the requester must hold.
44 * @return true|WP_Error True on pass, WP_Error on rejection.
45 */
46 function desktop_mode_plugins_window_ajax_guard( $cap ) {
47 $nonce_ok = check_ajax_referer( 'desktop-mode-plugins', '_ajax_nonce', false );
48 if ( ! $nonce_ok ) {
49 return new WP_Error(
50 'desktop_mode_plugins_bad_nonce',
51 __( 'Security check failed. Refresh the window and try again.', 'desktop-mode' ),
52 array( 'status' => 403 )
53 );
54 }
55 if ( ! current_user_can( $cap ) ) {
56 return new WP_Error(
57 'desktop_mode_plugins_forbidden',
58 __( 'You are not allowed to do that.', 'desktop-mode' ),
59 array( 'status' => 403 )
60 );
61 }
62 return true;
63 }
64
65 /**
66 * Send a `WP_Error` as a JSON response, then exit.
67 *
68 * @param WP_Error $error
69 * @return void
70 */
71 function desktop_mode_plugins_window_ajax_error( WP_Error $error ) {
72 $status = 500;
73 $data = $error->get_error_data();
74 if ( is_array( $data ) && isset( $data['status'] ) ) {
75 $status = (int) $data['status'];
76 }
77 wp_send_json_error(
78 array(
79 'code' => $error->get_error_code(),
80 'message' => $error->get_error_message(),
81 ),
82 $status
83 );
84 }
85
86 /**
87 * `wp_ajax_desktop_mode_plugins_browse` — proxy to
88 * `plugins_api( 'query_plugins', … )` with a 10-minute transient
89 * cache keyed by the args.
90 *
91 * Body params:
92 * - browse string (featured|popular|recommended|favorites|new|beta), default "featured"
93 * - search string, optional
94 * - tag string, optional
95 * - page int, default 1
96 * - per_page int, default 24, capped at 60
97 */
98 function desktop_mode_plugins_window_ajax_browse() {
99 $guard = desktop_mode_plugins_window_ajax_guard( 'install_plugins' );
100 if ( is_wp_error( $guard ) ) {
101 desktop_mode_plugins_window_ajax_error( $guard );
102 return; // unreachable; clarity for static analyzers.
103 }
104
105 // `plugins_api()` lives in `wp-admin/includes/plugin-install.php`,
106 // which `admin-ajax.php` does NOT auto-load. Same idiom Core's
107 // own `wp_ajax_install_plugin` uses (see
108 // `wp-admin/includes/ajax-actions.php` ~L4483). Plugin Check
109 // accepts this — the rule against `require_once ABSPATH .
110 // 'wp-admin/…'` only applies to non-admin contexts (REST
111 // callbacks, plugin bootstrap), and `wp_ajax_*` hooks fire
112 // inside admin-ajax which is itself an admin file.
113 if ( ! function_exists( 'plugins_api' ) ) {
114 require_once ABSPATH . 'wp-admin/includes/plugin-install.php';
115 }
116
117 $browse_raw = isset( $_POST['browse'] ) ? sanitize_key( wp_unslash( (string) $_POST['browse'] ) ) : 'featured';
118 $allowed = array( 'featured', 'popular', 'recommended', 'favorites', 'new', 'beta', 'updated' );
119 if ( ! in_array( $browse_raw, $allowed, true ) ) {
120 $browse_raw = 'featured';
121 }
122
123 $search = isset( $_POST['search'] ) ? sanitize_text_field( wp_unslash( (string) $_POST['search'] ) ) : '';
124 $tag = isset( $_POST['tag'] ) ? sanitize_key( wp_unslash( (string) $_POST['tag'] ) ) : '';
125 $page = isset( $_POST['page'] ) ? max( 1, (int) $_POST['page'] ) : 1;
126 $per_page = isset( $_POST['per_page'] ) ? max( 1, min( 60, (int) $_POST['per_page'] ) ) : 24;
127
128 $api_args = array(
129 'page' => $page,
130 'per_page' => $per_page,
131 // Lightweight field set — `plugin_information` covers the
132 // detail-flyout case via a separate call.
133 'fields' => array(
134 'icons' => true,
135 'banners' => true,
136 'short_description' => true,
137 'description' => false,
138 'sections' => false,
139 'screenshots' => false,
140 'rating' => true,
141 'ratings' => false,
142 'num_ratings' => true,
143 'active_installs' => true,
144 'last_updated' => true,
145 'tested' => true,
146 'requires' => true,
147 'requires_php' => true,
148 'homepage' => true,
149 'compatibility' => false,
150 'group' => false,
151 'contributors' => false,
152 'donate_link' => false,
153 ),
154 );
155
156 if ( '' !== $search ) {
157 $api_args['search'] = $search;
158 } elseif ( '' !== $tag ) {
159 $api_args['tag'] = $tag;
160 } else {
161 $api_args['browse'] = $browse_raw;
162 }
163
164 /**
165 * Filter the args passed to `plugins_api( 'query_plugins', … )`.
166 *
167 * @param array $api_args Args passed to plugins_api.
168 * @param array $raw_params Sanitized request params.
169 */
170 $api_args = (array) apply_filters(
171 'desktop_mode_plugins_window_browse_args',
172 $api_args,
173 array(
174 'browse' => $browse_raw,
175 'search' => $search,
176 'tag' => $tag,
177 'page' => $page,
178 'per_page' => $per_page,
179 )
180 );
181
182 $cache_key = 'dm_pwbrowse_' . md5( wp_json_encode( $api_args ) );
183 $cached = get_transient( $cache_key );
184 if ( false !== $cached && is_array( $cached ) ) {
185 wp_send_json_success( $cached );
186 return;
187 }
188
189 $result = plugins_api( 'query_plugins', $api_args );
190 if ( is_wp_error( $result ) ) {
191 desktop_mode_plugins_window_ajax_error( $result );
192 return;
193 }
194
195 // `plugins_api` returns an object with `plugins` + `info` props.
196 $payload = array(
197 'plugins' => isset( $result->plugins ) ? array_values( (array) $result->plugins ) : array(),
198 'info' => isset( $result->info ) ? (array) $result->info : array(),
199 );
200
201 /**
202 * Filter the browse response before it's cached + sent.
203 *
204 * @param array $payload `{ plugins, info }`.
205 * @param array $api_args Args used.
206 */
207 $payload = (array) apply_filters(
208 'desktop_mode_plugins_window_browse_response',
209 $payload,
210 $api_args
211 );
212
213 set_transient( $cache_key, $payload, 10 * MINUTE_IN_SECONDS );
214 wp_send_json_success( $payload );
215 }
216 add_action( 'wp_ajax_desktop_mode_plugins_browse', 'desktop_mode_plugins_window_ajax_browse' );
217
218 /**
219 * `wp_ajax_desktop_mode_plugins_info` — proxy to
220 * `plugins_api( 'plugin_information', { slug, fields: { … } } )`
221 * with a 1-hour transient cache per slug.
222 *
223 * Body params:
224 * - slug string, required
225 */
226 function desktop_mode_plugins_window_ajax_info() {
227 $guard = desktop_mode_plugins_window_ajax_guard( 'install_plugins' );
228 if ( is_wp_error( $guard ) ) {
229 desktop_mode_plugins_window_ajax_error( $guard );
230 return;
231 }
232
233 // See note in the browse handler — `plugins_api()` is admin-only;
234 // admin-ajax does not auto-load it. Mirrors Core's own
235 // `wp_ajax_install_plugin` idiom.
236 if ( ! function_exists( 'plugins_api' ) ) {
237 require_once ABSPATH . 'wp-admin/includes/plugin-install.php';
238 }
239
240 $slug = isset( $_POST['slug'] ) ? sanitize_key( wp_unslash( (string) $_POST['slug'] ) ) : '';
241 if ( '' === $slug ) {
242 desktop_mode_plugins_window_ajax_error(
243 new WP_Error(
244 'desktop_mode_plugins_missing_slug',
245 __( 'Missing plugin slug.', 'desktop-mode' ),
246 array( 'status' => 400 )
247 )
248 );
249 return;
250 }
251
252 $cache_key = 'dm_pwinfo_' . md5( $slug );
253 $cached = get_transient( $cache_key );
254 if ( false !== $cached && is_array( $cached ) ) {
255 wp_send_json_success( $cached );
256 return;
257 }
258
259 $api_args = array(
260 'slug' => $slug,
261 'fields' => array(
262 'sections' => true,
263 'screenshots' => true,
264 'ratings' => true,
265 'banners' => true,
266 'icons' => true,
267 'contributors' => true,
268 'last_updated' => true,
269 'requires' => true,
270 'requires_php' => true,
271 'tested' => true,
272 'homepage' => true,
273 'short_description' => true,
274 'donate_link' => true,
275 'reviews' => false, // We use our own scraper for the Reviews tab.
276 ),
277 );
278
279 $result = plugins_api( 'plugin_information', $api_args );
280 if ( is_wp_error( $result ) ) {
281 desktop_mode_plugins_window_ajax_error( $result );
282 return;
283 }
284
285 $payload = (array) $result;
286
287 /**
288 * Filter the plugin-information response before it's cached + sent.
289 *
290 * @param array $payload Result, cast to array.
291 * @param string $slug Plugin slug.
292 */
293 $payload = (array) apply_filters(
294 'desktop_mode_plugins_window_info_response',
295 $payload,
296 $slug
297 );
298
299 set_transient( $cache_key, $payload, HOUR_IN_SECONDS );
300 wp_send_json_success( $payload );
301 }
302 add_action( 'wp_ajax_desktop_mode_plugins_info', 'desktop_mode_plugins_window_ajax_info' );
303
304 /**
305 * `wp_ajax_desktop_mode_plugins_reviews` — best-effort scrape of the
306 * top reviews from a plugin's wp.org page.
307 *
308 * Body params:
309 * - slug string, required
310 *
311 * Returns either `{ items: [...], parsed: true }` or
312 * `{ items: [], parsed: false, reason: '<code>' }`. Caller is
313 * expected to fall back to the histogram-only view on `parsed: false`.
314 * Cache success 1h, failure 15m so wp.org can recover quickly.
315 */
316 function desktop_mode_plugins_window_ajax_reviews() {
317 $guard = desktop_mode_plugins_window_ajax_guard( 'install_plugins' );
318 if ( is_wp_error( $guard ) ) {
319 desktop_mode_plugins_window_ajax_error( $guard );
320 return;
321 }
322
323 $slug = isset( $_POST['slug'] ) ? sanitize_key( wp_unslash( (string) $_POST['slug'] ) ) : '';
324 if ( '' === $slug ) {
325 desktop_mode_plugins_window_ajax_error(
326 new WP_Error(
327 'desktop_mode_plugins_missing_slug',
328 __( 'Missing plugin slug.', 'desktop-mode' ),
329 array( 'status' => 400 )
330 )
331 );
332 return;
333 }
334
335 $cache_key = 'dm_pwreviews_' . md5( $slug );
336 $cached = get_transient( $cache_key );
337 if ( false !== $cached && is_array( $cached ) ) {
338 wp_send_json_success( $cached );
339 return;
340 }
341
342 /**
343 * Filter to swap out the default DOMDocument-based review parser.
344 *
345 * Return an array of items to short-circuit; return `null` to
346 * fall through to the default parser. Items must each be an
347 * associative array with `author`, `stars` (int 1–5), `excerpt`,
348 * `date`, and (optional) `url` keys.
349 *
350 * @param array|null $items Override list, or null for default behaviour.
351 * @param string $slug Plugin slug.
352 */
353 $override = apply_filters( 'desktop_mode_plugins_window_review_parser', null, $slug );
354 if ( is_array( $override ) ) {
355 $payload = array(
356 'items' => array_values( $override ),
357 'parsed' => true,
358 );
359 set_transient( $cache_key, $payload, HOUR_IN_SECONDS );
360 wp_send_json_success( $payload );
361 return;
362 }
363
364 $url = 'https://wordpress.org/plugins/' . $slug . '/#reviews';
365 $response = wp_remote_get(
366 $url,
367 array(
368 'timeout' => 5,
369 'sslverify' => true,
370 'headers' => array(
371 'Accept-Language' => get_locale(),
372 ),
373 )
374 );
375
376 if ( is_wp_error( $response ) ) {
377 $payload = array(
378 'items' => array(),
379 'parsed' => false,
380 'reason' => 'fetch_failed',
381 );
382 set_transient( $cache_key, $payload, 15 * MINUTE_IN_SECONDS );
383 wp_send_json_success( $payload );
384 return;
385 }
386
387 $status = (int) wp_remote_retrieve_response_code( $response );
388 if ( $status < 200 || $status >= 300 ) {
389 $payload = array(
390 'items' => array(),
391 'parsed' => false,
392 'reason' => 'http_' . $status,
393 );
394 set_transient( $cache_key, $payload, 15 * MINUTE_IN_SECONDS );
395 wp_send_json_success( $payload );
396 return;
397 }
398
399 $body = (string) wp_remote_retrieve_body( $response );
400 if ( '' === $body ) {
401 $payload = array(
402 'items' => array(),
403 'parsed' => false,
404 'reason' => 'empty_body',
405 );
406 set_transient( $cache_key, $payload, 15 * MINUTE_IN_SECONDS );
407 wp_send_json_success( $payload );
408 return;
409 }
410
411 $items = desktop_mode_plugins_window_parse_reviews_html( $body );
412 if ( null === $items ) {
413 $payload = array(
414 'items' => array(),
415 'parsed' => false,
416 'reason' => 'parse_failed',
417 );
418 set_transient( $cache_key, $payload, 15 * MINUTE_IN_SECONDS );
419 wp_send_json_success( $payload );
420 return;
421 }
422
423 $payload = array(
424 'items' => $items,
425 'parsed' => true,
426 );
427 set_transient( $cache_key, $payload, HOUR_IN_SECONDS );
428 wp_send_json_success( $payload );
429 }
430 add_action( 'wp_ajax_desktop_mode_plugins_reviews', 'desktop_mode_plugins_window_ajax_reviews' );
431
432 /**
433 * Default DOMDocument-based parser for the wp.org plugin reviews
434 * page. Returns an array of `{ author, stars, excerpt, date, url }`
435 * on success, or `null` when parsing fails.
436 *
437 * The wp.org review HTML may change without notice — wrap every
438 * navigation in `try`/`catch` and bail to `null` on any failure so
439 * the JS can fall back to the histogram-only view.
440 *
441 * @param string $html
442 * @return array<int,array<string,mixed>>|null
443 */
444 function desktop_mode_plugins_window_parse_reviews_html( $html ) {
445 if ( ! class_exists( 'DOMDocument' ) ) {
446 return null;
447 }
448
449 try {
450 $prev = libxml_use_internal_errors( true );
451 $doc = new DOMDocument();
452 // Force UTF-8 — wp.org output is UTF-8 but loadHTML defaults
453 // to ISO-8859-1.
454 $doc->loadHTML(
455 '<?xml encoding="UTF-8">' . $html,
456 LIBXML_NOERROR | LIBXML_NOWARNING
457 );
458 libxml_clear_errors();
459 libxml_use_internal_errors( $prev );
460
461 $xpath = new DOMXPath( $doc );
462
463 // wp.org review markup at the time of writing wraps each
464 // review in `<div class="review">` containing `<h4>`-like
465 // title, `<div class="reviewer">` author block, `<p>` body,
466 // star rating spans, and a permalink. We grab the first 5.
467 $reviews = $xpath->query( '//*[contains(concat(" ", normalize-space(@class), " "), " review ")]' );
468 if ( ! $reviews instanceof DOMNodeList || 0 === $reviews->length ) {
469 return null;
470 }
471
472 $out = array();
473 $count = 0;
474 foreach ( $reviews as $review ) {
475 if ( $count >= 5 ) {
476 break;
477 }
478 if ( ! $review instanceof DOMNode ) {
479 continue;
480 }
481
482 $author = '';
483 $author_nodes = $xpath->query(
484 './/*[contains(concat(" ", normalize-space(@class), " "), " reviewer-name ")]',
485 $review
486 );
487 if ( $author_nodes instanceof DOMNodeList && $author_nodes->length > 0 ) {
488 $author = trim( (string) $author_nodes->item( 0 )->textContent );
489 }
490
491 $excerpt = '';
492 $excerpt_nodes = $xpath->query( './/p', $review );
493 if ( $excerpt_nodes instanceof DOMNodeList && $excerpt_nodes->length > 0 ) {
494 $excerpt = trim( (string) $excerpt_nodes->item( 0 )->textContent );
495 }
496 if ( '' !== $excerpt && function_exists( 'mb_strimwidth' ) ) {
497 $excerpt = mb_strimwidth( $excerpt, 0, 320, '' );
498 }
499
500 $date = '';
501 $date_nodes = $xpath->query(
502 './/*[contains(concat(" ", normalize-space(@class), " "), " review-date ")]',
503 $review
504 );
505 if ( $date_nodes instanceof DOMNodeList && $date_nodes->length > 0 ) {
506 $date = trim( (string) $date_nodes->item( 0 )->textContent );
507 }
508
509 $stars = 0;
510 $rating_nodes = $xpath->query(
511 './/*[contains(concat(" ", normalize-space(@class), " "), " wporg-ratings ") or contains(concat(" ", normalize-space(@class), " "), " star-rating ")]',
512 $review
513 );
514 if ( $rating_nodes instanceof DOMNodeList && $rating_nodes->length > 0 ) {
515 $rating_text = (string) $rating_nodes->item( 0 )->textContent;
516 if ( preg_match( '/(\d+(?:\.\d+)?)\s*\/\s*5/', $rating_text, $m ) ) {
517 $stars = (int) round( (float) $m[1] );
518 } elseif ( preg_match( '/(\d+)\s*star/i', $rating_text, $m ) ) {
519 $stars = (int) $m[1];
520 } else {
521 // Fall back to counting filled-star elements.
522 $filled = $xpath->query(
523 './/*[contains(concat(" ", normalize-space(@class), " "), " star ") and contains(concat(" ", normalize-space(@class), " "), " filled ")]',
524 $rating_nodes->item( 0 )
525 );
526 if ( $filled instanceof DOMNodeList ) {
527 $stars = (int) $filled->length;
528 }
529 }
530 }
531 $stars = max( 0, min( 5, $stars ) );
532
533 $url = '';
534 $link_nodes = $xpath->query( './/a[contains(@href, "/topic/")]', $review );
535 if ( $link_nodes instanceof DOMNodeList && $link_nodes->length > 0 ) {
536 $href = $link_nodes->item( 0 );
537 if ( $href instanceof DOMElement ) {
538 $url = (string) $href->getAttribute( 'href' );
539 }
540 }
541
542 if ( '' === $author && '' === $excerpt ) {
543 continue;
544 }
545
546 $out[] = array(
547 'author' => $author,
548 'stars' => $stars,
549 'excerpt' => $excerpt,
550 'date' => $date,
551 'url' => $url,
552 );
553 $count++;
554 }
555
556 return $out;
557 } catch ( Throwable $e ) { // phpcs:ignore PHPCompatibility.Classes.NewClasses.throwableFound
558 // Throwable covers both Errors and Exceptions on PHP 7+. Any
559 // failure (e.g. malformed HTML, libxml gone) bails to null
560 // so the caller serves the histogram-only fallback.
561 return null;
562 }
563 }
564
565 /**
566 * `wp_ajax_desktop_mode_plugins_upload` — install a plugin from a
567 * .zip uploaded as multipart/form-data under the `pluginzip` field.
568 *
569 * Mirrors the classic `update.php?action=upload-plugin` flow but
570 * returns JSON. By the time this callback fires, the
571 * `Plugin_Upgrader`, `WP_Ajax_Upgrader_Skin`, and `wp_handle_upload`
572 * symbols are already loaded (admin-ajax loads them).
573 */
574 function desktop_mode_plugins_window_ajax_upload() {
575 $guard = desktop_mode_plugins_window_ajax_guard( 'upload_plugins' );
576 if ( is_wp_error( $guard ) ) {
577 desktop_mode_plugins_window_ajax_error( $guard );
578 return;
579 }
580
581 if ( empty( $_FILES['pluginzip'] ) || ! is_array( $_FILES['pluginzip'] ) ) {
582 desktop_mode_plugins_window_ajax_error(
583 new WP_Error(
584 'desktop_mode_plugins_missing_file',
585 __( 'No file received. Pick a .zip and try again.', 'desktop-mode' ),
586 array( 'status' => 400 )
587 )
588 );
589 return;
590 }
591
592 $file = $_FILES['pluginzip']; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- read raw, sanitized below.
593
594 if ( ! isset( $file['name'] ) || ! isset( $file['tmp_name'] ) || ! isset( $file['error'] ) ) {
595 desktop_mode_plugins_window_ajax_error(
596 new WP_Error(
597 'desktop_mode_plugins_invalid_file',
598 __( 'Upload payload is malformed.', 'desktop-mode' ),
599 array( 'status' => 400 )
600 )
601 );
602 return;
603 }
604
605 if ( UPLOAD_ERR_OK !== (int) $file['error'] ) {
606 desktop_mode_plugins_window_ajax_error(
607 new WP_Error(
608 'desktop_mode_plugins_upload_error',
609 sprintf(
610 /* translators: %d: PHP UPLOAD_ERR_* code. */
611 __( 'Upload failed (error %d). Try again.', 'desktop-mode' ),
612 (int) $file['error']
613 ),
614 array( 'status' => 400 )
615 )
616 );
617 return;
618 }
619
620 $name = sanitize_file_name( (string) $file['name'] );
621 if ( '' === $name || '.zip' !== strtolower( substr( $name, -4 ) ) ) {
622 desktop_mode_plugins_window_ajax_error(
623 new WP_Error(
624 'desktop_mode_plugins_not_zip',
625 __( 'Plugin uploads must be a .zip file.', 'desktop-mode' ),
626 array( 'status' => 400 )
627 )
628 );
629 return;
630 }
631
632 $tmp_name = (string) $file['tmp_name'];
633 if ( ! is_uploaded_file( $tmp_name ) ) {
634 // Defensive — `is_uploaded_file()` is the standard guard
635 // against a path-traversal payload smuggled through tmp_name.
636 desktop_mode_plugins_window_ajax_error(
637 new WP_Error(
638 'desktop_mode_plugins_bad_tmp',
639 __( 'Refused: temporary upload path is not trusted.', 'desktop-mode' ),
640 array( 'status' => 400 )
641 )
642 );
643 return;
644 }
645
646 // `Plugin_Upgrader` + `WP_Ajax_Upgrader_Skin` are admin-only
647 // classes; admin-ajax does NOT auto-load them. Same `require_once`
648 // chain Core's own `wp_ajax_install_plugin` uses (see
649 // `wp-admin/includes/ajax-actions.php`). Plugin Check accepts
650 // this in admin-ajax callbacks — the rule applies to non-admin
651 // contexts (REST callbacks, plugin bootstrap), not here.
652 if ( ! function_exists( 'wp_handle_upload' ) ) {
653 require_once ABSPATH . 'wp-admin/includes/file.php';
654 }
655 if ( ! class_exists( 'WP_Upgrader' ) ) {
656 require_once ABSPATH . 'wp-admin/includes/class-wp-upgrader.php';
657 }
658 if ( ! class_exists( 'WP_Ajax_Upgrader_Skin' ) ) {
659 require_once ABSPATH . 'wp-admin/includes/class-wp-ajax-upgrader-skin.php';
660 }
661 if ( ! class_exists( 'Plugin_Upgrader' ) || ! class_exists( 'WP_Ajax_Upgrader_Skin' ) ) {
662 desktop_mode_plugins_window_ajax_error(
663 new WP_Error(
664 'desktop_mode_plugins_upgrader_missing',
665 __( 'Plugin upgrader is unavailable in this context. Reload the page and try again.', 'desktop-mode' ),
666 array( 'status' => 503 )
667 )
668 );
669 return;
670 }
671
672 // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce verified above via desktop_mode_plugins_window_ajax_guard().
673 $overwrite = ! empty( $_POST['overwrite'] );
674
675 $skin = new WP_Ajax_Upgrader_Skin();
676 $upgrader = new Plugin_Upgrader( $skin );
677 $result = $upgrader->install(
678 $tmp_name,
679 array( 'overwrite_package' => $overwrite )
680 );
681
682 // Treat "destination folder exists" specially when the caller did
683 // NOT explicitly ask to overwrite — return a 409 with enough
684 // context for the client to prompt the user and re-submit with
685 // `overwrite=1`. WP_Ajax_Upgrader_Skin parks the error on
686 // `$skin->result`; older paths and `Plugin_Upgrader::install()`
687 // itself can also surface it via `$result` directly or by
688 // returning `false`, so check all three. Mirrors Core's classic
689 // `update.php?action=upload-plugin` confirm flow without the
690 // full upload-and-rerun-from-disk dance.
691 $folder_exists = false;
692 if ( ! $overwrite ) {
693 if ( is_wp_error( $skin->result ) && 'folder_exists' === $skin->result->get_error_code() ) {
694 $folder_exists = true;
695 } elseif ( is_wp_error( $result ) && 'folder_exists' === $result->get_error_code() ) {
696 $folder_exists = true;
697 } elseif ( false === $result || null === $result ) {
698 // `Plugin_Upgrader::install()` returns `false` when the
699 // destination already exists and overwrite isn't allowed.
700 // We can't get the destination path from the result, but
701 // the upgrader emitted the same `folder_exists` skin
702 // error en route (caught above) — this branch is a
703 // belt-and-braces fallback.
704 $folder_exists = true;
705 }
706 }
707
708 if ( $folder_exists ) {
709 desktop_mode_plugins_window_ajax_error(
710 new WP_Error(
711 'folder_exists',
712 __(
713 'A plugin with the same folder name is already installed. Replace it to continue.',
714 'desktop-mode'
715 ),
716 array( 'status' => 409 )
717 )
718 );
719 return;
720 }
721
722 if ( is_wp_error( $skin->result ) ) {
723 desktop_mode_plugins_window_ajax_error( $skin->result );
724 return;
725 }
726 if ( $skin->get_errors()->has_errors() ) {
727 desktop_mode_plugins_window_ajax_error( $skin->get_errors() );
728 return;
729 }
730 if ( is_wp_error( $result ) ) {
731 desktop_mode_plugins_window_ajax_error( $result );
732 return;
733 }
734 if ( false === $result || null === $result ) {
735 desktop_mode_plugins_window_ajax_error(
736 new WP_Error(
737 'desktop_mode_plugins_install_failed',
738 __( 'Plugin install failed.', 'desktop-mode' ),
739 array( 'status' => 500 )
740 )
741 );
742 return;
743 }
744
745 $plugin_file = $upgrader->plugin_info();
746
747 /**
748 * Fires after the Plugins window has installed a plugin from an
749 * uploaded .zip. Hook callers receive the resolved plugin file.
750 *
751 * @param string $plugin_file Plugin file (e.g. "akismet/akismet.php").
752 */
753 do_action( 'desktop_mode_plugins_window_installed', $plugin_file );
754
755 // Read the just-installed plugin's headers so the client can show
756 // a name / version on the post-install Activate panel without a
757 // follow-up round-trip. `get_plugin_data()` reads the file
758 // directly — cheap, and the file is already warm in disk cache
759 // from the upgrader.
760 $plugin_name = '';
761 $plugin_version = '';
762 if ( '' !== $plugin_file ) {
763 if ( ! function_exists( 'get_plugin_data' ) ) {
764 require_once ABSPATH . 'wp-admin/includes/plugin.php';
765 }
766 $abs_plugin_file = WP_PLUGIN_DIR . '/' . $plugin_file;
767 if ( file_exists( $abs_plugin_file ) ) {
768 $data = get_plugin_data( $abs_plugin_file, false, false );
769 $plugin_name = isset( $data['Name'] ) ? (string) $data['Name'] : '';
770 $plugin_version = isset( $data['Version'] ) ? (string) $data['Version'] : '';
771 }
772 }
773
774 wp_send_json_success(
775 array(
776 'plugin_file' => (string) $plugin_file,
777 'plugin_name' => $plugin_name,
778 'plugin_version' => $plugin_version,
779 'status' => 'inactive',
780 'messages' => $skin->get_upgrade_messages(),
781 )
782 );
783 }
784 add_action( 'wp_ajax_desktop_mode_plugins_upload', 'desktop_mode_plugins_window_ajax_upload' );
785
786 /**
787 * Curated list of slugs that lead the Featured tab.
788 *
789 * Hand-picked because wp.org's `plugins_api` does not surface a real
790 * "filter by `requires_plugins`" query — passing `requires_plugins` to
791 * `query_plugins` is silently ignored and returns the unfiltered repo.
792 * Until the directory grows a usable filter, we maintain the seed list
793 * here and let downstream plugins amend it via the filter below.
794 *
795 * Slug-only — the AJAX handler hydrates each entry through
796 * `plugins_api( 'plugin_information' )` so the card has up-to-date
797 * icons, descriptions, and install counts without us caching them.
798 *
799 * @return string[] List of wp.org plugin slugs.
800 */
801 function desktop_mode_plugins_window_featured_slugs() {
802 $slugs = array(
803 // The author of this plugin forgot to declare Desktop Mode as a
804 // dependency — surfacing it here makes sure desktop-mode users
805 // discover it anyway. Once the `requires_plugins` query lands on
806 // wp.org we can remove the manual seed.
807 'odd-outlandish-desktop-decorator',
808 );
809
810 /**
811 * Filter the curated list of featured-plugin slugs.
812 *
813 * Plugin authors can prepend (or remove) entries to recommend their
814 * own Desktop-Mode-aware add-ons. Order is preserved — the first
815 * slug renders first in the gallery.
816 *
817 * @param string[] $slugs Plugin slugs.
818 */
819 $slugs = (array) apply_filters( 'desktop_mode_plugins_featured_slugs', $slugs );
820 $slugs = array_values(
821 array_unique(
822 array_filter(
823 array_map(
824 static function ( $s ) {
825 return sanitize_key( (string) $s );
826 },
827 $slugs
828 )
829 )
830 )
831 );
832 return $slugs;
833 }
834
835 /**
836 * `wp_ajax_desktop_mode_plugins_featured` — return the Featured tab's
837 * curated + auto-discovered list of plugins that integrate with Desktop
838 * Mode.
839 *
840 * Composition:
841 * 1. Curated slugs from `desktop_mode_plugins_window_featured_slugs()`,
842 * hydrated via `plugins_api( 'plugin_information' )` so the card
843 * payload is always fresh.
844 * 2. Auto-discovered slugs from `plugins_api( 'query_plugins' )` whose
845 * `requires_plugins` array contains `desktop-mode`. wp.org has no
846 * server-side filter for this today, so we run a broad query and
847 * filter server-side. Deduped against the curated set.
848 *
849 * Body params: (none)
850 *
851 * Cached for 1h. Failures cached for 15m so a flaky wp.org doesn't
852 * hammer the API on every tab open.
853 */
854 function desktop_mode_plugins_window_ajax_featured() {
855 $guard = desktop_mode_plugins_window_ajax_guard( 'install_plugins' );
856 if ( is_wp_error( $guard ) ) {
857 desktop_mode_plugins_window_ajax_error( $guard );
858 return;
859 }
860
861 if ( ! function_exists( 'plugins_api' ) ) {
862 require_once ABSPATH . 'wp-admin/includes/plugin-install.php';
863 }
864
865 $cache_key = 'dm_pwfeatured_v1';
866 $cached = get_transient( $cache_key );
867 if ( false !== $cached && is_array( $cached ) ) {
868 wp_send_json_success( $cached );
869 return;
870 }
871
872 $plugins = array();
873 $seen_slugs = array();
874 $fields = array(
875 'icons' => true,
876 'banners' => true,
877 'short_description' => true,
878 'description' => false,
879 'sections' => false,
880 'screenshots' => false,
881 'rating' => true,
882 'ratings' => false,
883 'num_ratings' => true,
884 'active_installs' => true,
885 'last_updated' => true,
886 'tested' => true,
887 'requires' => true,
888 'requires_php' => true,
889 'requires_plugins' => true,
890 'homepage' => true,
891 'compatibility' => false,
892 'group' => false,
893 'contributors' => false,
894 'donate_link' => false,
895 );
896
897 // ─── 1. Curated slugs ─────────────────────────────────────────────
898 $curated = desktop_mode_plugins_window_featured_slugs();
899 foreach ( $curated as $slug ) {
900 if ( isset( $seen_slugs[ $slug ] ) ) {
901 continue;
902 }
903 $info = plugins_api(
904 'plugin_information',
905 array(
906 'slug' => $slug,
907 'fields' => $fields,
908 )
909 );
910 if ( is_wp_error( $info ) || ! is_object( $info ) ) {
911 // Skip — a curated slug that 404s shouldn't tank the whole
912 // tab.
913 continue;
914 }
915 $row = (array) $info;
916 $row['featured'] = true;
917 $plugins[] = $row;
918 $seen_slugs[ $slug ] = true;
919 }
920
921 // ─── 2. Auto-discover via `requires_plugins` ──────────────────────
922 // Best-effort scan: pull the top of the directory and keep rows that
923 // declare desktop-mode as a dependency. The wp.org `query_plugins`
924 // API ignores `requires_plugins` as a filter, so we have to fetch +
925 // sift locally. Scope is intentionally small (100 most-popular rows)
926 // to keep the request bounded; as the ecosystem grows we'll widen
927 // or replace with a real dependency query when wp.org ships one.
928 $discovered = plugins_api(
929 'query_plugins',
930 array(
931 'browse' => 'popular',
932 'page' => 1,
933 'per_page' => 100,
934 'fields' => $fields,
935 )
936 );
937 if ( ! is_wp_error( $discovered ) && isset( $discovered->plugins ) && is_array( $discovered->plugins ) ) {
938 foreach ( $discovered->plugins as $candidate ) {
939 $candidate = (array) $candidate;
940 $slug = isset( $candidate['slug'] ) ? sanitize_key( (string) $candidate['slug'] ) : '';
941 if ( '' === $slug || isset( $seen_slugs[ $slug ] ) ) {
942 continue;
943 }
944 $requires = isset( $candidate['requires_plugins'] ) ? (array) $candidate['requires_plugins'] : array();
945 if ( ! in_array( 'desktop-mode', $requires, true ) ) {
946 continue;
947 }
948 $candidate['featured'] = false;
949 $plugins[] = $candidate;
950 $seen_slugs[ $slug ] = true;
951 }
952 }
953
954 // `count( $plugins ) - count( $curated )` can underflow when a
955 // curated slug fails hydration (slug typo, plugin temporarily
956 // delisted from wp.org, plugins_api returning WP_Error). The JS
957 // only uses `discovered` for informational headers, so a negative
958 // number wouldn't crash anything, but it does read as a bug.
959 // Clamp at zero so the count remains a defensible "non-curated rows
960 // in the payload."
961 $payload = array(
962 'plugins' => array_values( $plugins ),
963 'info' => array(
964 'curated' => count( $curated ),
965 'discovered' => max( 0, count( $plugins ) - count( $curated ) ),
966 'results' => count( $plugins ),
967 ),
968 );
969
970 /**
971 * Filter the Featured tab payload before it's cached + sent.
972 *
973 * Use this to inject server-side curated rows (e.g. premium /
974 * private plugins not on wp.org), or to enforce a hard cap on the
975 * response.
976 *
977 * @param array $payload `{ plugins: [...], info: {...} }`.
978 * @param array $curated Curated slug list.
979 */
980 $payload = (array) apply_filters(
981 'desktop_mode_plugins_featured_response',
982 $payload,
983 $curated
984 );
985
986 set_transient( $cache_key, $payload, HOUR_IN_SECONDS );
987 wp_send_json_success( $payload );
988 }
989 add_action( 'wp_ajax_desktop_mode_plugins_featured', 'desktop_mode_plugins_window_ajax_featured' );
990