PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 1.1.11
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v1.1.11
1.1.11 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 All 35 releases
desktop-mode / includes / workspaces.php

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

573 lines 19.5 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 * And on a site that does not have them, the template is left out:
100 * `requires` names the plugin, and this is the side of the wire that
101 * knows whether it is active. The client's switcher shows what this
102 * list names — see `installWorkspacePresetSync()` — so dropping an
103 * entry here is what hides the card.
104 *
105 * Filterable so a site can add a template, or drop one it has no use
106 * for.
107 *
108 * @return array[] List of `array{ id, label, description, icon, color, layout }`.
109 */
110 function openstation_workspace_presets() {
111 $presets = array(
112 array(
113 'id' => 'commerce',
114 'label' => __( 'Commerce', 'desktop-mode' ),
115 'description' => __( 'A shop floor. WooCommerce orders, products and analytics side by side; everything that is not commerce leaves the rails.', 'desktop-mode' ),
116 'icon' => 'dashicons-cart',
117 'color' => '#7f54b3',
118 'layout' => 'columns',
119 'order' => 10,
120 'requires' => array( 'woocommerce/woocommerce.php' ),
121 ),
122 array(
123 'id' => 'learning',
124 'label' => __( 'Learning', 'desktop-mode' ),
125 'description' => __( 'A course studio. Sensei courses, lessons and learners tiled together, so moving between them is a glance rather than a navigation.', 'desktop-mode' ),
126 'icon' => 'dashicons-welcome-learn-more',
127 'color' => '#43a047',
128 'layout' => 'tile',
129 'order' => 20,
130 'requires' => array( 'sensei-lms/sensei-lms.php' ),
131 ),
132 array(
133 'id' => 'publishing',
134 'label' => __( 'Publishing', 'desktop-mode' ),
135 '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' ),
136 'icon' => 'dashicons-edit-page',
137 'color' => '#c8102e',
138 'layout' => 'focus',
139 'order' => 30,
140 ),
141 );
142
143 /**
144 * Filters the workspace templates offered in the switcher.
145 *
146 * A template added here is a complete one: give it `apps` and
147 * `windows` (lists of match tokens — see
148 * `openstation_sanitize_workspace_preset()`) and the client will
149 * resolve them against the live navigation the same way it
150 * resolves a built-in's. The three shipped entries deliberately
151 * carry neither, because the client already has their token lists
152 * and duplicating them here would be two places to keep in step.
153 *
154 * `requires` is the one field that stays on this side: a list of
155 * plugin basenames that must be active for the template to be
156 * offered at all. Unset it on a shipped entry to be offered that
157 * desk whatever is installed.
158 *
159 * @param array[] $presets List of preset definitions.
160 */
161 $presets = apply_filters( 'openstation_workspace_presets', $presets );
162
163 if ( ! is_array( $presets ) ) {
164 return array();
165 }
166
167 $clean = array();
168 foreach ( $presets as $preset ) {
169 if ( ! openstation_workspace_preset_requirements_met( $preset ) ) {
170 continue;
171 }
172 $entry = openstation_sanitize_workspace_preset( $preset );
173 if ( null !== $entry ) {
174 $clean[] = $entry;
175 }
176 }
177 return $clean;
178 }
179
180 /**
181 * Whether the plugins a template is built around are active here.
182 *
183 * `requires` is a list of plugin basenames — `woocommerce/woocommerce.php`,
184 * the same string `is_plugin_active()` takes — and every one of them has
185 * to be active or the template is not offered at all. A template that
186 * names none is always offered.
187 *
188 * The gate runs after the `openstation_workspace_presets` filter, so a
189 * site that wants a template anyway can unset its `requires` there.
190 *
191 * @param mixed $preset Raw preset definition.
192 * @return bool Whether the template may be offered.
193 */
194 function openstation_workspace_preset_requirements_met( $preset ) {
195 if ( ! is_array( $preset ) || empty( $preset['requires'] ) || ! is_array( $preset['requires'] ) ) {
196 return true;
197 }
198 if ( ! function_exists( 'is_plugin_active' ) ) {
199 require_once ABSPATH . 'wp-admin/includes/plugin.php';
200 }
201 foreach ( $preset['requires'] as $plugin ) {
202 if ( ! is_string( $plugin ) ) {
203 continue;
204 }
205 // A basename is a path, so the traversal characters go — the
206 // value is compared against `active_plugins`, never opened.
207 $plugin = str_replace( '..', '', substr( preg_replace( '#[^A-Za-z0-9_./-]#', '', $plugin ), 0, 256 ) );
208 if ( '' === $plugin || ! is_plugin_active( $plugin ) ) {
209 return false;
210 }
211 }
212 return true;
213 }
214
215 /**
216 * Sanitizes a launch entry's `place` — where a window goes, as
217 * fractions of the work area.
218 *
219 * Four numbers in `[0, 1]`, width and height at least 5% so a saved
220 * window can never come back as a sliver the user cannot grab. Null
221 * for anything else: the window then lands wherever the arrangement
222 * puts it, which is what an entry written before positions does.
223 *
224 * @param mixed $raw Raw place from the payload.
225 * @return array|null Sanitized place, or null.
226 */
227 function openstation_sanitize_workspace_place( $raw ) {
228 if ( ! is_array( $raw ) ) {
229 return null;
230 }
231 $out = array();
232 foreach ( array( 'x', 'y', 'width', 'height' ) as $key ) {
233 if ( ! isset( $raw[ $key ] ) || ! is_numeric( $raw[ $key ] ) ) {
234 return null;
235 }
236 $v = (float) $raw[ $key ];
237 if ( ! is_finite( $v ) ) {
238 return null;
239 }
240 $out[ $key ] = round( max( 0.0, min( 1.0, $v ) ), 4 );
241 }
242 if ( $out['width'] < 0.05 || $out['height'] < 0.05 ) {
243 return null;
244 }
245 return $out;
246 }
247
248 /**
249 * Sanitizes a workspace's appearance patch.
250 *
251 * Keys outside {@see OPENSTATION_WORKSPACE_APPEARANCE_KEYS} are
252 * dropped, and so is any value that isn't a scalar or a plain array —
253 * the settings layer's own deserializer validates the shapes, so this
254 * only has to guarantee the patch cannot reach a key it has no
255 * business setting, and cannot carry an object graph into user meta.
256 *
257 * `wallpaperSettings`, `customGradient` and `customImage` are the
258 * array-valued members, so arrays are allowed but bounded by
259 * {@see OPENSTATION_WORKSPACE_APPEARANCE_MAX_DEPTH} — exactly the
260 * nesting the deepest of them reaches, and nothing below it.
261 *
262 * @param mixed $raw Raw appearance patch.
263 * @return array Sanitized patch, possibly empty.
264 */
265 function openstation_sanitize_workspace_appearance( $raw ) {
266 if ( ! is_array( $raw ) ) {
267 return array();
268 }
269 $clean = array();
270 foreach ( OPENSTATION_WORKSPACE_APPEARANCE_KEYS as $key ) {
271 if ( ! array_key_exists( $key, $raw ) ) {
272 continue;
273 }
274 $value = $raw[ $key ];
275 if ( is_scalar( $value ) || null === $value ) {
276 $clean[ $key ] = is_string( $value ) ? substr( wp_strip_all_tags( $value ), 0, 512 ) : $value;
277 continue;
278 }
279 if ( is_array( $value ) ) {
280 $clean[ $key ] = openstation_sanitize_workspace_appearance_branch(
281 $value,
282 OPENSTATION_WORKSPACE_APPEARANCE_MAX_DEPTH
283 );
284 }
285 }
286 return $clean;
287 }
288
289 /**
290 * Depth-bounded scalar filter for an appearance value's sub-arrays.
291 *
292 * @param array $value Raw sub-array.
293 * @param int $depth Remaining levels to descend.
294 * @return array Sanitized sub-array.
295 */
296 function openstation_sanitize_workspace_appearance_branch( $value, $depth ) {
297 $out = array();
298 foreach ( $value as $key => $item ) {
299 $key = substr( preg_replace( '#[^A-Za-z0-9_/.-]#', '', (string) $key ), 0, 128 );
300 if ( '' === $key ) {
301 continue;
302 }
303 if ( is_scalar( $item ) || null === $item ) {
304 $out[ $key ] = is_string( $item ) ? substr( wp_strip_all_tags( $item ), 0, 512 ) : $item;
305 continue;
306 }
307 if ( is_array( $item ) && $depth > 1 ) {
308 $out[ $key ] = openstation_sanitize_workspace_appearance_branch( $item, $depth - 1 );
309 }
310 }
311 return $out;
312 }
313
314 /**
315 * Sanitizes one workspace template.
316 *
317 * Applied to everything the `openstation_workspace_presets` filter
318 * returns, shipped entries included — a template reaches the client in
319 * the shell config blob, and a plugin returning a malformed one should
320 * cost that template rather than the whole switcher.
321 *
322 * Returns `null` for an entry with no usable id.
323 *
324 * @param mixed $raw Raw preset definition.
325 * @return array|null Sanitized preset, or null.
326 */
327 function openstation_sanitize_workspace_preset( $raw ) {
328 if ( ! is_array( $raw ) ) {
329 return null;
330 }
331 $id = isset( $raw['id'] ) ? sanitize_key( (string) $raw['id'] ) : '';
332 if ( '' === $id ) {
333 return null;
334 }
335
336 $layout = isset( $raw['layout'] ) ? (string) $raw['layout'] : 'free';
337 if ( ! in_array( $layout, OPENSTATION_WORKSPACE_LAYOUTS, true ) ) {
338 $layout = 'free';
339 }
340
341 $label = isset( $raw['label'] ) ? wp_strip_all_tags( (string) $raw['label'] ) : '';
342 $color = isset( $raw['color'] ) ? sanitize_hex_color( (string) $raw['color'] ) : '';
343
344 $apps = array();
345 if ( isset( $raw['apps'] ) && is_array( $raw['apps'] ) ) {
346 foreach ( $raw['apps'] as $token ) {
347 if ( ! is_string( $token ) ) {
348 continue;
349 }
350 $token = substr( sanitize_text_field( $token ), 0, 128 );
351 if ( '' !== $token ) {
352 $apps[] = $token;
353 }
354 if ( count( $apps ) >= OPENSTATION_WORKSPACE_MAX_APPS ) {
355 break;
356 }
357 }
358 }
359
360 $widgets = array();
361 if ( isset( $raw['widgets'] ) && is_array( $raw['widgets'] ) ) {
362 foreach ( $raw['widgets'] as $id ) {
363 if ( ! is_string( $id ) ) {
364 continue;
365 }
366 // Namespaced registry keys — the slash is part of the id.
367 $id = substr( preg_replace( '#[^A-Za-z0-9_/-]#', '', $id ), 0, 128 );
368 if ( '' !== $id ) {
369 $widgets[] = $id;
370 }
371 if ( count( $widgets ) >= OPENSTATION_WORKSPACE_MAX_WIDGETS ) {
372 break;
373 }
374 }
375 }
376
377 $windows = array();
378 if ( isset( $raw['windows'] ) && is_array( $raw['windows'] ) ) {
379 foreach ( $raw['windows'] as $win ) {
380 if ( ! is_array( $win ) ) {
381 continue;
382 }
383 $match = isset( $win['match'] ) ? substr( sanitize_text_field( (string) $win['match'] ), 0, 128 ) : '';
384 if ( '' === $match ) {
385 continue;
386 }
387 $entry = array( 'match' => $match );
388 if ( isset( $win['url'] ) && is_string( $win['url'] ) ) {
389 $url = substr( wp_strip_all_tags( $win['url'] ), 0, 512 );
390 if ( '' !== $url ) {
391 $entry['url'] = $url;
392 }
393 }
394 if ( isset( $win['title'] ) && is_string( $win['title'] ) ) {
395 $title = substr( wp_strip_all_tags( $win['title'] ), 0, 128 );
396 if ( '' !== $title ) {
397 $entry['title'] = $title;
398 }
399 }
400 $windows[] = $entry;
401 if ( count( $windows ) >= OPENSTATION_WORKSPACE_MAX_WINDOWS ) {
402 break;
403 }
404 }
405 }
406
407 return array(
408 'appearance' => openstation_sanitize_workspace_appearance( isset( $raw['appearance'] ) ? $raw['appearance'] : null ),
409 'id' => $id,
410 'label' => '' !== $label ? $label : $id,
411 'description' => isset( $raw['description'] ) ? wp_strip_all_tags( (string) $raw['description'] ) : '',
412 'icon' => isset( $raw['icon'] ) ? sanitize_html_class( (string) $raw['icon'] ) : 'dashicons-desktop',
413 'color' => $color ? $color : '',
414 'apps' => $apps,
415 'widgets' => $widgets,
416 'windows' => $windows,
417 'layout' => $layout,
418 'order' => isset( $raw['order'] ) ? (int) $raw['order'] : 0,
419 );
420 }
421
422 /**
423 * Sanitizes one workspace profile from an untrusted session payload.
424 *
425 * Returns `null` for anything that is not a profile, which is the
426 * signal for "this desktop is a plain Space" — the field is optional
427 * and absent is meaningful, so a malformed profile degrades the
428 * desktop rather than the session.
429 *
430 * @param mixed $raw Raw profile from the client.
431 * @return array|null Sanitized profile, or null when there isn't one.
432 */
433 function openstation_sanitize_workspace_profile( $raw ) {
434 if ( ! is_array( $raw ) ) {
435 return null;
436 }
437
438 $layout = isset( $raw['layout'] ) ? (string) $raw['layout'] : 'free';
439 if ( ! in_array( $layout, OPENSTATION_WORKSPACE_LAYOUTS, true ) ) {
440 $layout = 'free';
441 }
442
443 // Colour is a `#rrggbb` accent or empty for "use the shell accent".
444 // `sanitize_hex_color()` returns null for anything else, which we
445 // fold back to empty rather than dropping the whole profile.
446 $color = isset( $raw['color'] ) ? sanitize_hex_color( (string) $raw['color'] ) : '';
447
448 $mode = 'all';
449 $ids = array();
450 if ( isset( $raw['apps'] ) && is_array( $raw['apps'] ) ) {
451 if ( isset( $raw['apps']['mode'] ) && 'only' === $raw['apps']['mode'] ) {
452 $mode = 'only';
453 }
454 if ( isset( $raw['apps']['ids'] ) && is_array( $raw['apps']['ids'] ) ) {
455 foreach ( $raw['apps']['ids'] as $id ) {
456 if ( ! is_string( $id ) && ! is_numeric( $id ) ) {
457 continue;
458 }
459 // Nav ids are slugs derived from admin URLs and window
460 // ids, so the character class is the same one
461 // `sanitize_key()` allows — but NOT `sanitize_key()`
462 // itself, which lowercases: a native window registered
463 // as `wpdcEditor` would be stored as `wpdceditor` and
464 // then match nothing on the client.
465 $id = substr( preg_replace( '/[^A-Za-z0-9_\-]/', '', (string) $id ), 0, 128 );
466 if ( '' === $id ) {
467 continue;
468 }
469 $ids[] = $id;
470 if ( count( $ids ) >= OPENSTATION_WORKSPACE_MAX_APPS ) {
471 break;
472 }
473 }
474 }
475 }
476
477 // Widgets are a separate decision from apps, with a separate rule:
478 // `only` means the column IS these ids, whether or not the user
479 // enabled them globally. See `WorkspaceWidgets` on the JS side.
480 $widget_mode = 'all';
481 $widget_ids = array();
482 if ( isset( $raw['widgets'] ) && is_array( $raw['widgets'] ) ) {
483 if ( isset( $raw['widgets']['mode'] ) && 'only' === $raw['widgets']['mode'] ) {
484 $widget_mode = 'only';
485 }
486 if ( isset( $raw['widgets']['ids'] ) && is_array( $raw['widgets']['ids'] ) ) {
487 foreach ( $raw['widgets']['ids'] as $id ) {
488 if ( ! is_string( $id ) ) {
489 continue;
490 }
491 // Widget ids are namespaced registry keys
492 // (`desktop-mode/post-stats`), so the slash is part of
493 // the id and the character class has to allow it.
494 $id = substr( preg_replace( '#[^A-Za-z0-9_/-]#', '', $id ), 0, 128 );
495 if ( '' === $id ) {
496 continue;
497 }
498 $widget_ids[] = $id;
499 if ( count( $widget_ids ) >= OPENSTATION_WORKSPACE_MAX_WIDGETS ) {
500 break;
501 }
502 }
503 }
504 }
505
506 $windows = array();
507 if ( isset( $raw['windows'] ) && is_array( $raw['windows'] ) ) {
508 foreach ( $raw['windows'] as $win ) {
509 if ( ! is_array( $win ) ) {
510 continue;
511 }
512 $match = isset( $win['match'] ) ? sanitize_text_field( (string) $win['match'] ) : '';
513 if ( '' === $match ) {
514 continue;
515 }
516 $entry = array( 'match' => substr( $match, 0, 128 ) );
517 if ( isset( $win['url'] ) && is_string( $win['url'] ) ) {
518 // Relative by design — a template has to survive being
519 // read on a subdirectory install — so this is not a URL
520 // validator. It strips markup and bounds the length;
521 // the client resolves it against wp-admin and the
522 // window manager refuses anything that lands outside.
523 $url = substr( wp_strip_all_tags( $win['url'] ), 0, 512 );
524 if ( '' !== $url ) {
525 $entry['url'] = $url;
526 }
527 }
528 if ( isset( $win['title'] ) && is_string( $win['title'] ) ) {
529 $title = substr( wp_strip_all_tags( $win['title'] ), 0, 128 );
530 if ( '' !== $title ) {
531 $entry['title'] = $title;
532 }
533 }
534 // Where the window goes — cells or fractions of the work
535 // area, both of which survive a different display. See
536 // `openstation_sanitize_workspace_place()`.
537 $grid_span = openstation_sanitize_session_grid_span( $win['gridSpan'] ?? null );
538 if ( null !== $grid_span ) {
539 $entry['gridSpan'] = $grid_span;
540 }
541 $place = openstation_sanitize_workspace_place( $win['place'] ?? null );
542 if ( null !== $place ) {
543 $entry['place'] = $place;
544 }
545 $windows[] = $entry;
546 if ( count( $windows ) >= OPENSTATION_WORKSPACE_MAX_WINDOWS ) {
547 break;
548 }
549 }
550 }
551
552 return array(
553 'appearance' => openstation_sanitize_workspace_appearance( isset( $raw['appearance'] ) ? $raw['appearance'] : null ),
554 'preset' => isset( $raw['preset'] ) ? substr( sanitize_key( (string) $raw['preset'] ), 0, 64 ) : '',
555 'icon' => isset( $raw['icon'] ) ? sanitize_html_class( (string) $raw['icon'] ) : 'dashicons-desktop',
556 'color' => $color ? $color : '',
557 'apps' => array(
558 'mode' => $mode,
559 'ids' => $ids,
560 ),
561 'widgets' => array(
562 'mode' => $widget_mode,
563 'ids' => $widget_ids,
564 ),
565 'windows' => $windows,
566 'layout' => $layout,
567 // Absent means "the launch list has not run", and a workspace
568 // restored mid-provision would otherwise open its windows a
569 // second time on top of the ones the session just restored.
570 'provisioned' => ! empty( $raw['provisioned'] ),
571 );
572 }
573