| 1 |
<?php |
| 2 |
/** |
| 3 |
* OpenStation — Native Plugins Window: REST field decorators. |
| 4 |
* |
| 5 |
* Adds enrichment fields to Core's `/wp/v2/plugins` REST resource so |
| 6 |
* the JS bundle can render rich rows in one round-trip: |
| 7 |
* |
| 8 |
* - openstation_update_available — `{ available, new_version }` |
| 9 |
* - openstation_can_manage — `{ activate, deactivate, delete }` |
| 10 |
* - openstation_wporg_slug — .org directory slug, or null when not listed |
| 11 |
* - openstation_icon_url — local-folder icon, falling back to wp.org |
| 12 |
* - openstation_size_kb — disk size of plugin folder |
| 13 |
* - openstation_auto_update — `{ enabled, forced, supported }` |
| 14 |
* |
| 15 |
* Plugin Check posture: every callback below uses ONLY functions |
| 16 |
* available in `wp-includes/` (current_user_can, get_site_transient, |
| 17 |
* filesize, glob, …). No admin-only includes are needed, so REST is |
| 18 |
* the right home — registering these fields on `rest_api_init` keeps |
| 19 |
* the contract consistent with Core's other plugin REST decorators. |
| 20 |
* |
| 21 |
* @package OpenStation |
| 22 |
*/ |
| 23 |
|
| 24 |
defined( 'ABSPATH' ) || exit; |
| 25 |
|
| 26 |
/** |
| 27 |
* Register the six enrichment fields on the `plugin` REST resource. |
| 28 |
*/ |
| 29 |
function openstation_plugins_window_register_rest_fields() { |
| 30 |
register_rest_field( |
| 31 |
'plugin', |
| 32 |
'openstation_update_available', |
| 33 |
array( |
| 34 |
'get_callback' => 'openstation_plugins_window_field_update_available', |
| 35 |
'schema' => array( |
| 36 |
'description' => __( 'Whether an update is available for this plugin (and the available version).', 'desktop-mode' ), |
| 37 |
'type' => 'object', |
| 38 |
'context' => array( 'view', 'edit' ), |
| 39 |
'readonly' => true, |
| 40 |
), |
| 41 |
) |
| 42 |
); |
| 43 |
|
| 44 |
register_rest_field( |
| 45 |
'plugin', |
| 46 |
'openstation_can_manage', |
| 47 |
array( |
| 48 |
'get_callback' => 'openstation_plugins_window_field_can_manage', |
| 49 |
'schema' => array( |
| 50 |
'description' => __( 'Per-plugin capability flags for the requester (activate / deactivate / delete).', 'desktop-mode' ), |
| 51 |
'type' => 'object', |
| 52 |
'context' => array( 'view', 'edit' ), |
| 53 |
'readonly' => true, |
| 54 |
), |
| 55 |
) |
| 56 |
); |
| 57 |
|
| 58 |
register_rest_field( |
| 59 |
'plugin', |
| 60 |
'openstation_wporg_slug', |
| 61 |
array( |
| 62 |
'get_callback' => 'openstation_plugins_window_field_wporg_slug', |
| 63 |
'schema' => array( |
| 64 |
'description' => __( 'The plugin\'s slug on the WordPress.org directory, or null when the plugin is not listed there.', 'desktop-mode' ), |
| 65 |
'type' => array( 'string', 'null' ), |
| 66 |
'context' => array( 'view', 'edit' ), |
| 67 |
'readonly' => true, |
| 68 |
), |
| 69 |
) |
| 70 |
); |
| 71 |
|
| 72 |
register_rest_field( |
| 73 |
'plugin', |
| 74 |
'openstation_icon_url', |
| 75 |
array( |
| 76 |
'get_callback' => 'openstation_plugins_window_field_icon_url', |
| 77 |
'schema' => array( |
| 78 |
'description' => __( 'Best-effort card icon URL. Prefers a local file in the plugin folder, falling back to the wp.org SVN URL; null when neither resolves.', 'desktop-mode' ), |
| 79 |
'type' => array( 'string', 'null' ), |
| 80 |
'context' => array( 'view', 'edit' ), |
| 81 |
'readonly' => true, |
| 82 |
), |
| 83 |
) |
| 84 |
); |
| 85 |
|
| 86 |
register_rest_field( |
| 87 |
'plugin', |
| 88 |
'openstation_size_kb', |
| 89 |
array( |
| 90 |
'get_callback' => 'openstation_plugins_window_field_size_kb', |
| 91 |
'schema' => array( |
| 92 |
'description' => __( 'Approximate disk footprint of the plugin folder, in kilobytes (cached 6h).', 'desktop-mode' ), |
| 93 |
'type' => array( 'integer', 'null' ), |
| 94 |
'context' => array( 'view', 'edit' ), |
| 95 |
'readonly' => true, |
| 96 |
), |
| 97 |
) |
| 98 |
); |
| 99 |
|
| 100 |
register_rest_field( |
| 101 |
'plugin', |
| 102 |
'openstation_auto_update', |
| 103 |
array( |
| 104 |
'get_callback' => 'openstation_plugins_window_field_auto_update', |
| 105 |
'schema' => array( |
| 106 |
'description' => __( 'Auto-update state for this plugin (enabled / forced / supported), mirroring Core\'s plugins.php column.', 'desktop-mode' ), |
| 107 |
'type' => 'object', |
| 108 |
'context' => array( 'view', 'edit' ), |
| 109 |
'readonly' => true, |
| 110 |
), |
| 111 |
) |
| 112 |
); |
| 113 |
} |
| 114 |
add_action( 'rest_api_init', 'openstation_plugins_window_register_rest_fields' ); |
| 115 |
|
| 116 |
/** |
| 117 |
* Resolve the plugin file path (relative to `WP_PLUGIN_DIR`, ending in |
| 118 |
* `.php`) for a Core REST plugin row. |
| 119 |
* |
| 120 |
* Core's `WP_REST_Plugins_Controller::prepare_item_for_response` emits |
| 121 |
* the `plugin` field with the trailing `.php` STRIPPED — e.g. |
| 122 |
* `"elementor/elementor"` rather than `"elementor/elementor.php"`. But |
| 123 |
* every internal WordPress data structure that keys off the plugin |
| 124 |
* file — `update_plugins` site transient, `active_plugins` option, |
| 125 |
* `plugin_basename()`, `WP_PLUGIN_DIR` paths — uses the full filename. |
| 126 |
* Mixing the two yields silent lookup misses (the symptom that hid |
| 127 |
* the "Update available" tab). |
| 128 |
* |
| 129 |
* This helper re-appends `.php` when missing so callers can use the |
| 130 |
* result as a transient/option key or filesystem path directly. |
| 131 |
* |
| 132 |
* @param array $row Core REST plugin row. |
| 133 |
* @return string Plugin file (e.g. `"elementor/elementor.php"`), or `''` |
| 134 |
* when the row has no `plugin` field. |
| 135 |
*/ |
| 136 |
function openstation_plugins_window_row_plugin_file( $row ) { |
| 137 |
$file = isset( $row['plugin'] ) ? (string) $row['plugin'] : ''; |
| 138 |
if ( '' === $file ) { |
| 139 |
return ''; |
| 140 |
} |
| 141 |
if ( '.php' !== substr( $file, -4 ) ) { |
| 142 |
$file .= '.php'; |
| 143 |
} |
| 144 |
return $file; |
| 145 |
} |
| 146 |
|
| 147 |
/** |
| 148 |
* Lazily prime the `update_plugins` site transient so REST callers see |
| 149 |
* the same "updates available" picture as the classic Plugins screen. |
| 150 |
* |
| 151 |
* Core only refreshes the transient on `load-plugins.php`, |
| 152 |
* `load-update-core.php`, and the twice-daily cron — REST is not on |
| 153 |
* that list, so a fresh page load of the desktop Plugins window can |
| 154 |
* see an empty/stale transient even when the dock badge (computed |
| 155 |
* off `$menu`, which Core builds against `wp_get_update_data()`) |
| 156 |
* reports pending updates. We mirror Core's own throttle |
| 157 |
* (`wp-admin/includes/update.php::_maybe_update_plugins()` — 12h since |
| 158 |
* last check) so a hot REST hit is a transient read, not an HTTPS |
| 159 |
* round-trip to api.wordpress.org. |
| 160 |
* |
| 161 |
* Idempotent on its own (Core's 12h throttle); callers that hit this |
| 162 |
* many times per request should additionally guard with their own |
| 163 |
* static so they don't pay the transient-read overhead per row. |
| 164 |
* |
| 165 |
* @param bool $force When true, delete the transient and force a fresh |
| 166 |
* wp.org check regardless of the 12h throttle. |
| 167 |
*/ |
| 168 |
function openstation_plugins_window_maybe_refresh_update_transient( $force = false ) { |
| 169 |
/** |
| 170 |
* Short-circuit the lazy refresh of the `update_plugins` transient. |
| 171 |
* |
| 172 |
* Return `false` to skip the refresh — useful for hosts that run |
| 173 |
* their own update orchestration (managed WordPress, internal |
| 174 |
* mirrors) and don't want every REST hit to the plugins endpoint |
| 175 |
* to potentially trigger a wp.org check. The filter also gates the |
| 176 |
* explicit force-refresh path so hosts that block wp.org calls |
| 177 |
* outright keep that posture even when the user clicks Refresh. |
| 178 |
* |
| 179 |
* @param bool $refresh Whether to call `wp_update_plugins()`. |
| 180 |
* @param bool $force Whether the caller asked to bypass the throttle. |
| 181 |
*/ |
| 182 |
if ( ! apply_filters( 'openstation_plugins_window_refresh_updates', true, $force ) ) { |
| 183 |
return; |
| 184 |
} |
| 185 |
|
| 186 |
if ( ! function_exists( 'wp_update_plugins' ) ) { |
| 187 |
// `wp-includes/update.php` is normally autoloaded on every |
| 188 |
// request; guard anyway so an unusual bootstrap (mu-plugin |
| 189 |
// CLI harness, stripped-down REST runtime) doesn't fatal. |
| 190 |
return; |
| 191 |
} |
| 192 |
|
| 193 |
if ( $force ) { |
| 194 |
// Explicit user-initiated refresh — bypass the throttle. |
| 195 |
// Two steps: |
| 196 |
// 1. Delete the `update_plugins` site transient (and the |
| 197 |
// `plugins` cache group) via `wp_clean_plugins_cache()`, |
| 198 |
// OR fall back to `delete_site_transient()` directly when |
| 199 |
// the admin-side helper isn't loaded. |
| 200 |
// 2. Call `wp_update_plugins()` to repopulate the transient |
| 201 |
// with a fresh wp.org snapshot. Without step 2 the field |
| 202 |
// callback reads `false` for the rest of this request and |
| 203 |
// every row reports "no updates" — that's the exact |
| 204 |
// regression from the first cut of this fix (GH#202). |
| 205 |
if ( function_exists( 'wp_clean_plugins_cache' ) ) { |
| 206 |
wp_clean_plugins_cache( true ); |
| 207 |
} else { |
| 208 |
delete_site_transient( 'update_plugins' ); |
| 209 |
} |
| 210 |
wp_update_plugins(); |
| 211 |
return; |
| 212 |
} |
| 213 |
|
| 214 |
$current = get_site_transient( 'update_plugins' ); |
| 215 |
if ( |
| 216 |
is_object( $current ) && |
| 217 |
isset( $current->last_checked ) && |
| 218 |
12 * HOUR_IN_SECONDS > ( time() - (int) $current->last_checked ) |
| 219 |
) { |
| 220 |
// Inside Core's standard refresh window — trust the cached |
| 221 |
// snapshot, identical to `_maybe_update_plugins()`'s posture. |
| 222 |
return; |
| 223 |
} |
| 224 |
|
| 225 |
wp_update_plugins(); |
| 226 |
} |
| 227 |
|
| 228 |
/** |
| 229 |
* Detect whether the current REST request asked for an explicit |
| 230 |
* `update_plugins` refresh via `?openstation_force_refresh=1`. |
| 231 |
* |
| 232 |
* The flag is set by the in-window Refresh button (see |
| 233 |
* `fetchInstalledPlugins({ force: true })` in `src/plugins-window/rest.ts`) |
| 234 |
* and read from the query string on the way through Core's REST |
| 235 |
* dispatcher. Querystring is the canonical channel — the value is an |
| 236 |
* idempotent "use the slow path" hint, not a state-changing action, |
| 237 |
* so no additional nonce is required beyond REST's standard |
| 238 |
* `X-WP-Nonce` cookie-auth check. |
| 239 |
* |
| 240 |
* @return bool True when the request asked for a force-refresh. |
| 241 |
*/ |
| 242 |
function openstation_plugins_window_force_refresh_requested() { |
| 243 |
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only hint flag; REST auth is enforced separately. |
| 244 |
if ( ! isset( $_GET['openstation_force_refresh'] ) ) { |
| 245 |
return false; |
| 246 |
} |
| 247 |
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only hint flag; REST auth is enforced separately. |
| 248 |
$value = sanitize_text_field( wp_unslash( (string) $_GET['openstation_force_refresh'] ) ); |
| 249 |
return '1' === $value || 'true' === $value; |
| 250 |
} |
| 251 |
|
| 252 |
/** |
| 253 |
* Prime the `update_plugins` transient at most once per request. |
| 254 |
*/ |
| 255 |
function openstation_plugins_window_prime_updates_once() { |
| 256 |
static $primed = false; |
| 257 |
if ( $primed ) { |
| 258 |
return; |
| 259 |
} |
| 260 |
$primed = true; |
| 261 |
openstation_plugins_window_maybe_refresh_update_transient( |
| 262 |
openstation_plugins_window_force_refresh_requested() |
| 263 |
); |
| 264 |
} |
| 265 |
|
| 266 |
/** |
| 267 |
* `openstation_update_available` callback. |
| 268 |
* |
| 269 |
* @param array $row Core REST plugin row. |
| 270 |
* @return array{available:bool,new_version:string|null,package:string,slug:string} |
| 271 |
*/ |
| 272 |
function openstation_plugins_window_field_update_available( $row ) { |
| 273 |
$plugin_file = openstation_plugins_window_row_plugin_file( $row ); |
| 274 |
if ( '' === $plugin_file ) { |
| 275 |
return array( |
| 276 |
'available' => false, |
| 277 |
'new_version' => null, |
| 278 |
'package' => '', |
| 279 |
'slug' => '', |
| 280 |
); |
| 281 |
} |
| 282 |
|
| 283 |
// Prime the transient once per request before reading it — |
| 284 |
// otherwise REST callers see a stale/empty snapshot relative to |
| 285 |
// the classic Plugins screen and the dock update badge. When the |
| 286 |
// request carries `?openstation_force_refresh=1` the helper always |
| 287 |
// takes the slow path so the in-window Refresh button can actually |
| 288 |
// pull a fresh wp.org snapshot (the original throttle made it a |
| 289 |
// no-op within 12h of the last check — see GH#202). |
| 290 |
openstation_plugins_window_prime_updates_once(); |
| 291 |
|
| 292 |
// `update_plugins` is the canonical site-wide cache of pending |
| 293 |
// updates, refreshed by `wp_update_plugins()` on the standard |
| 294 |
// schedule. Reading it costs nothing. |
| 295 |
$updates = get_site_transient( 'update_plugins' ); |
| 296 |
if ( ! is_object( $updates ) || empty( $updates->response ) || ! is_array( $updates->response ) ) { |
| 297 |
return array( |
| 298 |
'available' => false, |
| 299 |
'new_version' => null, |
| 300 |
'package' => '', |
| 301 |
'slug' => '', |
| 302 |
); |
| 303 |
} |
| 304 |
|
| 305 |
if ( ! isset( $updates->response[ $plugin_file ] ) ) { |
| 306 |
return array( |
| 307 |
'available' => false, |
| 308 |
'new_version' => null, |
| 309 |
'package' => '', |
| 310 |
'slug' => '', |
| 311 |
); |
| 312 |
} |
| 313 |
|
| 314 |
$entry = $updates->response[ $plugin_file ]; |
| 315 |
$ver = is_object( $entry ) && isset( $entry->new_version ) |
| 316 |
? (string) $entry->new_version |
| 317 |
: null; |
| 318 |
// `package` is the download URL Core's upgrader hits to fetch the |
| 319 |
// new .zip. Empty for plugins that don't ship a wp.org package |
| 320 |
// (premium / private hosts) — Core renders an "Automatic update is |
| 321 |
// unavailable for this plugin" notice in that case rather than the |
| 322 |
// "Update now" link. We surface the URL so JS can apply the same |
| 323 |
// gating without needing a second round-trip. |
| 324 |
$package = is_object( $entry ) && ! empty( $entry->package ) |
| 325 |
? (string) $entry->package |
| 326 |
: ''; |
| 327 |
// `slug` is what Core's `wp_ajax_update_plugin` echoes back in its |
| 328 |
// success / error envelope. We forward what the transient already |
| 329 |
// carries; the AJAX handler doesn't require it on the request |
| 330 |
// side (it derives slug from `plugin`), but having it client-side |
| 331 |
// keeps event payloads symmetric with Core's own. |
| 332 |
$slug = is_object( $entry ) && ! empty( $entry->slug ) |
| 333 |
? (string) $entry->slug |
| 334 |
: ''; |
| 335 |
|
| 336 |
return array( |
| 337 |
'available' => true, |
| 338 |
'new_version' => $ver, |
| 339 |
'package' => $package, |
| 340 |
'slug' => $slug, |
| 341 |
); |
| 342 |
} |
| 343 |
|
| 344 |
/** |
| 345 |
* Count plugin updates visible to the Plugins window — i.e. updates in |
| 346 |
* the `update_plugins` site transient whose key corresponds to an |
| 347 |
* actually-installed plugin file (`get_plugins()`). |
| 348 |
* |
| 349 |
* Core's `wp_get_update_data()` reports `count( $update_plugins->response )` |
| 350 |
* verbatim, which is what `wp-admin/menu.php` embeds in the Plugins |
| 351 |
* menu title (the source the dock-builder regex captures). That raw |
| 352 |
* count can drift above the in-window "Update available" filter when |
| 353 |
* the transient holds orphan entries — rows for plugin files that no |
| 354 |
* longer exist on disk, or rows injected via the standard `Update URI` |
| 355 |
* mechanism that key on a file `get_plugins()` doesn't return. |
| 356 |
* |
| 357 |
* The Plugins window iterates `get_plugins()` via REST and shows each |
| 358 |
* row as updatable iff `update_plugins->response[ $plugin_file ]` is |
| 359 |
* set — exactly the intersection we compute here. Using this count for |
| 360 |
* the dock badge guarantees the two surfaces agree (GH#258). |
| 361 |
* |
| 362 |
* @return int Number of installed plugins with a pending update. |
| 363 |
*/ |
| 364 |
function openstation_plugins_window_count_visible_updates() { |
| 365 |
$updates = get_site_transient( 'update_plugins' ); |
| 366 |
if ( ! is_object( $updates ) || empty( $updates->response ) || ! is_array( $updates->response ) ) { |
| 367 |
return 0; |
| 368 |
} |
| 369 |
|
| 370 |
// `get_plugins()` lives in `wp-admin/includes/plugin.php`. Loaded by |
| 371 |
// default on every admin request (which is where `$menu` is built), |
| 372 |
// but require it explicitly so REST + cron + WP-CLI callers can use |
| 373 |
// this helper without depending on the admin runtime. |
| 374 |
if ( ! function_exists( 'get_plugins' ) ) { |
| 375 |
require_once ABSPATH . 'wp-admin/includes/plugin.php'; |
| 376 |
} |
| 377 |
$installed = get_plugins(); |
| 378 |
|
| 379 |
$count = 0; |
| 380 |
foreach ( array_keys( $updates->response ) as $plugin_file ) { |
| 381 |
if ( isset( $installed[ $plugin_file ] ) ) { |
| 382 |
++$count; |
| 383 |
} |
| 384 |
} |
| 385 |
return $count; |
| 386 |
} |
| 387 |
|
| 388 |
/** |
| 389 |
* `openstation_can_manage` callback. |
| 390 |
* |
| 391 |
* Per-row cap surface so the JS UI can hide actions the viewer can't |
| 392 |
* perform without re-deriving caps client-side. Server still |
| 393 |
* re-validates every mutation. |
| 394 |
* |
| 395 |
* @param array $row Core REST plugin row. |
| 396 |
* @return array{activate:bool,deactivate:bool,delete:bool} |
| 397 |
*/ |
| 398 |
function openstation_plugins_window_field_can_manage( $row ) { |
| 399 |
$status = isset( $row['status'] ) ? (string) $row['status'] : ''; |
| 400 |
|
| 401 |
$can_activate = current_user_can( 'activate_plugins' ); |
| 402 |
$can_delete = current_user_can( 'delete_plugins' ); |
| 403 |
|
| 404 |
// Active plugins can only be deleted after deactivation; surface |
| 405 |
// that constraint so the JS can dim the Delete action while the |
| 406 |
// row is active. |
| 407 |
$can_delete_now = $can_delete && 'inactive' === $status; |
| 408 |
|
| 409 |
return array( |
| 410 |
'activate' => $can_activate && 'inactive' === $status, |
| 411 |
'deactivate' => $can_activate && 'active' === $status, |
| 412 |
'delete' => $can_delete_now, |
| 413 |
); |
| 414 |
} |
| 415 |
|
| 416 |
/** |
| 417 |
* `openstation_wporg_slug` callback. |
| 418 |
* |
| 419 |
* Is this plugin listed on the WordPress.org directory, and under |
| 420 |
* which slug? |
| 421 |
* |
| 422 |
* This mirrors Core (see `WP_Plugins_List_Table::prepare_items()`). |
| 423 |
* |
| 424 |
* @param array $row Core REST plugin row. |
| 425 |
* @return string|null Directory slug, or null when the plugin isn't listed. |
| 426 |
*/ |
| 427 |
function openstation_plugins_window_field_wporg_slug( $row ) { |
| 428 |
$plugin_file = openstation_plugins_window_row_plugin_file( $row ); |
| 429 |
|
| 430 |
openstation_plugins_window_prime_updates_once(); |
| 431 |
|
| 432 |
$slug = ''; |
| 433 |
if ( '' !== $plugin_file ) { |
| 434 |
$updates = get_site_transient( 'update_plugins' ); |
| 435 |
if ( is_object( $updates ) ) { |
| 436 |
$entry = null; |
| 437 |
if ( isset( $updates->response[ $plugin_file ] ) ) { |
| 438 |
$entry = (array) $updates->response[ $plugin_file ]; |
| 439 |
} elseif ( isset( $updates->no_update[ $plugin_file ] ) ) { |
| 440 |
$entry = (array) $updates->no_update[ $plugin_file ]; |
| 441 |
} |
| 442 |
if ( null !== $entry && ! empty( $entry['slug'] ) ) { |
| 443 |
$slug = sanitize_key( (string) $entry['slug'] ); |
| 444 |
} |
| 445 |
} |
| 446 |
} |
| 447 |
|
| 448 |
return '' !== $slug ? $slug : null; |
| 449 |
} |
| 450 |
|
| 451 |
/** |
| 452 |
* `openstation_icon_url` callback. |
| 453 |
* |
| 454 |
* Resolves a card icon URL for an installed plugin row, in priority: |
| 455 |
* |
| 456 |
* 1. **Local file** — if the plugin's own folder ships an icon at a |
| 457 |
* conventional path (`assets/icon.svg`, `assets/icon-256x256.png`, |
| 458 |
* `assets/icon-128x128.png`, or the same names at the folder |
| 459 |
* root), return its `plugins_url()`. This is what makes premium / |
| 460 |
* internal / native-bundled plugins (alcazaba-*, os-*, |
| 461 |
* and any private plugin that ships its own art) display |
| 462 |
* correctly — they aren't on `ps.w.org/<slug>/`, so the wp.org |
| 463 |
* candidate chain 404s through every variant before the |
| 464 |
* placeholder paints. |
| 465 |
* 2. **wp.org SVN asset** — `https://ps.w.org/<slug>/assets/icon.svg`, |
| 466 |
* keyed off the plugin's **folder name** (which is the .org repo |
| 467 |
* slug). Folder beats textdomain because the two often diverge |
| 468 |
* (`woocommerce` vs textdomain `woo`, `wordpress-seo` vs |
| 469 |
* `yoast-seo`). Falls back to textdomain for single-file plugins. |
| 470 |
* |
| 471 |
* We don't HEAD-check the URL — the JS card walks a candidate chain |
| 472 |
* (SVG → 256 PNG → 256 GIF → 128 PNG → 128 GIF) on `<img>` error for wp.org URLs, then |
| 473 |
* drops to a `<os-icon name="dashicons-admin-plugins">` placeholder. |
| 474 |
* A 404 here costs nothing. |
| 475 |
* |
| 476 |
* @param array $row Core REST plugin row. |
| 477 |
* @return string|null |
| 478 |
*/ |
| 479 |
function openstation_plugins_window_field_icon_url( $row ) { |
| 480 |
$plugin_file = openstation_plugins_window_row_plugin_file( $row ); |
| 481 |
$folder = '' !== $plugin_file ? dirname( $plugin_file ) : ''; |
| 482 |
$slug = ( '' !== $folder && '.' !== $folder ) ? $folder : ''; |
| 483 |
|
| 484 |
if ( '' === $slug ) { |
| 485 |
// Single-file plugin (e.g. hello.php at the plugins root) — |
| 486 |
// no folder slug, so fall back to the text domain. |
| 487 |
$slug = isset( $row['textdomain'] ) ? (string) $row['textdomain'] : ''; |
| 488 |
} |
| 489 |
|
| 490 |
$slug = sanitize_key( $slug ); |
| 491 |
if ( '' === $slug ) { |
| 492 |
return null; |
| 493 |
} |
| 494 |
|
| 495 |
$default = openstation_plugins_window_local_icon_url( $plugin_file ); |
| 496 |
if ( null === $default ) { |
| 497 |
$default = 'https://ps.w.org/' . $slug . '/assets/icon.svg'; |
| 498 |
} |
| 499 |
|
| 500 |
/** |
| 501 |
* Filter the resolved icon URL for a plugin row. |
| 502 |
* |
| 503 |
* Return `null` to suppress the icon (forces the placeholder). |
| 504 |
* Return a different URL to override the default — useful for |
| 505 |
* custom CDN art or for overriding the auto-detected local icon. |
| 506 |
* |
| 507 |
* The `$url` parameter is either a local `plugins_url()` (when the |
| 508 |
* plugin's own folder ships an icon at a conventional path) or the |
| 509 |
* wp.org `ps.w.org/<slug>/assets/icon.svg` URL. The JS receiver |
| 510 |
* walks a candidate chain on `<img>` error (`icon.svg` → 256 PNG → |
| 511 |
* 256 GIF → 128 PNG → 128 GIF) only when the URL matches the |
| 512 |
* wp.org SVN pattern; |
| 513 |
* custom URLs and local URLs are one-shot, then placeholder. |
| 514 |
* |
| 515 |
* @param string|null $url Default URL (local file if the plugin's |
| 516 |
* folder ships one, else wp.org SVG). |
| 517 |
* @param string $slug Plugin slug (folder name, or textdomain |
| 518 |
* for single-file plugins). |
| 519 |
* @param array $row Core REST plugin row. |
| 520 |
*/ |
| 521 |
return apply_filters( |
| 522 |
'openstation_plugins_window_icon_url', |
| 523 |
$default, |
| 524 |
$slug, |
| 525 |
$row |
| 526 |
); |
| 527 |
} |
| 528 |
|
| 529 |
/** |
| 530 |
* Probe an installed plugin's own folder for a card icon. |
| 531 |
* |
| 532 |
* Many premium and private plugins (and our own native extensions — |
| 533 |
* alcazaba-*, os-*) aren't on the .org repo, so the wp.org |
| 534 |
* SVN URL 404s through every candidate before the placeholder paints. |
| 535 |
* Most that ship art do so at a conventional location inside their |
| 536 |
* own folder — typically `assets/icon.svg` mirroring the wp.org SVN |
| 537 |
* /assets/ layout, occasionally bare `icon.svg` at the root for |
| 538 |
* minimal plugins. We probe both shapes and return the first URL we |
| 539 |
* resolve, or `null` when nothing matches. |
| 540 |
* |
| 541 |
* Single-file plugins (no folder) return `null` immediately — there's |
| 542 |
* no folder to scan. |
| 543 |
* |
| 544 |
* Cost: 1–6 `file_exists()` calls per row, ~1µs each with warm OS |
| 545 |
* cache. For a 50-row paint this is well under a millisecond — not |
| 546 |
* worth caching, and a cache would have to invalidate on plugin |
| 547 |
* install/update/delete. |
| 548 |
* |
| 549 |
* The candidate list is filterable via |
| 550 |
* `openstation_plugins_window_local_icon_candidates` so a host can |
| 551 |
* support a custom convention (e.g. an `icon@2x.svg` shape). |
| 552 |
* |
| 553 |
* @param string $plugin_file Plugin file (e.g. `"akismet/akismet.php"`). |
| 554 |
* @return string|null URL of the first local icon found, or null. |
| 555 |
*/ |
| 556 |
function openstation_plugins_window_local_icon_url( $plugin_file ) { |
| 557 |
if ( '' === $plugin_file ) { |
| 558 |
return null; |
| 559 |
} |
| 560 |
$folder = dirname( $plugin_file ); |
| 561 |
if ( '' === $folder || '.' === $folder ) { |
| 562 |
// Single-file plugin — no folder to scan. |
| 563 |
return null; |
| 564 |
} |
| 565 |
|
| 566 |
/** |
| 567 |
* Filter the ordered list of relative paths probed inside an |
| 568 |
* installed plugin's folder when looking for a card icon. The |
| 569 |
* first existing file wins; later entries are ignored. |
| 570 |
* |
| 571 |
* @param string[] $candidates Relative paths under the plugin folder. |
| 572 |
* @param string $folder Plugin folder name (e.g. `"akismet"`). |
| 573 |
*/ |
| 574 |
$candidates = apply_filters( |
| 575 |
'openstation_plugins_window_local_icon_candidates', |
| 576 |
array( |
| 577 |
'assets/icon.svg', |
| 578 |
'assets/icon-256x256.png', |
| 579 |
'assets/icon-128x128.png', |
| 580 |
'icon.svg', |
| 581 |
'icon-256x256.png', |
| 582 |
'icon-128x128.png', |
| 583 |
), |
| 584 |
$folder |
| 585 |
); |
| 586 |
|
| 587 |
$plugin_root = WP_PLUGIN_DIR . '/' . $folder; |
| 588 |
foreach ( (array) $candidates as $relative ) { |
| 589 |
$relative = (string) $relative; |
| 590 |
if ( '' === $relative ) { |
| 591 |
continue; |
| 592 |
} |
| 593 |
if ( file_exists( $plugin_root . '/' . $relative ) ) { |
| 594 |
return plugins_url( $relative, WP_PLUGIN_DIR . '/' . $plugin_file ); |
| 595 |
} |
| 596 |
} |
| 597 |
|
| 598 |
return null; |
| 599 |
} |
| 600 |
|
| 601 |
/** |
| 602 |
* `openstation_size_kb` callback. Caches per-plugin for 6 hours so |
| 603 |
* a 50-row table doesn't `glob`+`filesize` 50 directories on every |
| 604 |
* fetch. Returns `null` when the folder can't be read. |
| 605 |
* |
| 606 |
* @param array $row Core REST plugin row. |
| 607 |
* @return int|null Size in kilobytes, or null on failure. |
| 608 |
*/ |
| 609 |
function openstation_plugins_window_field_size_kb( $row ) { |
| 610 |
$plugin_file = openstation_plugins_window_row_plugin_file( $row ); |
| 611 |
if ( '' === $plugin_file ) { |
| 612 |
return null; |
| 613 |
} |
| 614 |
|
| 615 |
// `WP_PLUGIN_DIR` is defined in `wp-includes/default-constants.php` |
| 616 |
// — safe to reference anywhere. |
| 617 |
$plugin_dir = WP_PLUGIN_DIR; |
| 618 |
$root = $plugin_dir . '/' . dirname( $plugin_file ); |
| 619 |
if ( '.' === dirname( $plugin_file ) || ! is_dir( $root ) ) { |
| 620 |
// Single-file plugins (e.g. hello.php at the root of plugins/). |
| 621 |
$candidate = $plugin_dir . '/' . $plugin_file; |
| 622 |
if ( is_file( $candidate ) ) { |
| 623 |
$bytes = (int) filesize( $candidate ); |
| 624 |
return $bytes > 0 ? max( 1, (int) round( $bytes / 1024 ) ) : 0; |
| 625 |
} |
| 626 |
return null; |
| 627 |
} |
| 628 |
|
| 629 |
$cache_key = 'dm_pwsz_' . md5( $plugin_file ); |
| 630 |
$cached = get_transient( $cache_key ); |
| 631 |
if ( false !== $cached && is_int( $cached ) ) { |
| 632 |
return $cached; |
| 633 |
} |
| 634 |
|
| 635 |
$kb = openstation_plugins_window_compute_dir_size_kb( $root ); |
| 636 |
set_transient( $cache_key, $kb, 6 * HOUR_IN_SECONDS ); |
| 637 |
return $kb; |
| 638 |
} |
| 639 |
|
| 640 |
/** |
| 641 |
* Recursively sum file sizes under `$dir`, returning kilobytes. |
| 642 |
* |
| 643 |
* Caps total iteration to 5,000 entries so a pathological symlink |
| 644 |
* loop (or an enormous plugin folder full of vendor cruft) can't |
| 645 |
* stall a REST response. When the cap trips we return whatever we |
| 646 |
* counted so far — a slight under-report is better than a hung |
| 647 |
* request. |
| 648 |
* |
| 649 |
* @param string $dir Absolute filesystem path. |
| 650 |
* @return int Kilobytes (rounded). |
| 651 |
*/ |
| 652 |
function openstation_plugins_window_compute_dir_size_kb( $dir ) { |
| 653 |
if ( ! is_dir( $dir ) ) { |
| 654 |
return 0; |
| 655 |
} |
| 656 |
|
| 657 |
$total_bytes = 0; |
| 658 |
$visited = 0; |
| 659 |
$max_visit = 5000; |
| 660 |
|
| 661 |
$stack = array( $dir ); |
| 662 |
while ( ! empty( $stack ) && $visited < $max_visit ) { |
| 663 |
$current = array_pop( $stack ); |
| 664 |
$entries = @scandir( $current ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- best-effort, errors fall back to null. |
| 665 |
if ( ! is_array( $entries ) ) { |
| 666 |
continue; |
| 667 |
} |
| 668 |
foreach ( $entries as $entry ) { |
| 669 |
if ( '.' === $entry || '..' === $entry ) { |
| 670 |
continue; |
| 671 |
} |
| 672 |
$path = $current . '/' . $entry; |
| 673 |
if ( is_link( $path ) ) { |
| 674 |
// Skip symlinks: they could escape the plugin folder |
| 675 |
// or recurse infinitely. The classic admin's plugin |
| 676 |
// list ignores symlink contents for the same reason. |
| 677 |
continue; |
| 678 |
} |
| 679 |
++$visited; |
| 680 |
if ( $visited >= $max_visit ) { |
| 681 |
break 2; |
| 682 |
} |
| 683 |
if ( is_dir( $path ) ) { |
| 684 |
$stack[] = $path; |
| 685 |
} elseif ( is_file( $path ) ) { |
| 686 |
$total_bytes += (int) filesize( $path ); |
| 687 |
} |
| 688 |
} |
| 689 |
} |
| 690 |
|
| 691 |
return $total_bytes > 0 ? max( 1, (int) round( $total_bytes / 1024 ) ) : 0; |
| 692 |
} |
| 693 |
|
| 694 |
/** |
| 695 |
* `openstation_auto_update` callback. |
| 696 |
* |
| 697 |
* Mirrors the per-row state Core derives in |
| 698 |
* `WP_Plugins_List_Table::prepare_items()` for its "Automatic Updates" |
| 699 |
* column. Shape: |
| 700 |
* |
| 701 |
* - `enabled` bool — the plugin file is currently in the |
| 702 |
* `auto_update_plugins` site option, OR a |
| 703 |
* filter has forced auto-updates on. |
| 704 |
* - `forced` bool|null — `true`/`false` when the |
| 705 |
* `auto_update_plugin` filter pinned the state, |
| 706 |
* `null` when the user is free to toggle. |
| 707 |
* - `supported` bool — whether the `update_plugins` transient has an |
| 708 |
* entry for this plugin (either in `response` or |
| 709 |
* `no_update`). Core hides the toggle entirely |
| 710 |
* when this is false — premium / private plugins |
| 711 |
* that never check in with wp.org. |
| 712 |
* |
| 713 |
* NOT included here (lives on the window config instead): the global |
| 714 |
* `wp_is_auto_update_enabled_for_type( 'plugin' )` flag, which depends |
| 715 |
* on admin-only includes — see `openstation_plugins_window_auto_updates_enabled()`. |
| 716 |
* |
| 717 |
* @param array $row Core REST plugin row. |
| 718 |
* @return array{enabled:bool,forced:bool|null,supported:bool} |
| 719 |
*/ |
| 720 |
function openstation_plugins_window_field_auto_update( $row ) { |
| 721 |
$plugin_file = openstation_plugins_window_row_plugin_file( $row ); |
| 722 |
if ( '' === $plugin_file ) { |
| 723 |
return array( |
| 724 |
'enabled' => false, |
| 725 |
'forced' => null, |
| 726 |
'supported' => false, |
| 727 |
); |
| 728 |
} |
| 729 |
|
| 730 |
$auto_updates = (array) get_site_option( 'auto_update_plugins', array() ); |
| 731 |
$enabled = in_array( $plugin_file, $auto_updates, true ); |
| 732 |
|
| 733 |
// `update-supported` mirrors Core's logic: a plugin is "supported" |
| 734 |
// for auto-update toggling when wp.org has either a pending update |
| 735 |
// row OR an explicit no-update row in the `update_plugins` transient. |
| 736 |
// Premium / private plugins that never call home land in neither |
| 737 |
// bucket — Core hides the toggle so the user doesn't enable an |
| 738 |
// auto-update that can't ever fire. |
| 739 |
$supported = false; |
| 740 |
$updates = get_site_transient( 'update_plugins' ); |
| 741 |
if ( is_object( $updates ) ) { |
| 742 |
if ( isset( $updates->response[ $plugin_file ] ) || isset( $updates->no_update[ $plugin_file ] ) ) { |
| 743 |
$supported = true; |
| 744 |
} |
| 745 |
} |
| 746 |
|
| 747 |
// Build the payload Core's filter expects (mirrors |
| 748 |
// `WP_Plugins_List_Table::prepare_items()`'s `$filter_payload`). |
| 749 |
// `wp_is_auto_update_forced_for_item()` itself is in |
| 750 |
// `wp-admin/includes/update.php` — we can't include that from a REST |
| 751 |
// callback (Plugin Check), so we run the filter directly. It's a |
| 752 |
// single `apply_filters()` call under the hood. |
| 753 |
// |
| 754 |
// Important: `wp_parse_args( $row, $defaults )` lets `$row` keys |
| 755 |
// override `$defaults`. Core's REST controller strips `.php` from |
| 756 |
// the `plugin` field, but every filter that hooks `auto_update_plugin` |
| 757 |
// (including Core's own) reads `$item->plugin` expecting the FULL |
| 758 |
// filename. We layer the normalized `$plugin_file` AFTER the parse |
| 759 |
// so it always wins. |
| 760 |
$filter_payload = wp_parse_args( |
| 761 |
$row, |
| 762 |
array( |
| 763 |
'id' => $plugin_file, |
| 764 |
'slug' => isset( $row['textdomain'] ) ? (string) $row['textdomain'] : '', |
| 765 |
'plugin' => $plugin_file, |
| 766 |
'new_version' => '', |
| 767 |
'url' => '', |
| 768 |
'package' => '', |
| 769 |
'icons' => array(), |
| 770 |
'banners' => array(), |
| 771 |
'banners_rtl' => array(), |
| 772 |
'tested' => '', |
| 773 |
'requires_php' => '', |
| 774 |
'compatibility' => new stdClass(), |
| 775 |
) |
| 776 |
); |
| 777 |
$filter_payload['plugin'] = $plugin_file; |
| 778 |
$filter_payload['id'] = $plugin_file; |
| 779 |
$filter_payload = (object) $filter_payload; |
| 780 |
/** This filter is documented in wp-admin/includes/class-wp-automatic-updater.php */ |
| 781 |
$forced = apply_filters( 'auto_update_plugin', null, $filter_payload ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Core's filter; the effective auto-update state has to come from the same source Core reads. |
| 782 |
if ( null !== $forced ) { |
| 783 |
$forced = (bool) $forced; |
| 784 |
// When a filter forces the state, that's the effective state |
| 785 |
// regardless of the `auto_update_plugins` option — match Core's |
| 786 |
// rendering in `single_row_columns()`. |
| 787 |
$enabled = $forced; |
| 788 |
} |
| 789 |
|
| 790 |
return array( |
| 791 |
'enabled' => (bool) $enabled, |
| 792 |
'forced' => $forced, |
| 793 |
'supported' => $supported, |
| 794 |
); |
| 795 |
} |
| 796 |
|