PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 1.1.13
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v1.1.13
1.1.13 1.1.12 1.1.11 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 All 37 releases
desktop-mode / apps / plugins / parts / rest-fields.php

rest-fields.php in OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin 1.1.13, at apps/plugins/parts/rest-fields.php

303 lines 10.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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