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 / workspaces.php

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

522 lines 17.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * OpenStation — Workspaces.
4 *
5 * A virtual desktop ("Space") is a container for windows. A workspace
6 * is that container plus the answer to "what is this desk FOR": which
7 * apps belong on it, which windows it opens with, and how they are
8 * arranged. That answer is the desktop's `profile`, and it rides along
9 * with the desktop through {@see openstation_sanitize_session()}.
10 *
11 * This file owns two things:
12 *
13 * 1. The server-side view of the shipped templates, so a plugin can
14 * add or drop one from PHP without shipping JavaScript.
15 * 2. Sanitization of a profile arriving from the client. The session
16 * is user meta written from an untrusted payload, so every field
17 * is bounded here and nowhere else.
18 *
19 * The JS side is `src/workspaces/`, and the two lists of shipped
20 * templates are deliberately separate: PHP's exists so a filter has
21 * something to filter, JS's is what the switcher renders. Neither
22 * generates the other, and `Tests_OpenStation_Workspaces` pins that
23 * the ids match.
24 *
25 * @package OpenStation
26 */
27
28 defined( 'ABSPATH' ) || exit;
29
30 /** Hard cap on apps named by one workspace's visible set. */
31 const OPENSTATION_WORKSPACE_MAX_APPS = 128;
32
33 /** Hard cap on widgets named by one workspace's column. */
34 const OPENSTATION_WORKSPACE_MAX_WIDGETS = 32;
35
36 /**
37 * How many nested arrays an appearance value may hold.
38 *
39 * Two is exactly what the deepest real shape needs:
40 * `wallpaperSettings` is a record of wallpaper ids (one), each holding
41 * that wallpaper's own settings (two), each holding scalars.
42 * `customGradient` and `customImage` stop at one. Anything below that
43 * is not a setting, and user meta is not a place to store an object
44 * graph.
45 */
46 const OPENSTATION_WORKSPACE_APPEARANCE_MAX_DEPTH = 2;
47
48 /** Hard cap on windows one workspace opens with. */
49 const OPENSTATION_WORKSPACE_MAX_WINDOWS = 12;
50
51 /** Arrangements a workspace's `layout` may name. Mirrors `WORKSPACE_LAYOUTS`. */
52 const OPENSTATION_WORKSPACE_LAYOUTS = array( 'free', 'cascade', 'tile', 'columns', 'focus' );
53
54 /**
55 * Appearance settings a workspace may repaint the desk with.
56 *
57 * Mirrors `WORKSPACE_APPEARANCE_KEYS` in `src/workspaces/types.ts`,
58 * and enforcing it here is not belt-and-braces: a profile is user meta
59 * round-tripped through an untrusted client, and an unfiltered patch
60 * spread onto the settings state at boot would be a way to write any
61 * settings key from anywhere. Everything on the list is visual and
62 * instantly reversible, which is the test for belonging — switching
63 * desks must never leave a user somewhere they cannot get back from.
64 */
65 const OPENSTATION_WORKSPACE_APPEARANCE_KEYS = array(
66 'wallpaper',
67 'wallpaperSettings',
68 'customGradient',
69 'customImage',
70 'accent',
71 'customAccent',
72 'desktopTheme',
73 'desktopLayout',
74 'dockPlacement',
75 'dockSize',
76 'dockBehavior',
77 'sideDockBehavior',
78 'windowRadius',
79 'windowReveal',
80 'unfocusEffect',
81 'adminBarMode',
82 );
83
84 /**
85 * The workspace templates the server knows about.
86 *
87 * Mirrors `builtInPresets()` in `src/workspaces/presets.ts` — the ids,
88 * labels and layouts are the contract; the app/window token lists live
89 * on the JS side, which is where they are resolved against the live
90 * navigation.
91 *
92 * Named for the job, not for the plugin: a desk called "Woo" is wrong
93 * on a store running something else, and wrong again the day the
94 * product is renamed. The products are still what the templates reach
95 * for — the JS token lists name WooCommerce and Sensei directly — so
96 * on a site that has them, Commerce is a WooCommerce desk in
97 * everything but its label.
98 *
99 * Filterable so a site can add a template, or drop one it has no use
100 * for. A blog with no store has no reason to be offered a Woo desk.
101 *
102 * @return array[] List of `array{ id, label, description, icon, color, layout }`.
103 */
104 function openstation_workspace_presets() {
105 $presets = array(
106 array(
107 'id' => 'commerce',
108 'label' => __( 'Commerce', 'desktop-mode' ),
109 'description' => __( 'A shop floor. WooCommerce orders, products and analytics side by side; everything that is not commerce leaves the rails.', 'desktop-mode' ),
110 'icon' => 'dashicons-cart',
111 'color' => '#7f54b3',
112 'layout' => 'columns',
113 'order' => 10,
114 ),
115 array(
116 'id' => 'learning',
117 'label' => __( 'Learning', 'desktop-mode' ),
118 'description' => __( 'A course studio. Sensei courses, lessons and learners tiled together, so moving between them is a glance rather than a navigation.', 'desktop-mode' ),
119 'icon' => 'dashicons-welcome-learn-more',
120 'color' => '#43a047',
121 'layout' => 'tile',
122 'order' => 20,
123 ),
124 array(
125 'id' => 'publishing',
126 'label' => __( 'Publishing', 'desktop-mode' ),
127 'description' => __( 'A writing desk. A blank page takes two thirds of the screen, the library sits in the margin, and the rest of the admin is somewhere else.', 'desktop-mode' ),
128 'icon' => 'dashicons-edit-page',
129 'color' => '#c8102e',
130 'layout' => 'focus',
131 'order' => 30,
132 ),
133 );
134
135 /**
136 * Filters the workspace templates offered in the switcher.
137 *
138 * A template added here is a complete one: give it `apps` and
139 * `windows` (lists of match tokens — see
140 * `openstation_sanitize_workspace_preset()`) and the client will
141 * resolve them against the live navigation the same way it
142 * resolves a built-in's. The three shipped entries deliberately
143 * carry neither, because the client already has their token lists
144 * and duplicating them here would be two places to keep in step.
145 *
146 * @param array[] $presets List of preset definitions.
147 */
148 $presets = apply_filters( 'openstation_workspace_presets', $presets );
149
150 if ( ! is_array( $presets ) ) {
151 return array();
152 }
153
154 $clean = array();
155 foreach ( $presets as $preset ) {
156 $entry = openstation_sanitize_workspace_preset( $preset );
157 if ( null !== $entry ) {
158 $clean[] = $entry;
159 }
160 }
161 return $clean;
162 }
163
164 /**
165 * Sanitizes a launch entry's `place` — where a window goes, as
166 * fractions of the work area.
167 *
168 * Four numbers in `[0, 1]`, width and height at least 5% so a saved
169 * window can never come back as a sliver the user cannot grab. Null
170 * for anything else: the window then lands wherever the arrangement
171 * puts it, which is what an entry written before positions does.
172 *
173 * @param mixed $raw Raw place from the payload.
174 * @return array|null Sanitized place, or null.
175 */
176 function openstation_sanitize_workspace_place( $raw ) {
177 if ( ! is_array( $raw ) ) {
178 return null;
179 }
180 $out = array();
181 foreach ( array( 'x', 'y', 'width', 'height' ) as $key ) {
182 if ( ! isset( $raw[ $key ] ) || ! is_numeric( $raw[ $key ] ) ) {
183 return null;
184 }
185 $v = (float) $raw[ $key ];
186 if ( ! is_finite( $v ) ) {
187 return null;
188 }
189 $out[ $key ] = round( max( 0.0, min( 1.0, $v ) ), 4 );
190 }
191 if ( $out['width'] < 0.05 || $out['height'] < 0.05 ) {
192 return null;
193 }
194 return $out;
195 }
196
197 /**
198 * Sanitizes a workspace's appearance patch.
199 *
200 * Keys outside {@see OPENSTATION_WORKSPACE_APPEARANCE_KEYS} are
201 * dropped, and so is any value that isn't a scalar or a plain array —
202 * the settings layer's own deserializer validates the shapes, so this
203 * only has to guarantee the patch cannot reach a key it has no
204 * business setting, and cannot carry an object graph into user meta.
205 *
206 * `wallpaperSettings`, `customGradient` and `customImage` are the
207 * array-valued members, so arrays are allowed but bounded by
208 * {@see OPENSTATION_WORKSPACE_APPEARANCE_MAX_DEPTH} — exactly the
209 * nesting the deepest of them reaches, and nothing below it.
210 *
211 * @param mixed $raw Raw appearance patch.
212 * @return array Sanitized patch, possibly empty.
213 */
214 function openstation_sanitize_workspace_appearance( $raw ) {
215 if ( ! is_array( $raw ) ) {
216 return array();
217 }
218 $clean = array();
219 foreach ( OPENSTATION_WORKSPACE_APPEARANCE_KEYS as $key ) {
220 if ( ! array_key_exists( $key, $raw ) ) {
221 continue;
222 }
223 $value = $raw[ $key ];
224 if ( is_scalar( $value ) || null === $value ) {
225 $clean[ $key ] = is_string( $value ) ? substr( wp_strip_all_tags( $value ), 0, 512 ) : $value;
226 continue;
227 }
228 if ( is_array( $value ) ) {
229 $clean[ $key ] = openstation_sanitize_workspace_appearance_branch(
230 $value,
231 OPENSTATION_WORKSPACE_APPEARANCE_MAX_DEPTH
232 );
233 }
234 }
235 return $clean;
236 }
237
238 /**
239 * Depth-bounded scalar filter for an appearance value's sub-arrays.
240 *
241 * @param array $value Raw sub-array.
242 * @param int $depth Remaining levels to descend.
243 * @return array Sanitized sub-array.
244 */
245 function openstation_sanitize_workspace_appearance_branch( $value, $depth ) {
246 $out = array();
247 foreach ( $value as $key => $item ) {
248 $key = substr( preg_replace( '#[^A-Za-z0-9_/.-]#', '', (string) $key ), 0, 128 );
249 if ( '' === $key ) {
250 continue;
251 }
252 if ( is_scalar( $item ) || null === $item ) {
253 $out[ $key ] = is_string( $item ) ? substr( wp_strip_all_tags( $item ), 0, 512 ) : $item;
254 continue;
255 }
256 if ( is_array( $item ) && $depth > 1 ) {
257 $out[ $key ] = openstation_sanitize_workspace_appearance_branch( $item, $depth - 1 );
258 }
259 }
260 return $out;
261 }
262
263 /**
264 * Sanitizes one workspace template.
265 *
266 * Applied to everything the `openstation_workspace_presets` filter
267 * returns, shipped entries included — a template reaches the client in
268 * the shell config blob, and a plugin returning a malformed one should
269 * cost that template rather than the whole switcher.
270 *
271 * Returns `null` for an entry with no usable id.
272 *
273 * @param mixed $raw Raw preset definition.
274 * @return array|null Sanitized preset, or null.
275 */
276 function openstation_sanitize_workspace_preset( $raw ) {
277 if ( ! is_array( $raw ) ) {
278 return null;
279 }
280 $id = isset( $raw['id'] ) ? sanitize_key( (string) $raw['id'] ) : '';
281 if ( '' === $id ) {
282 return null;
283 }
284
285 $layout = isset( $raw['layout'] ) ? (string) $raw['layout'] : 'free';
286 if ( ! in_array( $layout, OPENSTATION_WORKSPACE_LAYOUTS, true ) ) {
287 $layout = 'free';
288 }
289
290 $label = isset( $raw['label'] ) ? wp_strip_all_tags( (string) $raw['label'] ) : '';
291 $color = isset( $raw['color'] ) ? sanitize_hex_color( (string) $raw['color'] ) : '';
292
293 $apps = array();
294 if ( isset( $raw['apps'] ) && is_array( $raw['apps'] ) ) {
295 foreach ( $raw['apps'] as $token ) {
296 if ( ! is_string( $token ) ) {
297 continue;
298 }
299 $token = substr( sanitize_text_field( $token ), 0, 128 );
300 if ( '' !== $token ) {
301 $apps[] = $token;
302 }
303 if ( count( $apps ) >= OPENSTATION_WORKSPACE_MAX_APPS ) {
304 break;
305 }
306 }
307 }
308
309 $widgets = array();
310 if ( isset( $raw['widgets'] ) && is_array( $raw['widgets'] ) ) {
311 foreach ( $raw['widgets'] as $id ) {
312 if ( ! is_string( $id ) ) {
313 continue;
314 }
315 // Namespaced registry keys — the slash is part of the id.
316 $id = substr( preg_replace( '#[^A-Za-z0-9_/-]#', '', $id ), 0, 128 );
317 if ( '' !== $id ) {
318 $widgets[] = $id;
319 }
320 if ( count( $widgets ) >= OPENSTATION_WORKSPACE_MAX_WIDGETS ) {
321 break;
322 }
323 }
324 }
325
326 $windows = array();
327 if ( isset( $raw['windows'] ) && is_array( $raw['windows'] ) ) {
328 foreach ( $raw['windows'] as $win ) {
329 if ( ! is_array( $win ) ) {
330 continue;
331 }
332 $match = isset( $win['match'] ) ? substr( sanitize_text_field( (string) $win['match'] ), 0, 128 ) : '';
333 if ( '' === $match ) {
334 continue;
335 }
336 $entry = array( 'match' => $match );
337 if ( isset( $win['url'] ) && is_string( $win['url'] ) ) {
338 $url = substr( wp_strip_all_tags( $win['url'] ), 0, 512 );
339 if ( '' !== $url ) {
340 $entry['url'] = $url;
341 }
342 }
343 if ( isset( $win['title'] ) && is_string( $win['title'] ) ) {
344 $title = substr( wp_strip_all_tags( $win['title'] ), 0, 128 );
345 if ( '' !== $title ) {
346 $entry['title'] = $title;
347 }
348 }
349 $windows[] = $entry;
350 if ( count( $windows ) >= OPENSTATION_WORKSPACE_MAX_WINDOWS ) {
351 break;
352 }
353 }
354 }
355
356 return array(
357 'appearance' => openstation_sanitize_workspace_appearance( isset( $raw['appearance'] ) ? $raw['appearance'] : null ),
358 'id' => $id,
359 'label' => '' !== $label ? $label : $id,
360 'description' => isset( $raw['description'] ) ? wp_strip_all_tags( (string) $raw['description'] ) : '',
361 'icon' => isset( $raw['icon'] ) ? sanitize_html_class( (string) $raw['icon'] ) : 'dashicons-desktop',
362 'color' => $color ? $color : '',
363 'apps' => $apps,
364 'widgets' => $widgets,
365 'windows' => $windows,
366 'layout' => $layout,
367 'order' => isset( $raw['order'] ) ? (int) $raw['order'] : 0,
368 );
369 }
370
371 /**
372 * Sanitizes one workspace profile from an untrusted session payload.
373 *
374 * Returns `null` for anything that is not a profile, which is the
375 * signal for "this desktop is a plain Space" — the field is optional
376 * and absent is meaningful, so a malformed profile degrades the
377 * desktop rather than the session.
378 *
379 * @param mixed $raw Raw profile from the client.
380 * @return array|null Sanitized profile, or null when there isn't one.
381 */
382 function openstation_sanitize_workspace_profile( $raw ) {
383 if ( ! is_array( $raw ) ) {
384 return null;
385 }
386
387 $layout = isset( $raw['layout'] ) ? (string) $raw['layout'] : 'free';
388 if ( ! in_array( $layout, OPENSTATION_WORKSPACE_LAYOUTS, true ) ) {
389 $layout = 'free';
390 }
391
392 // Colour is a `#rrggbb` accent or empty for "use the shell accent".
393 // `sanitize_hex_color()` returns null for anything else, which we
394 // fold back to empty rather than dropping the whole profile.
395 $color = isset( $raw['color'] ) ? sanitize_hex_color( (string) $raw['color'] ) : '';
396
397 $mode = 'all';
398 $ids = array();
399 if ( isset( $raw['apps'] ) && is_array( $raw['apps'] ) ) {
400 if ( isset( $raw['apps']['mode'] ) && 'only' === $raw['apps']['mode'] ) {
401 $mode = 'only';
402 }
403 if ( isset( $raw['apps']['ids'] ) && is_array( $raw['apps']['ids'] ) ) {
404 foreach ( $raw['apps']['ids'] as $id ) {
405 if ( ! is_string( $id ) && ! is_numeric( $id ) ) {
406 continue;
407 }
408 // Nav ids are slugs derived from admin URLs and window
409 // ids, so the character class is the same one
410 // `sanitize_key()` allows — but NOT `sanitize_key()`
411 // itself, which lowercases: a native window registered
412 // as `wpdcEditor` would be stored as `wpdceditor` and
413 // then match nothing on the client.
414 $id = substr( preg_replace( '/[^A-Za-z0-9_\-]/', '', (string) $id ), 0, 128 );
415 if ( '' === $id ) {
416 continue;
417 }
418 $ids[] = $id;
419 if ( count( $ids ) >= OPENSTATION_WORKSPACE_MAX_APPS ) {
420 break;
421 }
422 }
423 }
424 }
425
426 // Widgets are a separate decision from apps, with a separate rule:
427 // `only` means the column IS these ids, whether or not the user
428 // enabled them globally. See `WorkspaceWidgets` on the JS side.
429 $widget_mode = 'all';
430 $widget_ids = array();
431 if ( isset( $raw['widgets'] ) && is_array( $raw['widgets'] ) ) {
432 if ( isset( $raw['widgets']['mode'] ) && 'only' === $raw['widgets']['mode'] ) {
433 $widget_mode = 'only';
434 }
435 if ( isset( $raw['widgets']['ids'] ) && is_array( $raw['widgets']['ids'] ) ) {
436 foreach ( $raw['widgets']['ids'] as $id ) {
437 if ( ! is_string( $id ) ) {
438 continue;
439 }
440 // Widget ids are namespaced registry keys
441 // (`desktop-mode/post-stats`), so the slash is part of
442 // the id and the character class has to allow it.
443 $id = substr( preg_replace( '#[^A-Za-z0-9_/-]#', '', $id ), 0, 128 );
444 if ( '' === $id ) {
445 continue;
446 }
447 $widget_ids[] = $id;
448 if ( count( $widget_ids ) >= OPENSTATION_WORKSPACE_MAX_WIDGETS ) {
449 break;
450 }
451 }
452 }
453 }
454
455 $windows = array();
456 if ( isset( $raw['windows'] ) && is_array( $raw['windows'] ) ) {
457 foreach ( $raw['windows'] as $win ) {
458 if ( ! is_array( $win ) ) {
459 continue;
460 }
461 $match = isset( $win['match'] ) ? sanitize_text_field( (string) $win['match'] ) : '';
462 if ( '' === $match ) {
463 continue;
464 }
465 $entry = array( 'match' => substr( $match, 0, 128 ) );
466 if ( isset( $win['url'] ) && is_string( $win['url'] ) ) {
467 // Relative by design — a template has to survive being
468 // read on a subdirectory install — so this is not a URL
469 // validator. It strips markup and bounds the length;
470 // the client resolves it against wp-admin and the
471 // window manager refuses anything that lands outside.
472 $url = substr( wp_strip_all_tags( $win['url'] ), 0, 512 );
473 if ( '' !== $url ) {
474 $entry['url'] = $url;
475 }
476 }
477 if ( isset( $win['title'] ) && is_string( $win['title'] ) ) {
478 $title = substr( wp_strip_all_tags( $win['title'] ), 0, 128 );
479 if ( '' !== $title ) {
480 $entry['title'] = $title;
481 }
482 }
483 // Where the window goes — cells or fractions of the work
484 // area, both of which survive a different display. See
485 // `openstation_sanitize_workspace_place()`.
486 $grid_span = openstation_sanitize_session_grid_span( $win['gridSpan'] ?? null );
487 if ( null !== $grid_span ) {
488 $entry['gridSpan'] = $grid_span;
489 }
490 $place = openstation_sanitize_workspace_place( $win['place'] ?? null );
491 if ( null !== $place ) {
492 $entry['place'] = $place;
493 }
494 $windows[] = $entry;
495 if ( count( $windows ) >= OPENSTATION_WORKSPACE_MAX_WINDOWS ) {
496 break;
497 }
498 }
499 }
500
501 return array(
502 'appearance' => openstation_sanitize_workspace_appearance( isset( $raw['appearance'] ) ? $raw['appearance'] : null ),
503 'preset' => isset( $raw['preset'] ) ? substr( sanitize_key( (string) $raw['preset'] ), 0, 64 ) : '',
504 'icon' => isset( $raw['icon'] ) ? sanitize_html_class( (string) $raw['icon'] ) : 'dashicons-desktop',
505 'color' => $color ? $color : '',
506 'apps' => array(
507 'mode' => $mode,
508 'ids' => $ids,
509 ),
510 'widgets' => array(
511 'mode' => $widget_mode,
512 'ids' => $widget_ids,
513 ),
514 'windows' => $windows,
515 'layout' => $layout,
516 // Absent means "the launch list has not run", and a workspace
517 // restored mid-provision would otherwise open its windows a
518 // second time on top of the ones the session just restored.
519 'provisioned' => ! empty( $raw['provisioned'] ),
520 );
521 }
522