| 1 |
<?php |
| 2 |
/** |
| 3 |
* Plugins app — the REST fields on Core's plugin resource. |
| 4 |
* |
| 5 |
* Part of the `desktop-mode-plugins` app: required by `plugins.os.php`, |
| 6 |
* plain `.php` on purpose — only `*.os.php` files are app entries to |
| 7 |
* the framework loader. Registers the enrichment fields on Core's |
| 8 |
* `/wp/v2/plugins` resource so the app's `data()` (an in-process read |
| 9 |
* of that collection) renders rich rows in one round trip, and owns |
| 10 |
* the two that need no wp.org data: the per-row capability flags and |
| 11 |
* the folder size. The update-derived fields are `updates.php`, the |
| 12 |
* icon is `icons.php`. |
| 13 |
* |
| 14 |
* - openstation_update_available — `{ available, new_version, package, slug }` |
| 15 |
* - openstation_can_manage — `{ activate, deactivate, delete }` |
| 16 |
* - openstation_wporg_slug — .org directory slug, or null when not listed |
| 17 |
* - openstation_icon_url — local-folder icon, falling back to wp.org |
| 18 |
* - openstation_size_kb — disk size of the plugin folder |
| 19 |
* - openstation_auto_update — `{ enabled, forced, supported }` |
| 20 |
* |
| 21 |
* Every callback uses only `wp-includes/` functions, so REST is the |
| 22 |
* right home — registering on `rest_api_init` keeps the contract |
| 23 |
* consistent with Core's other plugin REST decorators. |
| 24 |
* |
| 25 |
* @package OpenStation |
| 26 |
*/ |
| 27 |
|
| 28 |
// Direct access, unless a standalone host is booting on bare PHP. |
| 29 |
if ( ! defined( 'ABSPATH' ) ) { |
| 30 |
defined( 'OPENSTATION_STANDALONE' ) || exit; |
| 31 |
} |
| 32 |
|
| 33 |
/** The per-request cache group the field callbacks memoise in. */ |
| 34 |
const OPENSTATION_PLUGINS_CACHE_GROUP = 'desktop-mode-plugins'; |
| 35 |
|
| 36 |
/** The transient holding every plugin folder's size, `{ file => kb }`. */ |
| 37 |
const OPENSTATION_PLUGINS_SIZE_TRANSIENT = 'dm_pwsz_map'; |
| 38 |
|
| 39 |
/** |
| 40 |
* Register the six enrichment fields on the `plugin` REST resource. |
| 41 |
*/ |
| 42 |
function openstation_plugins_window_register_rest_fields() { |
| 43 |
$fields = array( |
| 44 |
'openstation_update_available' => array( |
| 45 |
'callback' => 'openstation_plugins_window_field_update_available', |
| 46 |
'description' => __( 'Whether an update is available for this plugin (and the available version).', 'desktop-mode' ), |
| 47 |
'type' => 'object', |
| 48 |
), |
| 49 |
'openstation_can_manage' => array( |
| 50 |
'callback' => 'openstation_plugins_window_field_can_manage', |
| 51 |
'description' => __( 'Per-plugin capability flags for the requester (activate / deactivate / delete).', 'desktop-mode' ), |
| 52 |
'type' => 'object', |
| 53 |
), |
| 54 |
'openstation_wporg_slug' => array( |
| 55 |
'callback' => 'openstation_plugins_window_field_wporg_slug', |
| 56 |
'description' => __( 'The plugin\'s slug on the WordPress.org directory, or null when the plugin is not listed there.', 'desktop-mode' ), |
| 57 |
'type' => array( 'string', 'null' ), |
| 58 |
), |
| 59 |
'openstation_icon_url' => array( |
| 60 |
'callback' => 'openstation_plugins_window_field_icon_url', |
| 61 |
'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' ), |
| 62 |
'type' => array( 'string', 'null' ), |
| 63 |
), |
| 64 |
'openstation_size_kb' => array( |
| 65 |
'callback' => 'openstation_plugins_window_field_size_kb', |
| 66 |
'description' => __( 'Approximate disk footprint of the plugin folder, in kilobytes (cached 6h).', 'desktop-mode' ), |
| 67 |
'type' => array( 'integer', 'null' ), |
| 68 |
), |
| 69 |
'openstation_auto_update' => array( |
| 70 |
'callback' => 'openstation_plugins_window_field_auto_update', |
| 71 |
'description' => __( 'Auto-update state for this plugin (enabled / forced / supported), mirroring Core\'s plugins.php column.', 'desktop-mode' ), |
| 72 |
'type' => 'object', |
| 73 |
), |
| 74 |
); |
| 75 |
foreach ( $fields as $name => $field ) { |
| 76 |
register_rest_field( |
| 77 |
'plugin', |
| 78 |
$name, |
| 79 |
array( |
| 80 |
'get_callback' => $field['callback'], |
| 81 |
'schema' => array( |
| 82 |
'description' => $field['description'], |
| 83 |
'type' => $field['type'], |
| 84 |
'context' => array( 'view', 'edit' ), |
| 85 |
'readonly' => true, |
| 86 |
), |
| 87 |
) |
| 88 |
); |
| 89 |
} |
| 90 |
} |
| 91 |
add_action( 'rest_api_init', 'openstation_plugins_window_register_rest_fields' ); |
| 92 |
|
| 93 |
/** |
| 94 |
* Resolve the plugin file path (relative to `WP_PLUGIN_DIR`, ending in |
| 95 |
* `.php`) for a Core REST plugin row. |
| 96 |
* |
| 97 |
* Core's `WP_REST_Plugins_Controller::prepare_item_for_response` emits |
| 98 |
* the `plugin` field with the trailing `.php` STRIPPED — e.g. |
| 99 |
* `"elementor/elementor"` — while every internal WordPress structure |
| 100 |
* that keys off the plugin file (the `update_plugins` transient, the |
| 101 |
* `active_plugins` option, `plugin_basename()`, `WP_PLUGIN_DIR` paths) |
| 102 |
* uses the full filename. Mixing the two yields silent lookup misses. |
| 103 |
* |
| 104 |
* @param array $row Core REST plugin row. |
| 105 |
* @return string Plugin file (e.g. `"elementor/elementor.php"`), or `''` |
| 106 |
* when the row has no `plugin` field. |
| 107 |
*/ |
| 108 |
function openstation_plugins_window_row_plugin_file( $row ) { |
| 109 |
$file = isset( $row['plugin'] ) ? (string) $row['plugin'] : ''; |
| 110 |
if ( '' === $file ) { |
| 111 |
return ''; |
| 112 |
} |
| 113 |
if ( '.php' !== substr( $file, -4 ) ) { |
| 114 |
$file .= '.php'; |
| 115 |
} |
| 116 |
return $file; |
| 117 |
} |
| 118 |
|
| 119 |
/** |
| 120 |
* `openstation_can_manage` callback. |
| 121 |
* |
| 122 |
* Per-row cap surface so the client can hide actions the viewer can't |
| 123 |
* perform without re-deriving caps. The server still re-validates |
| 124 |
* every mutation. A network-activated plugin (`network-active`, a |
| 125 |
* status only a network has) is deactivated from the network admin — |
| 126 |
* Core's site screen offers no action on it, and neither does this |
| 127 |
* window unless the viewer manages network plugins. |
| 128 |
* |
| 129 |
* @param array $row Core REST plugin row. |
| 130 |
* @return array{activate:bool,deactivate:bool,delete:bool} |
| 131 |
*/ |
| 132 |
function openstation_plugins_window_field_can_manage( $row ) { |
| 133 |
static $caps = null; |
| 134 |
if ( null === $caps || ! isset( $caps['user'] ) || get_current_user_id() !== $caps['user'] ) { |
| 135 |
$caps = array( |
| 136 |
'user' => get_current_user_id(), |
| 137 |
'activate' => current_user_can( 'activate_plugins' ), |
| 138 |
// Never on multisite: plugin files are network-wide, and |
| 139 |
// Core's site screens offer no delete — see the note in |
| 140 |
// `openstation_plugins_window_caps()`. |
| 141 |
'delete' => ! is_multisite() && current_user_can( 'delete_plugins' ), |
| 142 |
'network' => current_user_can( 'manage_network_plugins' ), |
| 143 |
); |
| 144 |
} |
| 145 |
$status = isset( $row['status'] ) ? (string) $row['status'] : ''; |
| 146 |
|
| 147 |
$deactivate = false; |
| 148 |
if ( 'active' === $status ) { |
| 149 |
$deactivate = $caps['activate']; |
| 150 |
} elseif ( 'network-active' === $status ) { |
| 151 |
$deactivate = $caps['activate'] && $caps['network']; |
| 152 |
} |
| 153 |
|
| 154 |
return array( |
| 155 |
'activate' => $caps['activate'] && 'inactive' === $status, |
| 156 |
'deactivate' => $deactivate, |
| 157 |
// Active plugins can only be deleted after deactivation. |
| 158 |
'delete' => $caps['delete'] && 'inactive' === $status, |
| 159 |
); |
| 160 |
} |
| 161 |
|
| 162 |
/** |
| 163 |
* `openstation_size_kb` callback. Every folder's size lives in ONE |
| 164 |
* transient (`{ file => kb }`, 6h) read once per request, so a 50-row |
| 165 |
* table costs one option read rather than fifty; a folder missing |
| 166 |
* from the map is measured and the map written back on shutdown. |
| 167 |
* Returns `null` when the folder can't be read. |
| 168 |
* |
| 169 |
* @param array $row Core REST plugin row. |
| 170 |
* @return int|null Size in kilobytes, or null on failure. |
| 171 |
*/ |
| 172 |
function openstation_plugins_window_field_size_kb( $row ) { |
| 173 |
$plugin_file = openstation_plugins_window_row_plugin_file( $row ); |
| 174 |
if ( '' === $plugin_file ) { |
| 175 |
return null; |
| 176 |
} |
| 177 |
|
| 178 |
$root = WP_PLUGIN_DIR . '/' . dirname( $plugin_file ); |
| 179 |
if ( '.' === dirname( $plugin_file ) || ! is_dir( $root ) ) { |
| 180 |
// Single-file plugins (e.g. hello.php at the root of plugins/). |
| 181 |
$candidate = WP_PLUGIN_DIR . '/' . $plugin_file; |
| 182 |
if ( is_file( $candidate ) ) { |
| 183 |
$bytes = (int) filesize( $candidate ); |
| 184 |
return $bytes > 0 ? max( 1, (int) round( $bytes / 1024 ) ) : 0; |
| 185 |
} |
| 186 |
return null; |
| 187 |
} |
| 188 |
|
| 189 |
$map = openstation_plugins_window_size_map(); |
| 190 |
if ( isset( $map[ $plugin_file ] ) && is_int( $map[ $plugin_file ] ) ) { |
| 191 |
return $map[ $plugin_file ]; |
| 192 |
} |
| 193 |
|
| 194 |
$kb = openstation_plugins_window_compute_dir_size_kb( $root ); |
| 195 |
openstation_plugins_window_size_map( $plugin_file, $kb ); |
| 196 |
return $kb; |
| 197 |
} |
| 198 |
|
| 199 |
/** |
| 200 |
* The size map, read from its transient once per request; with a file |
| 201 |
* and a size, record that entry and schedule the write-back. |
| 202 |
* |
| 203 |
* @param string $plugin_file Optional. A plugin file to record. |
| 204 |
* @param int|null $kb Optional. Its size in kilobytes. |
| 205 |
* @return array<string,int> The map as of now. |
| 206 |
*/ |
| 207 |
function openstation_plugins_window_size_map( $plugin_file = '', $kb = null ) { |
| 208 |
static $map = null; |
| 209 |
static $dirty = false; |
| 210 |
if ( null === $map ) { |
| 211 |
$stored = get_transient( OPENSTATION_PLUGINS_SIZE_TRANSIENT ); |
| 212 |
$map = is_array( $stored ) ? $stored : array(); |
| 213 |
} |
| 214 |
if ( '' !== $plugin_file && null !== $kb ) { |
| 215 |
$map[ $plugin_file ] = (int) $kb; |
| 216 |
if ( ! $dirty ) { |
| 217 |
$dirty = true; |
| 218 |
add_action( |
| 219 |
'shutdown', |
| 220 |
static function () use ( &$map ) { |
| 221 |
set_transient( OPENSTATION_PLUGINS_SIZE_TRANSIENT, $map, 6 * HOUR_IN_SECONDS ); |
| 222 |
} |
| 223 |
); |
| 224 |
} |
| 225 |
} |
| 226 |
return $map; |
| 227 |
} |
| 228 |
|
| 229 |
/** |
| 230 |
* Drop the size map after an install, update or delete changed a |
| 231 |
* folder — the next paint measures it again. |
| 232 |
* |
| 233 |
* @return void |
| 234 |
*/ |
| 235 |
function openstation_plugins_window_forget_sizes() { |
| 236 |
delete_transient( OPENSTATION_PLUGINS_SIZE_TRANSIENT ); |
| 237 |
} |
| 238 |
add_action( 'deleted_plugin', 'openstation_plugins_window_forget_sizes' ); |
| 239 |
add_action( |
| 240 |
'upgrader_process_complete', |
| 241 |
static function ( $upgrader, $hook_extra ) { |
| 242 |
if ( is_array( $hook_extra ) && isset( $hook_extra['type'] ) && 'plugin' === $hook_extra['type'] ) { |
| 243 |
openstation_plugins_window_forget_sizes(); |
| 244 |
} |
| 245 |
}, |
| 246 |
10, |
| 247 |
2 |
| 248 |
); |
| 249 |
|
| 250 |
/** |
| 251 |
* Recursively sum file sizes under `$dir`, returning kilobytes. |
| 252 |
* |
| 253 |
* Caps total iteration to 5,000 entries so a pathological symlink |
| 254 |
* loop (or an enormous plugin folder full of vendor cruft) can't |
| 255 |
* stall a REST response. When the cap trips we return whatever we |
| 256 |
* counted so far — a slight under-report is better than a hung |
| 257 |
* request. |
| 258 |
* |
| 259 |
* @param string $dir Absolute filesystem path. |
| 260 |
* @return int Kilobytes (rounded). |
| 261 |
*/ |
| 262 |
function openstation_plugins_window_compute_dir_size_kb( $dir ) { |
| 263 |
if ( ! is_dir( $dir ) ) { |
| 264 |
return 0; |
| 265 |
} |
| 266 |
|
| 267 |
$total_bytes = 0; |
| 268 |
$visited = 0; |
| 269 |
$max_visit = 5000; |
| 270 |
|
| 271 |
$stack = array( $dir ); |
| 272 |
while ( ! empty( $stack ) && $visited < $max_visit ) { |
| 273 |
$current = array_pop( $stack ); |
| 274 |
$entries = @scandir( $current ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- best-effort, errors fall back to null. |
| 275 |
if ( ! is_array( $entries ) ) { |
| 276 |
continue; |
| 277 |
} |
| 278 |
foreach ( $entries as $entry ) { |
| 279 |
if ( '.' === $entry || '..' === $entry ) { |
| 280 |
continue; |
| 281 |
} |
| 282 |
$path = $current . '/' . $entry; |
| 283 |
if ( is_link( $path ) ) { |
| 284 |
// Skip symlinks: they could escape the plugin folder |
| 285 |
// or recurse infinitely. The classic admin's plugin |
| 286 |
// list ignores symlink contents for the same reason. |
| 287 |
continue; |
| 288 |
} |
| 289 |
++$visited; |
| 290 |
if ( $visited >= $max_visit ) { |
| 291 |
break 2; |
| 292 |
} |
| 293 |
if ( is_dir( $path ) ) { |
| 294 |
$stack[] = $path; |
| 295 |
} elseif ( is_file( $path ) ) { |
| 296 |
$total_bytes += (int) filesize( $path ); |
| 297 |
} |
| 298 |
} |
| 299 |
} |
| 300 |
|
| 301 |
return $total_bytes > 0 ? max( 1, (int) round( $total_bytes / 1024 ) ) : 0; |
| 302 |
} |
| 303 |
|