'commerce', 'label' => __( 'Commerce', 'desktop-mode' ), 'description' => __( 'A shop floor. WooCommerce orders, products and analytics side by side; everything that is not commerce leaves the rails.', 'desktop-mode' ), 'icon' => 'dashicons-cart', 'color' => '#7f54b3', 'layout' => 'columns', 'order' => 10, ), array( 'id' => 'learning', 'label' => __( 'Learning', 'desktop-mode' ), 'description' => __( 'A course studio. Sensei courses, lessons and learners tiled together, so moving between them is a glance rather than a navigation.', 'desktop-mode' ), 'icon' => 'dashicons-welcome-learn-more', 'color' => '#43a047', 'layout' => 'tile', 'order' => 20, ), array( 'id' => 'publishing', 'label' => __( 'Publishing', 'desktop-mode' ), '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' ), 'icon' => 'dashicons-edit-page', 'color' => '#c8102e', 'layout' => 'focus', 'order' => 30, ), ); /** * Filters the workspace templates offered in the switcher. * * A template added here is a complete one: give it `apps` and * `windows` (lists of match tokens — see * `openstation_sanitize_workspace_preset()`) and the client will * resolve them against the live navigation the same way it * resolves a built-in's. The three shipped entries deliberately * carry neither, because the client already has their token lists * and duplicating them here would be two places to keep in step. * * @param array[] $presets List of preset definitions. */ $presets = apply_filters( 'openstation_workspace_presets', $presets ); if ( ! is_array( $presets ) ) { return array(); } $clean = array(); foreach ( $presets as $preset ) { $entry = openstation_sanitize_workspace_preset( $preset ); if ( null !== $entry ) { $clean[] = $entry; } } return $clean; } /** * Sanitizes a launch entry's `place` — where a window goes, as * fractions of the work area. * * Four numbers in `[0, 1]`, width and height at least 5% so a saved * window can never come back as a sliver the user cannot grab. Null * for anything else: the window then lands wherever the arrangement * puts it, which is what an entry written before positions does. * * @param mixed $raw Raw place from the payload. * @return array|null Sanitized place, or null. */ function openstation_sanitize_workspace_place( $raw ) { if ( ! is_array( $raw ) ) { return null; } $out = array(); foreach ( array( 'x', 'y', 'width', 'height' ) as $key ) { if ( ! isset( $raw[ $key ] ) || ! is_numeric( $raw[ $key ] ) ) { return null; } $v = (float) $raw[ $key ]; if ( ! is_finite( $v ) ) { return null; } $out[ $key ] = round( max( 0.0, min( 1.0, $v ) ), 4 ); } if ( $out['width'] < 0.05 || $out['height'] < 0.05 ) { return null; } return $out; } /** * Sanitizes a workspace's appearance patch. * * Keys outside {@see OPENSTATION_WORKSPACE_APPEARANCE_KEYS} are * dropped, and so is any value that isn't a scalar or a plain array — * the settings layer's own deserializer validates the shapes, so this * only has to guarantee the patch cannot reach a key it has no * business setting, and cannot carry an object graph into user meta. * * `wallpaperSettings`, `customGradient` and `customImage` are the * array-valued members, so arrays are allowed but bounded by * {@see OPENSTATION_WORKSPACE_APPEARANCE_MAX_DEPTH} — exactly the * nesting the deepest of them reaches, and nothing below it. * * @param mixed $raw Raw appearance patch. * @return array Sanitized patch, possibly empty. */ function openstation_sanitize_workspace_appearance( $raw ) { if ( ! is_array( $raw ) ) { return array(); } $clean = array(); foreach ( OPENSTATION_WORKSPACE_APPEARANCE_KEYS as $key ) { if ( ! array_key_exists( $key, $raw ) ) { continue; } $value = $raw[ $key ]; if ( is_scalar( $value ) || null === $value ) { $clean[ $key ] = is_string( $value ) ? substr( wp_strip_all_tags( $value ), 0, 512 ) : $value; continue; } if ( is_array( $value ) ) { $clean[ $key ] = openstation_sanitize_workspace_appearance_branch( $value, OPENSTATION_WORKSPACE_APPEARANCE_MAX_DEPTH ); } } return $clean; } /** * Depth-bounded scalar filter for an appearance value's sub-arrays. * * @param array $value Raw sub-array. * @param int $depth Remaining levels to descend. * @return array Sanitized sub-array. */ function openstation_sanitize_workspace_appearance_branch( $value, $depth ) { $out = array(); foreach ( $value as $key => $item ) { $key = substr( preg_replace( '#[^A-Za-z0-9_/.-]#', '', (string) $key ), 0, 128 ); if ( '' === $key ) { continue; } if ( is_scalar( $item ) || null === $item ) { $out[ $key ] = is_string( $item ) ? substr( wp_strip_all_tags( $item ), 0, 512 ) : $item; continue; } if ( is_array( $item ) && $depth > 1 ) { $out[ $key ] = openstation_sanitize_workspace_appearance_branch( $item, $depth - 1 ); } } return $out; } /** * Sanitizes one workspace template. * * Applied to everything the `openstation_workspace_presets` filter * returns, shipped entries included — a template reaches the client in * the shell config blob, and a plugin returning a malformed one should * cost that template rather than the whole switcher. * * Returns `null` for an entry with no usable id. * * @param mixed $raw Raw preset definition. * @return array|null Sanitized preset, or null. */ function openstation_sanitize_workspace_preset( $raw ) { if ( ! is_array( $raw ) ) { return null; } $id = isset( $raw['id'] ) ? sanitize_key( (string) $raw['id'] ) : ''; if ( '' === $id ) { return null; } $layout = isset( $raw['layout'] ) ? (string) $raw['layout'] : 'free'; if ( ! in_array( $layout, OPENSTATION_WORKSPACE_LAYOUTS, true ) ) { $layout = 'free'; } $label = isset( $raw['label'] ) ? wp_strip_all_tags( (string) $raw['label'] ) : ''; $color = isset( $raw['color'] ) ? sanitize_hex_color( (string) $raw['color'] ) : ''; $apps = array(); if ( isset( $raw['apps'] ) && is_array( $raw['apps'] ) ) { foreach ( $raw['apps'] as $token ) { if ( ! is_string( $token ) ) { continue; } $token = substr( sanitize_text_field( $token ), 0, 128 ); if ( '' !== $token ) { $apps[] = $token; } if ( count( $apps ) >= OPENSTATION_WORKSPACE_MAX_APPS ) { break; } } } $widgets = array(); if ( isset( $raw['widgets'] ) && is_array( $raw['widgets'] ) ) { foreach ( $raw['widgets'] as $id ) { if ( ! is_string( $id ) ) { continue; } // Namespaced registry keys — the slash is part of the id. $id = substr( preg_replace( '#[^A-Za-z0-9_/-]#', '', $id ), 0, 128 ); if ( '' !== $id ) { $widgets[] = $id; } if ( count( $widgets ) >= OPENSTATION_WORKSPACE_MAX_WIDGETS ) { break; } } } $windows = array(); if ( isset( $raw['windows'] ) && is_array( $raw['windows'] ) ) { foreach ( $raw['windows'] as $win ) { if ( ! is_array( $win ) ) { continue; } $match = isset( $win['match'] ) ? substr( sanitize_text_field( (string) $win['match'] ), 0, 128 ) : ''; if ( '' === $match ) { continue; } $entry = array( 'match' => $match ); if ( isset( $win['url'] ) && is_string( $win['url'] ) ) { $url = substr( wp_strip_all_tags( $win['url'] ), 0, 512 ); if ( '' !== $url ) { $entry['url'] = $url; } } if ( isset( $win['title'] ) && is_string( $win['title'] ) ) { $title = substr( wp_strip_all_tags( $win['title'] ), 0, 128 ); if ( '' !== $title ) { $entry['title'] = $title; } } $windows[] = $entry; if ( count( $windows ) >= OPENSTATION_WORKSPACE_MAX_WINDOWS ) { break; } } } return array( 'appearance' => openstation_sanitize_workspace_appearance( isset( $raw['appearance'] ) ? $raw['appearance'] : null ), 'id' => $id, 'label' => '' !== $label ? $label : $id, 'description' => isset( $raw['description'] ) ? wp_strip_all_tags( (string) $raw['description'] ) : '', 'icon' => isset( $raw['icon'] ) ? sanitize_html_class( (string) $raw['icon'] ) : 'dashicons-desktop', 'color' => $color ? $color : '', 'apps' => $apps, 'widgets' => $widgets, 'windows' => $windows, 'layout' => $layout, 'order' => isset( $raw['order'] ) ? (int) $raw['order'] : 0, ); } /** * Sanitizes one workspace profile from an untrusted session payload. * * Returns `null` for anything that is not a profile, which is the * signal for "this desktop is a plain Space" — the field is optional * and absent is meaningful, so a malformed profile degrades the * desktop rather than the session. * * @param mixed $raw Raw profile from the client. * @return array|null Sanitized profile, or null when there isn't one. */ function openstation_sanitize_workspace_profile( $raw ) { if ( ! is_array( $raw ) ) { return null; } $layout = isset( $raw['layout'] ) ? (string) $raw['layout'] : 'free'; if ( ! in_array( $layout, OPENSTATION_WORKSPACE_LAYOUTS, true ) ) { $layout = 'free'; } // Colour is a `#rrggbb` accent or empty for "use the shell accent". // `sanitize_hex_color()` returns null for anything else, which we // fold back to empty rather than dropping the whole profile. $color = isset( $raw['color'] ) ? sanitize_hex_color( (string) $raw['color'] ) : ''; $mode = 'all'; $ids = array(); if ( isset( $raw['apps'] ) && is_array( $raw['apps'] ) ) { if ( isset( $raw['apps']['mode'] ) && 'only' === $raw['apps']['mode'] ) { $mode = 'only'; } if ( isset( $raw['apps']['ids'] ) && is_array( $raw['apps']['ids'] ) ) { foreach ( $raw['apps']['ids'] as $id ) { if ( ! is_string( $id ) && ! is_numeric( $id ) ) { continue; } // Nav ids are slugs derived from admin URLs and window // ids, so the character class is the same one // `sanitize_key()` allows — but NOT `sanitize_key()` // itself, which lowercases: a native window registered // as `wpdcEditor` would be stored as `wpdceditor` and // then match nothing on the client. $id = substr( preg_replace( '/[^A-Za-z0-9_\-]/', '', (string) $id ), 0, 128 ); if ( '' === $id ) { continue; } $ids[] = $id; if ( count( $ids ) >= OPENSTATION_WORKSPACE_MAX_APPS ) { break; } } } } // Widgets are a separate decision from apps, with a separate rule: // `only` means the column IS these ids, whether or not the user // enabled them globally. See `WorkspaceWidgets` on the JS side. $widget_mode = 'all'; $widget_ids = array(); if ( isset( $raw['widgets'] ) && is_array( $raw['widgets'] ) ) { if ( isset( $raw['widgets']['mode'] ) && 'only' === $raw['widgets']['mode'] ) { $widget_mode = 'only'; } if ( isset( $raw['widgets']['ids'] ) && is_array( $raw['widgets']['ids'] ) ) { foreach ( $raw['widgets']['ids'] as $id ) { if ( ! is_string( $id ) ) { continue; } // Widget ids are namespaced registry keys // (`desktop-mode/post-stats`), so the slash is part of // the id and the character class has to allow it. $id = substr( preg_replace( '#[^A-Za-z0-9_/-]#', '', $id ), 0, 128 ); if ( '' === $id ) { continue; } $widget_ids[] = $id; if ( count( $widget_ids ) >= OPENSTATION_WORKSPACE_MAX_WIDGETS ) { break; } } } } $windows = array(); if ( isset( $raw['windows'] ) && is_array( $raw['windows'] ) ) { foreach ( $raw['windows'] as $win ) { if ( ! is_array( $win ) ) { continue; } $match = isset( $win['match'] ) ? sanitize_text_field( (string) $win['match'] ) : ''; if ( '' === $match ) { continue; } $entry = array( 'match' => substr( $match, 0, 128 ) ); if ( isset( $win['url'] ) && is_string( $win['url'] ) ) { // Relative by design — a template has to survive being // read on a subdirectory install — so this is not a URL // validator. It strips markup and bounds the length; // the client resolves it against wp-admin and the // window manager refuses anything that lands outside. $url = substr( wp_strip_all_tags( $win['url'] ), 0, 512 ); if ( '' !== $url ) { $entry['url'] = $url; } } if ( isset( $win['title'] ) && is_string( $win['title'] ) ) { $title = substr( wp_strip_all_tags( $win['title'] ), 0, 128 ); if ( '' !== $title ) { $entry['title'] = $title; } } // Where the window goes — cells or fractions of the work // area, both of which survive a different display. See // `openstation_sanitize_workspace_place()`. $grid_span = openstation_sanitize_session_grid_span( $win['gridSpan'] ?? null ); if ( null !== $grid_span ) { $entry['gridSpan'] = $grid_span; } $place = openstation_sanitize_workspace_place( $win['place'] ?? null ); if ( null !== $place ) { $entry['place'] = $place; } $windows[] = $entry; if ( count( $windows ) >= OPENSTATION_WORKSPACE_MAX_WINDOWS ) { break; } } } return array( 'appearance' => openstation_sanitize_workspace_appearance( isset( $raw['appearance'] ) ? $raw['appearance'] : null ), 'preset' => isset( $raw['preset'] ) ? substr( sanitize_key( (string) $raw['preset'] ), 0, 64 ) : '', 'icon' => isset( $raw['icon'] ) ? sanitize_html_class( (string) $raw['icon'] ) : 'dashicons-desktop', 'color' => $color ? $color : '', 'apps' => array( 'mode' => $mode, 'ids' => $ids, ), 'widgets' => array( 'mode' => $widget_mode, 'ids' => $widget_ids, ), 'windows' => $windows, 'layout' => $layout, // Absent means "the launch list has not run", and a workspace // restored mid-provision would otherwise open its windows a // second time on top of the ones the session just restored. 'provisioned' => ! empty( $raw['provisioned'] ), ); }