PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / trunk
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin vtrunk
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 0.8.6 0.8.5 0.8.4 All 31 releases
desktop-mode / includes / devtools.php

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

357 lines 11.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * OpenStation — 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.os.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.os.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 openstation_debug_session_for_request()}, run their
23 * capture (SAVEQUERIES, output buffering, etc.), and publish via
24 * {@see openstation_debug_publish()}.
25 * 5. Inspector subscribes via
26 * `wp.os.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 OPENSTATION_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 OpenStation
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 OPENSTATION_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 OPENSTATION_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 openstation_debug_transient_key( $session_id, $channel ) {
72 return 'openstation_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 openstation_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 * `openstation_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 openstation_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 = openstation_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( 'openstation_debug_ring_size', OPENSTATION_DEBUG_RING_SIZE );
149 if ( $max < 1 ) {
150 $max = OPENSTATION_DEBUG_RING_SIZE;
151 }
152 if ( count( $existing['events'] ) > $max ) {
153 $existing['events'] = array_slice( $existing['events'], -$max );
154 }
155 set_transient( $key, $existing, OPENSTATION_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( 'openstation_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 openstation_debug_drain( $session_id, $since = 0, $channel = null ) {
186 $session_id = (string) $session_id;
187 if ( '' === $session_id ) {
188 return array(
189 'events' => array(),
190 'cursor' => (int) $since,
191 );
192 }
193
194 $channels = array();
195 if ( null !== $channel && '' !== (string) $channel ) {
196 $channels[] = (string) $channel;
197 } else {
198 // Without a channel filter the client wants every channel for
199 // this session. We don't keep an index of channels per session
200 // (would double-write on every publish); instead we let the
201 // caller pass a list, OR fan out via the
202 // `openstation_debug_channels` filter for plugins that know
203 // their full set up-front.
204 $declared = apply_filters( 'openstation_debug_channels', array(), $session_id );
205 if ( is_array( $declared ) ) {
206 foreach ( $declared as $ch ) {
207 if ( is_string( $ch ) && '' !== $ch ) {
208 $channels[] = $ch;
209 }
210 }
211 }
212 }
213
214 $cursor = (int) $since;
215 $out = array();
216 foreach ( $channels as $ch ) {
217 $key = openstation_debug_transient_key( $session_id, $ch );
218 $data = get_transient( $key );
219 if ( ! is_array( $data ) || empty( $data['events'] ) ) {
220 continue;
221 }
222 foreach ( $data['events'] as $ev ) {
223 if ( ! is_array( $ev ) || ! isset( $ev['id'] ) ) {
224 continue;
225 }
226 if ( (int) $ev['id'] <= (int) $since ) {
227 continue;
228 }
229 $out[] = $ev;
230 if ( (int) $ev['id'] > $cursor ) {
231 $cursor = (int) $ev['id'];
232 }
233 }
234 }
235
236 // Stable sort by event id so a multi-channel response is in
237 // publication order rather than channel-iteration order. usort()
238 // in PHP 8+ is stable; this matches the JS-side expectation.
239 usort(
240 $out,
241 static function ( $a, $b ) {
242 return ( (int) $a['id'] ) - ( (int) $b['id'] );
243 }
244 );
245 return array(
246 'events' => $out,
247 'cursor' => $cursor,
248 );
249 }
250
251 /**
252 * REST: GET /desktop-mode/v1/debug
253 *
254 * Returns events newer than `since` for the given session id.
255 * Supports both `channel=foo` (single) and `channels[]=foo&channels[]=bar`
256 * (list); falls back to the `openstation_debug_channels` filter
257 * when no channel param is supplied.
258 *
259 * @param WP_REST_Request $request REST request.
260 * @return WP_REST_Response
261 */
262 function openstation_rest_debug_drain( WP_REST_Request $request ) {
263 $session_id = (string) $request->get_param( 'sessionId' );
264 $since = (int) $request->get_param( 'since' );
265 $channel = $request->get_param( 'channel' );
266 $channels = $request->get_param( 'channels' );
267
268 if ( is_array( $channels ) && count( $channels ) > 0 ) {
269 // Multi-channel drain — concatenate the per-channel results.
270 $cursor = $since;
271 $all_events = array();
272 foreach ( $channels as $ch ) {
273 $result = openstation_debug_drain( $session_id, $since, (string) $ch );
274 foreach ( $result['events'] as $ev ) {
275 $all_events[] = $ev;
276 }
277 if ( $result['cursor'] > $cursor ) {
278 $cursor = $result['cursor'];
279 }
280 }
281 usort(
282 $all_events,
283 static function ( $a, $b ) {
284 return ( (int) $a['id'] ) - ( (int) $b['id'] );
285 }
286 );
287 return rest_ensure_response(
288 array(
289 'events' => $all_events,
290 'cursor' => $cursor,
291 )
292 );
293 }
294
295 $result = openstation_debug_drain(
296 $session_id,
297 $since,
298 is_string( $channel ) ? $channel : null
299 );
300 return rest_ensure_response( $result );
301 }
302
303 /**
304 * Permission gate for the debug REST endpoint.
305 *
306 * Logged-in admins only — debug data exposes internal request shapes
307 * that should never leak to lower-privileged users. Plugins that need
308 * to relax this for a specific session can hook the
309 * `openstation_debug_rest_permission` filter (filters TRUE/FALSE).
310 *
311 * @return bool
312 */
313 function openstation_rest_debug_permission() {
314 $allowed = is_user_logged_in() && current_user_can( 'manage_options' );
315 /**
316 * Filter the permission decision for the debug REST endpoint.
317 *
318 * @param bool $allowed Default: caller is a logged-in admin.
319 */
320 return (bool) apply_filters( 'openstation_debug_rest_permission', $allowed );
321 }
322
323 /**
324 * Register the debug REST routes.
325 */
326 function openstation_register_debug_rest_routes() {
327 register_rest_route(
328 'desktop-mode/v1',
329 '/debug',
330 array(
331 array(
332 'methods' => WP_REST_Server::READABLE,
333 'callback' => 'openstation_rest_debug_drain',
334 'permission_callback' => 'openstation_rest_debug_permission',
335 'args' => array(
336 'sessionId' => array(
337 'required' => true,
338 'type' => 'string',
339 ),
340 'since' => array(
341 'type' => 'integer',
342 'default' => 0,
343 ),
344 'channel' => array(
345 'type' => 'string',
346 ),
347 'channels' => array(
348 'type' => 'array',
349 'items' => array( 'type' => 'string' ),
350 ),
351 ),
352 ),
353 )
354 );
355 }
356 add_action( 'rest_api_init', 'openstation_register_debug_rest_routes' );
357