PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 0.9.2
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v0.9.2
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.2, at includes/plugins-window/ajax.php

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