PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 0.9.5
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v0.9.5
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.9.5, at includes/devtools.php

371 lines 11.8 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 a case-preserving alphanumeric+dash
88 * filter (`sanitize_key()` would lowercase, breaking UUID v4
89 * round-trips); values longer than 64 characters are rejected —
90 * a tight gate for `crypto.randomUUID()`-shaped ids.
91 *
92 * @since 0.6.0
93 *
94 * @return string Session id, or '' when absent / invalid.
95 */
96 function desktop_mode_debug_session_for_request() {
97 $raw = '';
98 if ( isset( $_SERVER['HTTP_X_WP_DEBUG_SESSION'] ) ) {
99 $raw = sanitize_text_field( wp_unslash( $_SERVER['HTTP_X_WP_DEBUG_SESSION'] ) );
100 }
101 $raw = trim( $raw );
102 if ( '' === $raw ) {
103 return '';
104 }
105 // `sanitize_key()` lowercases — preserve case so UUID v4 strings
106 // round-trip cleanly. Replace anything that isn't alnum/dash.
107 $sanitised = preg_replace( '/[^A-Za-z0-9\-]/', '', $raw );
108 if ( ! is_string( $sanitised ) || '' === $sanitised || strlen( $sanitised ) > 64 ) {
109 return '';
110 }
111 return $sanitised;
112 }
113
114 /**
115 * Publish a payload onto a (session, channel).
116 *
117 * The newest event is appended to the ring buffer; once the cap is
118 * reached, the oldest events are dropped FIFO. Fires the
119 * `desktop_mode_debug_publish` action so observability widgets can
120 * tail the stream synchronously without going through the REST poll.
121 *
122 * @since 0.6.0
123 *
124 * @param string $session_id Session id from the client.
125 * @param string $channel Channel name. Free-form; convention is
126 * lowercase ASCII (e.g. `'query'`,
127 * `'log'`, `'rest_timing'`).
128 * @param mixed $payload Anything `wp_json_encode()` can serialise.
129 * @return bool True when the event was appended (queued for storage);
130 * false only when `$session_id` or `$channel` is empty.
131 * `set_transient()` failures are not detected.
132 */
133 function desktop_mode_debug_publish( $session_id, $channel, $payload ) {
134 $session_id = (string) $session_id;
135 $channel = (string) $channel;
136 if ( '' === $session_id || '' === $channel ) {
137 return false;
138 }
139 $key = desktop_mode_debug_transient_key( $session_id, $channel );
140 $existing = get_transient( $key );
141 if ( ! is_array( $existing ) ) {
142 $existing = array(
143 'next_id' => 0,
144 'events' => array(),
145 );
146 }
147 $next_id = isset( $existing['next_id'] ) ? (int) $existing['next_id'] : 0;
148 $next_id++;
149 $existing['next_id'] = $next_id;
150 $existing['events'][] = array(
151 'id' => $next_id,
152 't' => (int) round( microtime( true ) * 1000 ),
153 'channel' => $channel,
154 'payload' => $payload,
155 );
156 $max = (int) apply_filters( 'desktop_mode_debug_ring_size', DESKTOP_MODE_DEBUG_RING_SIZE );
157 if ( $max < 1 ) {
158 $max = DESKTOP_MODE_DEBUG_RING_SIZE;
159 }
160 if ( count( $existing['events'] ) > $max ) {
161 $existing['events'] = array_slice( $existing['events'], -$max );
162 }
163 set_transient( $key, $existing, DESKTOP_MODE_DEBUG_SESSION_TTL );
164
165 /**
166 * Fires after a debug event is appended to the ring buffer.
167 *
168 * Lets observability hooks tail the stream synchronously instead
169 * of polling the REST endpoint. The arguments mirror the JS-side
170 * `DebugEvent` shape minus the auto-assigned id / timestamp.
171 *
172 * @since 0.6.0
173 *
174 * @param string $session_id Session id from the publishing call.
175 * @param string $channel Channel name.
176 * @param mixed $payload Published payload.
177 */
178 do_action( 'desktop_mode_debug_publish', $session_id, $channel, $payload );
179 return true;
180 }
181
182 /**
183 * Drain events newer than `$since` for a session, optionally
184 * narrowed to one channel.
185 *
186 * Returns `array( 'events' => [], 'cursor' => N )`. The cursor is the
187 * highest event id seen across all returned events; clients pass it
188 * back as `since` on the next poll.
189 *
190 * @since 0.6.0
191 *
192 * @param string $session_id Session id.
193 * @param int $since Highest id the client has seen.
194 * @param string|null $channel Optional channel filter.
195 * @return array
196 */
197 function desktop_mode_debug_drain( $session_id, $since = 0, $channel = null ) {
198 $session_id = (string) $session_id;
199 if ( '' === $session_id ) {
200 return array( 'events' => array(), 'cursor' => (int) $since );
201 }
202
203 $channels = array();
204 if ( null !== $channel && '' !== (string) $channel ) {
205 $channels[] = (string) $channel;
206 } else {
207 // Without a channel filter the client wants every channel for
208 // this session. We don't keep an index of channels per session
209 // (would double-write on every publish); instead we let the
210 // caller pass a list, OR fan out via the
211 // `desktop_mode_debug_channels` filter for plugins that know
212 // their full set up-front.
213 $declared = apply_filters( 'desktop_mode_debug_channels', array(), $session_id );
214 if ( is_array( $declared ) ) {
215 foreach ( $declared as $ch ) {
216 if ( is_string( $ch ) && '' !== $ch ) {
217 $channels[] = $ch;
218 }
219 }
220 }
221 }
222
223 $cursor = (int) $since;
224 $out = array();
225 foreach ( $channels as $ch ) {
226 $key = desktop_mode_debug_transient_key( $session_id, $ch );
227 $data = get_transient( $key );
228 if ( ! is_array( $data ) || empty( $data['events'] ) ) {
229 continue;
230 }
231 foreach ( $data['events'] as $ev ) {
232 if ( ! is_array( $ev ) || ! isset( $ev['id'] ) ) {
233 continue;
234 }
235 if ( (int) $ev['id'] <= (int) $since ) {
236 continue;
237 }
238 $out[] = $ev;
239 if ( (int) $ev['id'] > $cursor ) {
240 $cursor = (int) $ev['id'];
241 }
242 }
243 }
244
245 // Stable sort by event id so a multi-channel response is in
246 // publication order rather than channel-iteration order. usort()
247 // in PHP 8+ is stable; this matches the JS-side expectation.
248 usort(
249 $out,
250 static function ( $a, $b ) {
251 return ( (int) $a['id'] ) - ( (int) $b['id'] );
252 }
253 );
254 return array( 'events' => $out, 'cursor' => $cursor );
255 }
256
257 /**
258 * REST: GET /desktop-mode/v1/debug
259 *
260 * Returns events newer than `since` for the given session id.
261 * Supports both `channel=foo` (single) and `channels[]=foo&channels[]=bar`
262 * (list); falls back to the `desktop_mode_debug_channels` filter
263 * when no channel param is supplied.
264 *
265 * @since 0.6.0
266 *
267 * @param WP_REST_Request $request REST request.
268 * @return WP_REST_Response
269 */
270 function desktop_mode_rest_debug_drain( WP_REST_Request $request ) {
271 $session_id = (string) $request->get_param( 'sessionId' );
272 $since = (int) $request->get_param( 'since' );
273 $channel = $request->get_param( 'channel' );
274 $channels = $request->get_param( 'channels' );
275
276 if ( is_array( $channels ) && count( $channels ) > 0 ) {
277 // Multi-channel drain — concatenate the per-channel results.
278 $cursor = $since;
279 $all_events = array();
280 foreach ( $channels as $ch ) {
281 $result = desktop_mode_debug_drain( $session_id, $since, (string) $ch );
282 foreach ( $result['events'] as $ev ) {
283 $all_events[] = $ev;
284 }
285 if ( $result['cursor'] > $cursor ) {
286 $cursor = $result['cursor'];
287 }
288 }
289 usort(
290 $all_events,
291 static function ( $a, $b ) {
292 return ( (int) $a['id'] ) - ( (int) $b['id'] );
293 }
294 );
295 return rest_ensure_response(
296 array(
297 'events' => $all_events,
298 'cursor' => $cursor,
299 )
300 );
301 }
302
303 $result = desktop_mode_debug_drain(
304 $session_id,
305 $since,
306 is_string( $channel ) ? $channel : null
307 );
308 return rest_ensure_response( $result );
309 }
310
311 /**
312 * Permission gate for the debug REST endpoint.
313 *
314 * Logged-in admins only — debug data exposes internal request shapes
315 * that should never leak to lower-privileged users. Plugins that need
316 * to relax this for a specific session can hook the
317 * `desktop_mode_debug_rest_permission` filter (filters TRUE/FALSE).
318 *
319 * @since 0.6.0
320 *
321 * @return bool
322 */
323 function desktop_mode_rest_debug_permission() {
324 $allowed = is_user_logged_in() && current_user_can( 'manage_options' );
325 /**
326 * Filter the permission decision for the debug REST endpoint.
327 *
328 * @since 0.6.0
329 *
330 * @param bool $allowed Default: caller is a logged-in admin.
331 */
332 return (bool) apply_filters( 'desktop_mode_debug_rest_permission', $allowed );
333 }
334
335 /**
336 * Register the debug REST routes.
337 *
338 * @since 0.6.0
339 */
340 function desktop_mode_register_debug_rest_routes() {
341 register_rest_route(
342 'desktop-mode/v1',
343 '/debug',
344 array(
345 array(
346 'methods' => WP_REST_Server::READABLE,
347 'callback' => 'desktop_mode_rest_debug_drain',
348 'permission_callback' => 'desktop_mode_rest_debug_permission',
349 'args' => array(
350 'sessionId' => array(
351 'required' => true,
352 'type' => 'string',
353 ),
354 'since' => array(
355 'type' => 'integer',
356 'default' => 0,
357 ),
358 'channel' => array(
359 'type' => 'string',
360 ),
361 'channels' => array(
362 'type' => 'array',
363 'items' => array( 'type' => 'string' ),
364 ),
365 ),
366 ),
367 )
368 );
369 }
370 add_action( 'rest_api_init', 'desktop_mode_register_debug_rest_routes' );
371