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

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