PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 0.8.7
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v0.8.7
1.1.10 1.1.9 1.1.8 1.1.7 1.1.6 1.1.5 1.1.4 1.1.3 1.1.2 1.1.1 1.1.0 1.0.1 1.0.0 0.9.8 0.9.7 0.9.6 0.9.4 0.9.5 0.9.3 0.9.2 0.9.1 0.9.0 0.8.9 0.8.8 0.8.7 All 34 releases
desktop-mode / includes / devtools.php

devtools.php in OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin 0.8.7, at includes/devtools.php

369 lines 11.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Desktop Mode — DevTools / debug bus.
4 *
5 * Provides a generic per-session pub/sub channel that plugins use to
6 * stream debug data (SQL queries, HTTP timings, hook traces, custom
7 * events) from a server-side capture into a client-side inspector
8 * window.
9 *
10 * Architecture:
11 *
12 * 1. Inspector plugin allocates a session id with
13 * `wp.desktop.devtools.debug.startSession()` and decides which
14 * channels it cares about (`'query'`, `'log'`, …).
15 * 2. Inspector contributes `X-WP-Debug-Session: <id>` to the
16 * target window via
17 * `wp.desktop.devtools.addRequestHeader( windowId, 'X-WP-Debug-Session', sessionId )`.
18 * 3. The target window's iframe attaches that header to every
19 * fetch / XHR / sendBeacon (the chromeless inline bridge merges
20 * contributed headers into outgoing requests).
21 * 4. Server-side capture hooks read the header via
22 * {@see desktop_mode_debug_session_for_request()}, run their
23 * capture (SAVEQUERIES, output buffering, etc.), and publish via
24 * {@see desktop_mode_debug_publish()}.
25 * 5. Inspector subscribes via
26 * `wp.desktop.devtools.debug.subscribe( sessionId, channel, cb )`.
27 * The shell polls `GET /desktop-mode/v1/debug` every second and
28 * replays new events to subscribers.
29 *
30 * Storage: a per-session ring buffer in a transient. Bounded by
31 * {@see DESKTOP_MODE_DEBUG_RING_SIZE} so a misconfigured capture
32 * loop can't fill the database. TTL is 1 hour — long enough for an
33 * inspector session to span a few page loads, short enough that
34 * abandoned sessions don't squat indefinitely.
35 *
36 * Capability gate: every public surface requires the caller to be
37 * logged-in AND hold `manage_options`. Debug data leaks request /
38 * response details (query parameters, internal IDs) — locking it to
39 * site admins matches the cost of getting that wrong.
40 *
41 * @since 0.6.0
42 *
43 * @package WPDesktopMode
44 */
45
46 defined( 'ABSPATH' ) || exit;
47
48 /**
49 * Maximum number of events kept per (session, channel) ring buffer.
50 *
51 * A 500-event cap means a chatty SQL capture ((100 queries / page) × 5
52 * page loads) survives on the buffer without truncation. Anything
53 * higher and a single transient row starts to push the row-size
54 * sanity threshold for typical wp_options storage.
55 */
56 const DESKTOP_MODE_DEBUG_RING_SIZE = 500;
57
58 /**
59 * Transient TTL for a session ring buffer, in seconds.
60 *
61 * One hour. Inspector windows that stay open longer than that should
62 * heartbeat by republishing — at which point the TTL extends.
63 */
64 const DESKTOP_MODE_DEBUG_SESSION_TTL = 3600;
65
66 /**
67 * Build the transient key for a (session, channel) pair.
68 *
69 * @since 0.6.0
70 *
71 * @param string $session_id Session id (as supplied by the client).
72 * @param string $channel Channel name (`'query'`, `'log'`, …).
73 * @return string Transient key safe for `set_transient`.
74 */
75 function desktop_mode_debug_transient_key( $session_id, $channel ) {
76 return 'desktop_mode_dbg_' . md5( (string) $session_id . '|' . (string) $channel );
77 }
78
79 /**
80 * Read the debug session id from the current request's headers.
81 *
82 * Plugins running inside an admin request (chromeless iframe load,
83 * admin-ajax, REST request) call this to detect whether the request
84 * originated from an instrumented window. Returns an empty string
85 * when no session id is attached or the value fails sanitisation.
86 *
87 * The header is sanitised with `sanitize_key()` — session ids
88 * generated by the JS side (`crypto.randomUUID()` or the legacy
89 * fallback) are alphanumeric + dashes, so this is a tight gate.
90 *
91 * @since 0.6.0
92 *
93 * @return string Session id, or '' when absent / invalid.
94 */
95 function desktop_mode_debug_session_for_request() {
96 $raw = '';
97 if ( isset( $_SERVER['HTTP_X_WP_DEBUG_SESSION'] ) ) {
98 $raw = sanitize_text_field( wp_unslash( $_SERVER['HTTP_X_WP_DEBUG_SESSION'] ) );
99 }
100 $raw = trim( $raw );
101 if ( '' === $raw ) {
102 return '';
103 }
104 // `sanitize_key()` lowercases — preserve case so UUID v4 strings
105 // round-trip cleanly. Replace anything that isn't alnum/dash.
106 $sanitised = preg_replace( '/[^A-Za-z0-9\-]/', '', $raw );
107 if ( ! is_string( $sanitised ) || '' === $sanitised || strlen( $sanitised ) > 64 ) {
108 return '';
109 }
110 return $sanitised;
111 }
112
113 /**
114 * Publish a payload onto a (session, channel).
115 *
116 * The newest event is appended to the ring buffer; once the cap is
117 * reached, the oldest events are dropped FIFO. Fires the
118 * `desktop_mode_debug_publish` action so observability widgets can
119 * tail the stream synchronously without going through the REST poll.
120 *
121 * @since 0.6.0
122 *
123 * @param string $session_id Session id from the client.
124 * @param string $channel Channel name. Free-form; convention is
125 * lowercase ASCII (e.g. `'query'`,
126 * `'log'`, `'rest_timing'`).
127 * @param mixed $payload Anything `wp_json_encode()` can serialise.
128 * @return bool True on success, false when storage refused (ring full
129 * and transient API unavailable, etc.).
130 */
131 function desktop_mode_debug_publish( $session_id, $channel, $payload ) {
132 $session_id = (string) $session_id;
133 $channel = (string) $channel;
134 if ( '' === $session_id || '' === $channel ) {
135 return false;
136 }
137 $key = desktop_mode_debug_transient_key( $session_id, $channel );
138 $existing = get_transient( $key );
139 if ( ! is_array( $existing ) ) {
140 $existing = array(
141 'next_id' => 0,
142 'events' => array(),
143 );
144 }
145 $next_id = isset( $existing['next_id'] ) ? (int) $existing['next_id'] : 0;
146 $next_id++;
147 $existing['next_id'] = $next_id;
148 $existing['events'][] = array(
149 'id' => $next_id,
150 't' => (int) round( microtime( true ) * 1000 ),
151 'channel' => $channel,
152 'payload' => $payload,
153 );
154 $max = (int) apply_filters( 'desktop_mode_debug_ring_size', DESKTOP_MODE_DEBUG_RING_SIZE );
155 if ( $max < 1 ) {
156 $max = DESKTOP_MODE_DEBUG_RING_SIZE;
157 }
158 if ( count( $existing['events'] ) > $max ) {
159 $existing['events'] = array_slice( $existing['events'], -$max );
160 }
161 set_transient( $key, $existing, DESKTOP_MODE_DEBUG_SESSION_TTL );
162
163 /**
164 * Fires after a debug event is appended to the ring buffer.
165 *
166 * Lets observability hooks tail the stream synchronously instead
167 * of polling the REST endpoint. The arguments mirror the JS-side
168 * `DebugEvent` shape minus the auto-assigned id / timestamp.
169 *
170 * @since 0.6.0
171 *
172 * @param string $session_id Session id from the publishing call.
173 * @param string $channel Channel name.
174 * @param mixed $payload Published payload (post-filter).
175 */
176 do_action( 'desktop_mode_debug_publish', $session_id, $channel, $payload );
177 return true;
178 }
179
180 /**
181 * Drain events newer than `$since` for a session, optionally
182 * narrowed to one channel.
183 *
184 * Returns `array( 'events' => [], 'cursor' => N )`. The cursor is the
185 * highest event id seen across all returned events; clients pass it
186 * back as `since` on the next poll.
187 *
188 * @since 0.6.0
189 *
190 * @param string $session_id Session id.
191 * @param int $since Highest id the client has seen.
192 * @param string|null $channel Optional channel filter.
193 * @return array
194 */
195 function desktop_mode_debug_drain( $session_id, $since = 0, $channel = null ) {
196 $session_id = (string) $session_id;
197 if ( '' === $session_id ) {
198 return array( 'events' => array(), 'cursor' => (int) $since );
199 }
200
201 $channels = array();
202 if ( null !== $channel && '' !== (string) $channel ) {
203 $channels[] = (string) $channel;
204 } else {
205 // Without a channel filter the client wants every channel for
206 // this session. We don't keep an index of channels per session
207 // (would double-write on every publish); instead we let the
208 // caller pass a list, OR fan out via the
209 // `desktop_mode_debug_channels` filter for plugins that know
210 // their full set up-front.
211 $declared = apply_filters( 'desktop_mode_debug_channels', array(), $session_id );
212 if ( is_array( $declared ) ) {
213 foreach ( $declared as $ch ) {
214 if ( is_string( $ch ) && '' !== $ch ) {
215 $channels[] = $ch;
216 }
217 }
218 }
219 }
220
221 $cursor = (int) $since;
222 $out = array();
223 foreach ( $channels as $ch ) {
224 $key = desktop_mode_debug_transient_key( $session_id, $ch );
225 $data = get_transient( $key );
226 if ( ! is_array( $data ) || empty( $data['events'] ) ) {
227 continue;
228 }
229 foreach ( $data['events'] as $ev ) {
230 if ( ! is_array( $ev ) || ! isset( $ev['id'] ) ) {
231 continue;
232 }
233 if ( (int) $ev['id'] <= (int) $since ) {
234 continue;
235 }
236 $out[] = $ev;
237 if ( (int) $ev['id'] > $cursor ) {
238 $cursor = (int) $ev['id'];
239 }
240 }
241 }
242
243 // Stable sort by event id so a multi-channel response is in
244 // publication order rather than channel-iteration order. usort()
245 // in PHP 8+ is stable; this matches the JS-side expectation.
246 usort(
247 $out,
248 static function ( $a, $b ) {
249 return ( (int) $a['id'] ) - ( (int) $b['id'] );
250 }
251 );
252 return array( 'events' => $out, 'cursor' => $cursor );
253 }
254
255 /**
256 * REST: GET /desktop-mode/v1/debug
257 *
258 * Returns events newer than `since` for the given session id.
259 * Supports both `channel=foo` (single) and `channels[]=foo&channels[]=bar`
260 * (list); falls back to the `desktop_mode_debug_channels` filter
261 * when no channel param is supplied.
262 *
263 * @since 0.6.0
264 *
265 * @param WP_REST_Request $request REST request.
266 * @return WP_REST_Response
267 */
268 function desktop_mode_rest_debug_drain( WP_REST_Request $request ) {
269 $session_id = (string) $request->get_param( 'sessionId' );
270 $since = (int) $request->get_param( 'since' );
271 $channel = $request->get_param( 'channel' );
272 $channels = $request->get_param( 'channels' );
273
274 if ( is_array( $channels ) && count( $channels ) > 0 ) {
275 // Multi-channel drain — concatenate the per-channel results.
276 $cursor = $since;
277 $all_events = array();
278 foreach ( $channels as $ch ) {
279 $result = desktop_mode_debug_drain( $session_id, $since, (string) $ch );
280 foreach ( $result['events'] as $ev ) {
281 $all_events[] = $ev;
282 }
283 if ( $result['cursor'] > $cursor ) {
284 $cursor = $result['cursor'];
285 }
286 }
287 usort(
288 $all_events,
289 static function ( $a, $b ) {
290 return ( (int) $a['id'] ) - ( (int) $b['id'] );
291 }
292 );
293 return rest_ensure_response(
294 array(
295 'events' => $all_events,
296 'cursor' => $cursor,
297 )
298 );
299 }
300
301 $result = desktop_mode_debug_drain(
302 $session_id,
303 $since,
304 is_string( $channel ) ? $channel : null
305 );
306 return rest_ensure_response( $result );
307 }
308
309 /**
310 * Permission gate for the debug REST endpoint.
311 *
312 * Logged-in admins only — debug data exposes internal request shapes
313 * that should never leak to lower-privileged users. Plugins that need
314 * to relax this for a specific session can hook the
315 * `desktop_mode_debug_rest_permission` filter (filters TRUE/FALSE).
316 *
317 * @since 0.6.0
318 *
319 * @return bool
320 */
321 function desktop_mode_rest_debug_permission() {
322 $allowed = is_user_logged_in() && current_user_can( 'manage_options' );
323 /**
324 * Filter the permission decision for the debug REST endpoint.
325 *
326 * @since 0.6.0
327 *
328 * @param bool $allowed Default: caller is a logged-in admin.
329 */
330 return (bool) apply_filters( 'desktop_mode_debug_rest_permission', $allowed );
331 }
332
333 /**
334 * Register the debug REST routes.
335 *
336 * @since 0.6.0
337 */
338 function desktop_mode_register_debug_rest_routes() {
339 register_rest_route(
340 'desktop-mode/v1',
341 '/debug',
342 array(
343 array(
344 'methods' => WP_REST_Server::READABLE,
345 'callback' => 'desktop_mode_rest_debug_drain',
346 'permission_callback' => 'desktop_mode_rest_debug_permission',
347 'args' => array(
348 'sessionId' => array(
349 'required' => true,
350 'type' => 'string',
351 ),
352 'since' => array(
353 'type' => 'integer',
354 'default' => 0,
355 ),
356 'channel' => array(
357 'type' => 'string',
358 ),
359 'channels' => array(
360 'type' => 'array',
361 'items' => array( 'type' => 'string' ),
362 ),
363 ),
364 ),
365 )
366 );
367 }
368 add_action( 'rest_api_init', 'desktop_mode_register_debug_rest_routes' );
369