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

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