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

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