PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 1.0.1
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v1.0.1
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 / desktop-themes / manifest.php

manifest.php in OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin 1.0.1, at includes/desktop-themes/manifest.php

1,187 lines 39.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * OpenStation — Desktop-theme manifest sanitizer.
4 *
5 * Pure functions: no filesystem writes, no option reads. The one
6 * dependency on the outside world is an injected `$asset_resolver`
7 * callable, so the same sanitizer serves both intake paths:
8 *
9 * - ZIP uploads pass a resolver that validates a path INSIDE the
10 * staging directory and hands back the theme-relative path.
11 * - Code registrations (`openstation_register_desktop_theme()`)
12 * pass a resolver that validates an absolute http(s) URL.
13 *
14 * Both resolvers take the same two arguments —
15 * `fn( string $path, string $kind ): string|false` — where `$kind`
16 * is `'image'` or `'font'` and selects the extension allowlist. A
17 * font reference can therefore never resolve through the icon path,
18 * or vice versa.
19 *
20 * Validation posture, in two tiers:
21 *
22 * - **Fatal** (returns `WP_Error`): `manifestVersion` (`1` or `2`),
23 * `id`, `name`. Without those there is no theme to speak of.
24 * - **Everything else drops and continues.** A bad token, a
25 * missing icon file, an unknown slot — the offending entry is
26 * removed and the rest of the theme installs. That IS the
27 * fallback contract: whatever the manifest doesn't say, the
28 * system default keeps saying.
29 *
30 * @package OpenStation
31 */
32
33 defined( 'ABSPATH' ) || exit;
34
35 /**
36 * Whether a token VALUE is safe to emit into a compiled stylesheet.
37 *
38 * The compiler writes `key: value;` declarations verbatim, so this
39 * is the only thing standing between an author string and the
40 * stylesheet. The rules:
41 *
42 * - 1–256 characters.
43 * - Charset allowlist. `;` `{` `}` `@` `\` `<` `>` `!` and the
44 * backtick are simply not in it, which kills declaration
45 * escape, at-rule injection, `!important` overrides, and
46 * markup breakout in one stroke.
47 * - **Quotes ARE allowed**, and that is deliberate rather than an
48 * oversight — `font-family: "Segoe UI", sans-serif` needs them.
49 * They are safe because of where a value can end up, which is
50 * only ever one of two places:
51 * 1. A custom-property declaration in a compiled stylesheet
52 * (`--x: <value>;`). A quote opens a CSS string; it cannot
53 * end the declaration, because `;` `{` `}` are banned, and
54 * it cannot end the STYLESHEET, because `<` `>` are banned
55 * so `</style>` is unwritable.
56 * 2. That same stylesheet handed to the shell as `cssText`,
57 * which `src/desktop-themes/apply.ts` assigns via
58 * `style.textContent` — never `innerHTML`.
59 * An unbalanced quote therefore breaks the author's own
60 * declaration and nothing else. If a future consumer ever
61 * interpolates a token value into an HTML attribute or a JS
62 * string literal, THAT consumer has to escape, and this note is
63 * the reason why.
64 * - No CSS comment sequences (`/*`, `*​/`) — `/` and `*` are
65 * allowed individually because shorthand values need them.
66 * - No `url(`, `image-set(`, `element(`, `attr(`, `var(`, or
67 * `expression`. External references are PHP's job: the compiler
68 * generates every `url()` in the output itself from a resolved,
69 * `rawurlencode`d path. `var()` is banned so an author can't
70 * alias a property we didn't intend them to reach.
71 * - Balanced parentheses.
72 *
73 * @param mixed $value Candidate value.
74 * @return bool
75 */
76 function openstation_desktop_theme_is_safe_css_value( $value ) {
77 if ( ! is_string( $value ) ) {
78 return false;
79 }
80 $value = trim( $value );
81 if ( '' === $value || strlen( $value ) > 256 ) {
82 return false;
83 }
84 if ( ! preg_match( '~^[A-Za-z0-9\s#%.,()/*+\-_\'"]+$~', $value ) ) {
85 return false;
86 }
87 if ( false !== strpos( $value, '/*' ) || false !== strpos( $value, '*/' ) ) {
88 return false;
89 }
90 $lower = strtolower( $value );
91 $banned = array( 'url(', 'image-set(', 'element(', 'attr(', 'var(', 'expression', 'javascript' );
92 foreach ( $banned as $needle ) {
93 if ( false !== strpos( $lower, $needle ) ) {
94 return false;
95 }
96 }
97 // Balanced parentheses, never negative.
98 $depth = 0;
99 $len = strlen( $value );
100 for ( $i = 0; $i < $len; $i++ ) {
101 if ( '(' === $value[ $i ] ) {
102 ++$depth;
103 } elseif ( ')' === $value[ $i ] ) {
104 --$depth;
105 if ( $depth < 0 ) {
106 return false;
107 }
108 }
109 }
110 return 0 === $depth;
111 }
112
113 /**
114 * Sanitize the `tokens` block: a map of custom-property name =>
115 * value. Unknown property names and unsafe values drop.
116 *
117 * @internal
118 *
119 * @param mixed $raw Raw `tokens` value.
120 * @return array<string,string>
121 */
122 function openstation_sanitize_desktop_theme_tokens( $raw ) {
123 if ( ! is_array( $raw ) ) {
124 return array();
125 }
126 $out = array();
127 $count = 0;
128 foreach ( $raw as $key => $value ) {
129 if ( $count >= 512 ) {
130 break;
131 }
132 if ( ! is_string( $key ) ) {
133 continue;
134 }
135 $key = strtolower( trim( $key ) );
136 // Three namespaces are themable:
137 //
138 // --os-* the shell's own tokens (chrome, dock,
139 // desktop, window frame).
140 // --os-ui-* the `<os-*>` component kit. Window
141 // BODIES are built from those components,
142 // and `--os-ui-*` is the kit's documented
143 // theming contract (see
144 // `src/ui/core/tokens.ts`). Without this a
145 // theme could restyle the chrome around a
146 // window but not a single thing inside it.
147 // --wp-admin-theme-color
148 // the one Core property the shell already
149 // writes at runtime (the admin accent).
150 //
151 // Everything else is dropped: a theme must not be able to
152 // reach properties the shell never meant to expose.
153 if (
154 '--wp-admin-theme-color' !== $key
155 && ! preg_match( '/^--os-[a-z0-9-]+$/', $key )
156 && ! preg_match( '/^--os-ui-[a-z0-9-]+$/', $key )
157 ) {
158 continue;
159 }
160 if ( ! openstation_desktop_theme_is_safe_css_value( $value ) ) {
161 continue;
162 }
163 $out[ $key ] = trim( (string) $value );
164 ++$count;
165 }
166 return $out;
167 }
168
169 /**
170 * Whether a value is usable as a CSS colour.
171 *
172 * Deliberately narrower than the general value grammar: this one is
173 * painted as a fill, so a length or a gradient would be nonsense
174 * rather than dangerous. Accepts `currentColor`, hex in all four
175 * lengths, the functional notations, and bare keywords.
176 *
177 * `currentColor` is the interesting one — it means "whatever the
178 * surface I land on is already using for text", which is how one
179 * silhouette iconset stays legible on a dark dock, a light title bar,
180 * and a red danger-hover without the author knowing any of them.
181 *
182 * @param mixed $value Candidate.
183 * @return bool
184 */
185 function openstation_desktop_theme_is_color_value( $value ) {
186 if ( ! is_string( $value ) ) {
187 return false;
188 }
189 $value = trim( preg_replace( '/\s+/', ' ', $value ) );
190 if ( '' === $value || strlen( $value ) > 64 ) {
191 return false;
192 }
193 // The general grammar is still the floor — it is what bans `;`,
194 // `{`, `@`, quotes, comments and `var()`.
195 if ( ! openstation_desktop_theme_is_safe_css_value( $value ) ) {
196 return false;
197 }
198 if ( 0 === strcasecmp( 'currentcolor', $value ) ) {
199 // Normalized to the spelling CSS authors expect to read back.
200 return true;
201 }
202 if ( preg_match( '/^#([0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/i', $value ) ) {
203 return true;
204 }
205 if ( preg_match( '/^(rgb|rgba|hsl|hsla|hwb|lab|lch|oklab|oklch|color)\([0-9a-z%.,\/ +-]+\)$/i', $value ) ) {
206 return true;
207 }
208 // Bare keyword (`transparent`, `rebeccapurple`, …). Letters only,
209 // so nothing else can hide in here.
210 return (bool) preg_match( '/^[a-z]{3,24}$/i', $value );
211 }
212
213 /**
214 * Sanitize the `icons` block: a map of slot => icon descriptor.
215 *
216 * Accepted descriptors:
217 * - `{ "type": "image", "path": "icons/close.svg" }`
218 * - `{ "type": "dashicon", "name": "dashicons-no-alt" }`
219 *
220 * Either shape may carry `color`, which decides HOW the glyph is
221 * painted, not just what colour it comes out:
222 *
223 * - **absent** — today's behaviour. An image paints as an `<img>`
224 * and keeps the colours it was drawn with.
225 * - **present** — the glyph is tinted. A dashicon simply takes the
226 * colour; an image is painted as a `currentColor`-style CSS MASK,
227 * so only its alpha channel is used and the fill comes from here.
228 *
229 * That distinction is the whole point: a monochrome iconset drawn in
230 * black is invisible on a dark dock as an `<img>`, and perfect as a
231 * mask.
232 *
233 * @internal
234 *
235 * @param mixed $raw Raw `icons` value.
236 * @param callable $asset_resolver `fn( string $path, string $kind ): string|false`.
237 * @param string $default_color Manifest-level `iconColor`, applied
238 * to any icon that doesn't set its
239 * own. `''` for none.
240 * @return array<string,array>
241 */
242 function openstation_sanitize_desktop_theme_icons( $raw, $asset_resolver, $default_color = '' ) {
243 if ( ! is_array( $raw ) ) {
244 return array();
245 }
246 $allowed = array_flip( array_map( 'strval', openstation_desktop_theme_icon_slots() ) );
247 $out = array();
248 $count = 0;
249 foreach ( $raw as $slot => $descriptor ) {
250 if ( $count >= 256 ) {
251 break;
252 }
253 if ( ! is_string( $slot ) ) {
254 continue;
255 }
256 $slot = trim( $slot );
257 // Either a known fixed slot, or the `APP:<slug>` pattern.
258 $is_app = 0 === strpos( $slot, 'APP:' );
259 if ( $is_app ) {
260 $app_slug = sanitize_key( substr( $slot, 4 ) );
261 if ( '' === $app_slug ) {
262 continue;
263 }
264 $slot = 'APP:' . $app_slug;
265 } elseif ( ! isset( $allowed[ $slot ] ) ) {
266 continue;
267 }
268
269 if ( ! is_array( $descriptor ) ) {
270 continue;
271 }
272 $type = isset( $descriptor['type'] ) ? (string) $descriptor['type'] : '';
273
274 // `color` falls back to the manifest-wide `iconColor`. The
275 // literal string `none` is the opt-OUT: it lets one icon in an
276 // otherwise-tinted set keep its own colours (a brand mark, a
277 // multi-colour app icon) without the author having to drop the
278 // default for everything else.
279 $color = '';
280 if ( isset( $descriptor['color'] ) && is_string( $descriptor['color'] ) ) {
281 $candidate = trim( $descriptor['color'] );
282 if ( 0 === strcasecmp( 'none', $candidate ) ) {
283 $color = 'none';
284 } elseif ( openstation_desktop_theme_is_color_value( $candidate ) ) {
285 $color = openstation_desktop_theme_normalize_color( $candidate );
286 }
287 }
288 if ( '' === $color ) {
289 $color = $default_color;
290 }
291 if ( 'none' === $color ) {
292 $color = '';
293 }
294
295 if ( 'dashicon' === $type ) {
296 $name = isset( $descriptor['name'] ) ? strtolower( trim( (string) $descriptor['name'] ) ) : '';
297 if ( ! preg_match( '/^dashicons-[a-z0-9-]+$/', $name ) ) {
298 continue;
299 }
300 $entry = array(
301 'type' => 'dashicon',
302 'name' => $name,
303 );
304 if ( '' !== $color ) {
305 $entry['color'] = $color;
306 }
307 $out[ $slot ] = $entry;
308 ++$count;
309 continue;
310 }
311
312 if ( 'image' === $type ) {
313 $path = isset( $descriptor['path'] ) ? (string) $descriptor['path'] : '';
314 $ref = call_user_func( $asset_resolver, $path, 'image' );
315 if ( ! is_string( $ref ) || '' === $ref ) {
316 continue;
317 }
318 $entry = array(
319 'type' => 'image',
320 'path' => $ref,
321 );
322 if ( '' !== $color ) {
323 $entry['color'] = $color;
324 }
325 $out[ $slot ] = $entry;
326 ++$count;
327 }
328 }
329 return $out;
330 }
331
332 /**
333 * Normalize a validated colour to its canonical spelling.
334 *
335 * Only `currentColor` actually changes: CSS is case-insensitive, but
336 * the value is echoed back to theme authors through the payload and
337 * the JS API, and `currentcolor` reads like a typo.
338 *
339 * @internal
340 *
341 * @param string $value Validated colour.
342 * @return string
343 */
344 function openstation_desktop_theme_normalize_color( $value ) {
345 $value = trim( preg_replace( '/\s+/', ' ', (string) $value ) );
346 return 0 === strcasecmp( 'currentcolor', $value ) ? 'currentColor' : $value;
347 }
348
349 /**
350 * Whether a `background-size`-shaped value is well-formed.
351 *
352 * Accepts `auto` / `cover` / `contain`, or one-to-two length
353 * components (`px`, `%`, `rem`, `em`, or the `auto` keyword).
354 *
355 * @internal
356 *
357 * @param string $value Candidate.
358 * @return bool
359 */
360 function openstation_desktop_theme_is_size_value( $value ) {
361 $value = strtolower( trim( (string) $value ) );
362 if ( in_array( $value, array( 'auto', 'cover', 'contain' ), true ) ) {
363 return true;
364 }
365 $parts = preg_split( '/\s+/', $value );
366 if ( ! is_array( $parts ) || count( $parts ) < 1 || count( $parts ) > 2 ) {
367 return false;
368 }
369 foreach ( $parts as $part ) {
370 if ( 'auto' === $part ) {
371 continue;
372 }
373 if ( ! preg_match( '/^\d+(\.\d+)?(px|%|rem|em)$/', $part ) ) {
374 return false;
375 }
376 }
377 return true;
378 }
379
380 /**
381 * Whether a `background-position`-shaped value is well-formed.
382 *
383 * Accepts one or two components, each a keyword (`left`, `center`,
384 * `right`, `top`, `bottom`) or a length/percentage — including the
385 * negative offsets a bleeding texture needs.
386 *
387 * `position` is what makes a big detailed texture usable rather than
388 * merely present: `size: auto` + `repeat` tiles the artwork at its
389 * true resolution, and `position` decides where the tiling grid
390 * starts. Without it every texture is pinned to the same origin and
391 * a motif can never be aligned to the surface it decorates.
392 *
393 * @internal
394 *
395 * @param string $value Candidate.
396 * @return bool
397 */
398 function openstation_desktop_theme_is_position_value( $value ) {
399 $value = strtolower( trim( (string) $value ) );
400 if ( '' === $value || strlen( $value ) > 64 ) {
401 return false;
402 }
403 $parts = preg_split( '/\s+/', $value );
404 if ( ! is_array( $parts ) || count( $parts ) < 1 || count( $parts ) > 2 ) {
405 return false;
406 }
407 $keywords = array( 'left', 'right', 'top', 'bottom', 'center' );
408 foreach ( $parts as $part ) {
409 if ( in_array( $part, $keywords, true ) ) {
410 continue;
411 }
412 // A bare `0` is a valid CSS length and the natural way to write
413 // a flush edge, so it is accepted without a unit. Every other
414 // number needs one.
415 if ( preg_match( '/^-?(0|\d+(\.\d+)?(px|%|rem|em))$/', $part ) ) {
416 continue;
417 }
418 return false;
419 }
420 return true;
421 }
422
423 /**
424 * Sanitize the `textures` block: a map of slot => texture
425 * descriptor. Each descriptor's `path` runs through the resolver;
426 * every presentational property is grammar-checked against a closed
427 * enum or a numeric pattern, never a free string.
428 *
429 * @internal
430 *
431 * @param mixed $raw Raw `textures` value.
432 * @param callable $asset_resolver `fn( string $path ): string|false`.
433 * @return array<string,array>
434 */
435 function openstation_sanitize_desktop_theme_textures( $raw, $asset_resolver ) {
436 if ( ! is_array( $raw ) ) {
437 return array();
438 }
439 $slots = openstation_desktop_theme_texture_slots();
440 $out = array();
441 $repeat = array( 'repeat', 'repeat-x', 'repeat-y', 'no-repeat', 'space', 'round' );
442
443 foreach ( $raw as $slot => $descriptor ) {
444 if ( ! is_string( $slot ) || ! isset( $slots[ $slot ] ) || ! is_array( $descriptor ) ) {
445 continue;
446 }
447 $expected = isset( $slots[ $slot ]['type'] ) ? (string) $slots[ $slot ]['type'] : 'image';
448 $type = isset( $descriptor['type'] ) ? (string) $descriptor['type'] : $expected;
449 if ( $type !== $expected ) {
450 continue;
451 }
452
453 $path = isset( $descriptor['path'] ) ? (string) $descriptor['path'] : '';
454 $ref = call_user_func( $asset_resolver, $path );
455 if ( ! is_string( $ref ) || '' === $ref ) {
456 continue;
457 }
458
459 $entry = array(
460 'type' => $type,
461 'path' => $ref,
462 );
463
464 if ( 'border-image' === $type ) {
465 // `slice` — 1–4 unitless numbers, optional trailing `fill`.
466 if ( isset( $descriptor['slice'] ) && is_string( $descriptor['slice'] ) ) {
467 $slice = strtolower( trim( preg_replace( '/\s+/', ' ', $descriptor['slice'] ) ) );
468 if ( preg_match( '/^\d+( \d+){0,3}( fill)?$/', $slice ) ) {
469 $entry['slice'] = $slice;
470 }
471 }
472 // `width` — 1–4 lengths (unitless allowed: multiples of
473 // the border width, per the border-image-width grammar).
474 if ( isset( $descriptor['width'] ) && is_string( $descriptor['width'] ) ) {
475 $width = strtolower( trim( preg_replace( '/\s+/', ' ', $descriptor['width'] ) ) );
476 $parts = preg_split( '/ /', $width );
477 if ( is_array( $parts ) && count( $parts ) >= 1 && count( $parts ) <= 4 ) {
478 $ok = true;
479 foreach ( $parts as $part ) {
480 if ( ! preg_match( '/^\d+(\.\d+)?(px|%|rem|em)?$/', $part ) ) {
481 $ok = false;
482 break;
483 }
484 }
485 if ( $ok ) {
486 $entry['width'] = $width;
487 }
488 }
489 }
490 // `repeat` — 1–2 of the border-image-repeat keywords.
491 if ( isset( $descriptor['repeat'] ) && is_string( $descriptor['repeat'] ) ) {
492 $value = strtolower( trim( preg_replace( '/\s+/', ' ', $descriptor['repeat'] ) ) );
493 $parts = preg_split( '/ /', $value );
494 $allow = array( 'stretch', 'repeat', 'round', 'space' );
495 if ( is_array( $parts ) && count( $parts ) >= 1 && count( $parts ) <= 2 ) {
496 $ok = true;
497 foreach ( $parts as $part ) {
498 if ( ! in_array( $part, $allow, true ) ) {
499 $ok = false;
500 break;
501 }
502 }
503 if ( $ok ) {
504 $entry['repeat'] = $value;
505 }
506 }
507 }
508 } else {
509 if ( isset( $descriptor['repeat'] ) && is_string( $descriptor['repeat'] ) ) {
510 $value = strtolower( trim( $descriptor['repeat'] ) );
511 if ( in_array( $value, $repeat, true ) ) {
512 $entry['repeat'] = $value;
513 }
514 }
515 if ( isset( $descriptor['size'] ) && is_string( $descriptor['size'] ) ) {
516 $value = strtolower( trim( preg_replace( '/\s+/', ' ', $descriptor['size'] ) ) );
517 if ( openstation_desktop_theme_is_size_value( $value ) ) {
518 $entry['size'] = $value;
519 }
520 }
521 if ( isset( $descriptor['position'] ) && is_string( $descriptor['position'] ) ) {
522 $value = strtolower( trim( preg_replace( '/\s+/', ' ', $descriptor['position'] ) ) );
523 if ( openstation_desktop_theme_is_position_value( $value ) ) {
524 $entry['position'] = $value;
525 }
526 }
527 }
528
529 $out[ $slot ] = $entry;
530 }
531 return $out;
532 }
533
534 /**
535 * Map a resolved font reference to its `format()` hint.
536 *
537 * The hint is DERIVED, never author-supplied: the extension already
538 * passed the font allowlist, and deriving it removes one more free
539 * string from the compiled output. Works on both a theme-relative
540 * path and an absolute URL (whose query string is discarded first).
541 *
542 * @internal
543 *
544 * @param string $ref Resolved reference.
545 * @return string Format keyword, or `''` when unrecognised.
546 */
547 function openstation_desktop_theme_font_format( $ref ) {
548 $ref = (string) $ref;
549 $path = $ref;
550 if ( preg_match( '~^https?://~i', $ref ) ) {
551 $path = (string) wp_parse_url( $ref, PHP_URL_PATH );
552 }
553 $formats = array(
554 'woff2' => 'woff2',
555 'woff' => 'woff',
556 'ttf' => 'truetype',
557 'otf' => 'opentype',
558 );
559 $ext = strtolower( (string) pathinfo( $path, PATHINFO_EXTENSION ) );
560 return isset( $formats[ $ext ] ) ? $formats[ $ext ] : '';
561 }
562
563 /**
564 * Sanitize the `fonts` block: a list of `@font-face` descriptors.
565 *
566 * ```json
567 * "fonts": [
568 * { "family": "Neon Grotesk", "weight": "400", "style": "normal",
569 * "display": "swap", "src": [ "fonts/neon.woff2", "fonts/neon.woff" ] }
570 * ]
571 * ```
572 *
573 * **Every field is a closed grammar, and `src` is the only one that
574 * reaches the filesystem.** The family name is restricted hard
575 * enough that the compiler can wrap it in double quotes and be done:
576 * no quote, backslash, semicolon, or brace can appear in it, so
577 * there is nothing to escape and no way out of the string. The
578 * `format()` hint is derived from the extension rather than read
579 * from the author, so a face contributes exactly two author-chosen
580 * substrings to the stylesheet — the family name and the file path —
581 * and both are constrained before they get there.
582 *
583 * Sanitization is drop-and-continue at every level: a face with no
584 * usable source disappears, a bad `weight` falls back to the CSS
585 * initial value, and the rest of the theme installs regardless.
586 *
587 * @internal
588 *
589 * @param mixed $raw Raw `fonts` value.
590 * @param callable $asset_resolver `fn( string $path, string $kind ): string|false`.
591 * @return array<int,array>
592 */
593 function openstation_sanitize_desktop_theme_fonts( $raw, $asset_resolver ) {
594 if ( ! is_array( $raw ) ) {
595 return array();
596 }
597 $caps = openstation_desktop_theme_font_caps();
598 $out = array();
599
600 foreach ( $raw as $face ) {
601 if ( count( $out ) >= $caps['max_faces'] ) {
602 break;
603 }
604 if ( ! is_array( $face ) ) {
605 continue;
606 }
607
608 // --- family. Quoted verbatim by the compiler, hence strict. ---
609 $family = isset( $face['family'] ) && is_string( $face['family'] )
610 ? trim( preg_replace( '/\s+/', ' ', $face['family'] ) )
611 : '';
612 if ( ! preg_match( '/^[A-Za-z0-9][A-Za-z0-9 _-]{0,63}$/', $family ) ) {
613 continue;
614 }
615
616 // --- src. A string, or a list of strings, in preference order. ---
617 $sources = array();
618 $raw_src = isset( $face['src'] ) ? $face['src'] : null;
619 if ( is_string( $raw_src ) ) {
620 $raw_src = array( $raw_src );
621 }
622 if ( ! is_array( $raw_src ) ) {
623 continue;
624 }
625 foreach ( $raw_src as $candidate ) {
626 if ( count( $sources ) >= $caps['max_sources'] ) {
627 break;
628 }
629 // Tolerate the `{ "path": … }` object shape too — it is what
630 // icons and textures use, and authors reasonably assume it
631 // generalizes.
632 if ( is_array( $candidate ) && isset( $candidate['path'] ) ) {
633 $candidate = $candidate['path'];
634 }
635 if ( ! is_string( $candidate ) ) {
636 continue;
637 }
638 $ref = call_user_func( $asset_resolver, $candidate, 'font' );
639 if ( ! is_string( $ref ) || '' === $ref ) {
640 continue;
641 }
642 $format = openstation_desktop_theme_font_format( $ref );
643 if ( '' === $format ) {
644 continue;
645 }
646 $sources[] = array(
647 'path' => $ref,
648 'format' => $format,
649 );
650 }
651 if ( empty( $sources ) ) {
652 // A face with nothing to load is not a partially broken
653 // face — it is no face at all.
654 continue;
655 }
656
657 $entry = array(
658 'family' => $family,
659 'src' => $sources,
660 );
661
662 // --- weight. One or two of `normal` / `bold` / 1–1000. ---
663 if ( isset( $face['weight'] ) && ( is_string( $face['weight'] ) || is_int( $face['weight'] ) ) ) {
664 $weight = strtolower( trim( preg_replace( '/\s+/', ' ', (string) $face['weight'] ) ) );
665 $parts = '' === $weight ? array() : explode( ' ', $weight );
666 if ( count( $parts ) >= 1 && count( $parts ) <= 2 ) {
667 $ok = true;
668 foreach ( $parts as $part ) {
669 if ( in_array( $part, array( 'normal', 'bold' ), true ) ) {
670 continue;
671 }
672 if ( preg_match( '/^\d{1,4}$/', $part ) && (int) $part >= 1 && (int) $part <= 1000 ) {
673 continue;
674 }
675 $ok = false;
676 break;
677 }
678 if ( $ok ) {
679 $entry['weight'] = $weight;
680 }
681 }
682 }
683
684 // --- style / display / stretch. Closed enums. ---
685 if ( isset( $face['style'] ) && is_string( $face['style'] ) ) {
686 $style = strtolower( trim( $face['style'] ) );
687 if ( in_array( $style, array( 'normal', 'italic', 'oblique' ), true ) ) {
688 $entry['style'] = $style;
689 }
690 }
691 if ( isset( $face['display'] ) && is_string( $face['display'] ) ) {
692 $display = strtolower( trim( $face['display'] ) );
693 if ( in_array( $display, array( 'auto', 'block', 'swap', 'fallback', 'optional' ), true ) ) {
694 $entry['display'] = $display;
695 }
696 }
697 if ( isset( $face['stretch'] ) && is_string( $face['stretch'] ) ) {
698 $stretch = strtolower( trim( preg_replace( '/\s+/', ' ', $face['stretch'] ) ) );
699 $keywords = array(
700 'ultra-condensed',
701 'extra-condensed',
702 'condensed',
703 'semi-condensed',
704 'normal',
705 'semi-expanded',
706 'expanded',
707 'extra-expanded',
708 'ultra-expanded',
709 );
710 $parts = '' === $stretch ? array() : explode( ' ', $stretch );
711 if ( count( $parts ) >= 1 && count( $parts ) <= 2 ) {
712 $ok = true;
713 foreach ( $parts as $part ) {
714 if ( in_array( $part, $keywords, true ) ) {
715 continue;
716 }
717 if ( preg_match( '/^\d{1,3}(\.\d+)?%$/', $part ) ) {
718 continue;
719 }
720 $ok = false;
721 break;
722 }
723 if ( $ok ) {
724 $entry['stretch'] = $stretch;
725 }
726 }
727 }
728
729 // --- unicodeRange. Subsetted faces live and die by this one. ---
730 if ( isset( $face['unicodeRange'] ) && is_string( $face['unicodeRange'] ) ) {
731 $range = strtoupper( trim( preg_replace( '/\s+/', ' ', $face['unicodeRange'] ) ) );
732 if (
733 strlen( $range ) <= 512
734 && preg_match( '/^U\+[0-9A-F?]{1,6}(-[0-9A-F]{1,6})?( ?, ?U\+[0-9A-F?]{1,6}(-[0-9A-F]{1,6})?){0,31}$/', $range )
735 ) {
736 $entry['unicodeRange'] = $range;
737 }
738 }
739
740 $out[] = $entry;
741 }
742
743 return $out;
744 }
745
746 /**
747 * Sanitize the `wallpapers` block: one or more pickable wallpapers.
748 *
749 * Four author shapes, because all four are things people reasonably
750 * write and none is ambiguous:
751 *
752 * "wallpaper": "textures/desk.png"
753 * "wallpaper": { "path": "textures/desk.png", "size": "cover" }
754 * "wallpapers": [ "a.png", { "path": "b.png", "label": "Dusk" } ]
755 * "wallpapers": { "dusk": { "path": "b.png" } } <- keys are ids
756 *
757 * Always returns a LIST of descriptors, so every consumer downstream
758 * handles exactly one shape.
759 *
760 * ## Ids are a stored preference, so they must be stable
761 *
762 * The user's wallpaper choice persists by id. If ids shifted when an
763 * author reordered their list, a re-upload would silently move every
764 * user onto a different picture. So an id is taken from, in order:
765 * an explicit `id`, the map key, a slug of the `label`, and finally
766 * the image's own filename — never the array index.
767 *
768 * @internal
769 *
770 * @param mixed $raw Raw `wallpaper` / `wallpapers` value.
771 * @param callable $asset_resolver `fn( string $path, string $kind ): string|false`.
772 * @return array[] List of sanitized descriptors.
773 */
774 function openstation_sanitize_desktop_theme_wallpapers( $raw, $asset_resolver ) {
775 if ( is_string( $raw ) ) {
776 $raw = array( array( 'path' => $raw ) );
777 } elseif ( is_array( $raw ) && isset( $raw['path'] ) ) {
778 // A single descriptor, not a collection.
779 $raw = array( $raw );
780 }
781 if ( ! is_array( $raw ) ) {
782 return array();
783 }
784
785 /**
786 * Filters how many wallpapers one desktop theme may contribute.
787 *
788 * @param int $max Default 12.
789 */
790 $max = max( 1, (int) apply_filters( 'openstation_desktop_theme_max_wallpapers', 12 ) );
791 $out = array();
792 $seen = array();
793
794 foreach ( $raw as $key => $entry ) {
795 if ( count( $out ) >= $max ) {
796 break;
797 }
798 if ( is_string( $entry ) ) {
799 $entry = array( 'path' => $entry );
800 }
801 if ( ! is_array( $entry ) ) {
802 continue;
803 }
804
805 $path = isset( $entry['path'] ) ? (string) $entry['path'] : '';
806 $ref = call_user_func( $asset_resolver, $path, 'image' );
807 if ( ! is_string( $ref ) || '' === $ref ) {
808 continue;
809 }
810
811 $label = isset( $entry['label'] ) && is_string( $entry['label'] )
812 ? mb_substr( sanitize_text_field( $entry['label'] ), 0, 80 )
813 : '';
814
815 // Id precedence — see the docblock. `sanitize_title` on the
816 // filename keeps a stable, readable id with no author effort.
817 $id = '';
818 if ( isset( $entry['id'] ) && is_string( $entry['id'] ) ) {
819 $id = sanitize_title( $entry['id'] );
820 }
821 if ( '' === $id && is_string( $key ) ) {
822 $id = sanitize_title( $key );
823 }
824 if ( '' === $id && '' !== $label ) {
825 $id = sanitize_title( $label );
826 }
827 if ( '' === $id ) {
828 $id = sanitize_title( (string) pathinfo( $path, PATHINFO_FILENAME ) );
829 }
830 if ( '' === $id || isset( $seen[ $id ] ) ) {
831 continue;
832 }
833 $seen[ $id ] = true;
834
835 $item = array(
836 'id' => $id,
837 'label' => $label,
838 'path' => $ref,
839 );
840
841 if ( isset( $entry['repeat'] ) && is_string( $entry['repeat'] ) ) {
842 $value = strtolower( trim( $entry['repeat'] ) );
843 if ( in_array( $value, array( 'repeat', 'repeat-x', 'repeat-y', 'no-repeat', 'space', 'round' ), true ) ) {
844 $item['repeat'] = $value;
845 }
846 }
847 if ( isset( $entry['size'] ) && is_string( $entry['size'] ) ) {
848 $value = strtolower( trim( preg_replace( '/\s+/', ' ', $entry['size'] ) ) );
849 if ( openstation_desktop_theme_is_size_value( $value ) ) {
850 $item['size'] = $value;
851 }
852 }
853 if ( isset( $entry['position'] ) && is_string( $entry['position'] ) ) {
854 $value = strtolower( trim( preg_replace( '/\s+/', ' ', $entry['position'] ) ) );
855 if ( openstation_desktop_theme_is_position_value( $value ) ) {
856 $item['position'] = $value;
857 }
858 }
859 if ( isset( $entry['description'] ) && is_string( $entry['description'] ) ) {
860 $item['description'] = mb_substr( sanitize_textarea_field( $entry['description'] ), 0, 500 );
861 }
862
863 $out[] = $item;
864 }
865
866 return $out;
867 }
868
869 /**
870 * Sanitize the `recommendedOsSettings` block: presentation
871 * preferences the theme would LIKE the user to be wearing.
872 *
873 * ```json
874 * "recommendedOsSettings": {
875 * "dockSize": "large",
876 * "desktopLayout": "unified",
877 * "windowRadius": "default",
878 * "adminBarMode": "dynamic",
879 * "dockRailRenderer": "default"
880 * }
881 * ```
882 *
883 * These are recommendations, not settings. The shell writes them into
884 * user meta once — the first time that user activates the theme — and
885 * never again; a user who then moves the dock or squares the corners
886 * keeps their choice for good. See docs/desktop-themes.md §
887 * "Recommended OS settings" for the full contract.
888 *
889 * Every key is checked against
890 * {@see openstation_desktop_theme_recommended_os_settings_schema()},
891 * and the same drop-and-continue posture as the rest of the manifest
892 * applies: an unknown key or an out-of-enum value disappears and the
893 * remaining recommendations survive.
894 *
895 * @internal
896 *
897 * @param mixed $raw Raw `recommendedOsSettings` value.
898 * @return array<string,string|int>
899 */
900 function openstation_sanitize_desktop_theme_recommended_os_settings( $raw ) {
901 if ( ! is_array( $raw ) ) {
902 return array();
903 }
904 $schema = openstation_desktop_theme_recommended_os_settings_schema();
905 $out = array();
906 foreach ( $schema as $key => $rule ) {
907 if ( ! isset( $raw[ $key ] ) ) {
908 continue;
909 }
910 // Numeric grammar — clamped into range rather than dropped, so a
911 // theme asking for something outside what the shell will play
912 // still gets the nearest thing it will.
913 if ( isset( $rule['int'] ) ) {
914 if ( ! is_numeric( $raw[ $key ] ) ) {
915 continue;
916 }
917 $out[ $key ] = max(
918 (int) $rule['int']['min'],
919 min( (int) $rule['int']['max'], (int) round( (float) $raw[ $key ] ) )
920 );
921 continue;
922 }
923 if ( ! is_string( $raw[ $key ] ) ) {
924 continue;
925 }
926 $value = trim( $raw[ $key ] );
927 if ( '' === $value ) {
928 continue;
929 }
930 if ( isset( $rule['enum'] ) ) {
931 if ( in_array( $value, $rule['enum'], true ) ) {
932 $out[ $key ] = $value;
933 }
934 continue;
935 }
936 // Registry id — charset only. The shell resolves it against the
937 // live registry and skips the key when nothing answers to it.
938 $slug = sanitize_key( $value );
939 if ( '' !== $slug ) {
940 $out[ $key ] = $slug;
941 }
942 }
943 return $out;
944 }
945
946 /**
947 * Sanitize a whole `theme.json` manifest.
948 *
949 * @param mixed $raw Decoded manifest.
950 * @param callable $asset_resolver `fn( string $path, string $kind ): string|false`.
951 * Returns the reference the compiler
952 * should emit (theme-relative path
953 * for uploads, absolute URL for code
954 * registrations), or `false` to drop.
955 * `$kind` is `'image'` or `'font'`
956 * and selects the extension
957 * allowlist.
958 * @return array|WP_Error Sanitized manifest, or `WP_Error` when a
959 * structural field is missing/invalid.
960 */
961 function openstation_sanitize_desktop_theme_manifest( $raw, $asset_resolver ) {
962 if ( ! is_array( $raw ) ) {
963 return new WP_Error(
964 'openstation_desktop_theme_invalid_manifest',
965 __( 'The theme manifest is not a JSON object.', 'desktop-mode' ),
966 array( 'status' => 400 )
967 );
968 }
969 if ( ! is_callable( $asset_resolver ) ) {
970 return new WP_Error(
971 'openstation_desktop_theme_invalid_resolver',
972 __( 'No asset resolver was provided for this manifest.', 'desktop-mode' ),
973 array( 'status' => 500 )
974 );
975 }
976
977 // --- Fatal fields. ---
978 //
979 // Two versions are current. `2` says nothing about the shape of
980 // the fields below — it exists so an author can DECLARE that
981 // their manifest carries `recommendedOsSettings`, and so a future
982 // reader can tell a deliberate omission from an old file. A `1`
983 // manifest that ships the block still has it honoured: dropping a
984 // valid, individually-sanitized field over a version number would
985 // contradict the drop-and-continue contract everything else here
986 // follows.
987 $version_field = isset( $raw['manifestVersion'] ) ? $raw['manifestVersion'] : null;
988 $version = is_numeric( $version_field ) ? (int) $version_field : 0;
989 if ( ! in_array( $version, array( 1, 2 ), true ) ) {
990 return new WP_Error(
991 'openstation_desktop_theme_bad_version',
992 __( 'Unsupported theme manifest version. Expected "manifestVersion": 1 or 2.', 'desktop-mode' ),
993 array( 'status' => 400 )
994 );
995 }
996
997 $id = isset( $raw['id'] ) && is_string( $raw['id'] ) ? trim( $raw['id'] ) : '';
998 if ( '' === $id || strlen( $id ) > 64 || ! preg_match( '~^[a-z0-9_-]+(/[a-z0-9_-]+)?$~', $id ) ) {
999 return new WP_Error(
1000 'openstation_desktop_theme_bad_id',
1001 __( 'The theme id must look like "neon-glass" or "vendor/neon-glass" (lowercase, max 64 characters).', 'desktop-mode' ),
1002 array( 'status' => 400 )
1003 );
1004 }
1005 $slug = openstation_desktop_theme_slug_from_id( $id );
1006 if ( '' === $slug ) {
1007 return new WP_Error(
1008 'openstation_desktop_theme_bad_id',
1009 __( 'The theme id does not reduce to a usable slug.', 'desktop-mode' ),
1010 array( 'status' => 400 )
1011 );
1012 }
1013
1014 $name = isset( $raw['name'] ) && is_string( $raw['name'] ) ? sanitize_text_field( $raw['name'] ) : '';
1015 if ( '' === $name ) {
1016 return new WP_Error(
1017 'openstation_desktop_theme_missing_name',
1018 __( 'The theme manifest requires a non-empty "name".', 'desktop-mode' ),
1019 array( 'status' => 400 )
1020 );
1021 }
1022
1023 // --- Everything below drops-and-continues. ---
1024 $preview = '';
1025 $preview_raw = isset( $raw['preview'] ) && is_string( $raw['preview'] ) ? $raw['preview'] : '';
1026 if ( '' !== $preview_raw ) {
1027 $resolved = call_user_func( $asset_resolver, $preview_raw );
1028 if ( is_string( $resolved ) && '' !== $resolved ) {
1029 $preview = $resolved;
1030 }
1031 }
1032
1033 // Manifest-wide icon tint. Applied to every icon that doesn't set
1034 // its own `color`, so a monochrome iconset is one line rather than
1035 // twenty-odd repetitions.
1036 $icon_color = '';
1037 if ( isset( $raw['iconColor'] ) && openstation_desktop_theme_is_color_value( $raw['iconColor'] ) ) {
1038 $icon_color = openstation_desktop_theme_normalize_color( $raw['iconColor'] );
1039 }
1040
1041 $manifest = array(
1042 'manifestVersion' => $version,
1043 'id' => $id,
1044 'slug' => $slug,
1045 'name' => mb_substr( $name, 0, 120 ),
1046 'version' => isset( $raw['version'] ) && is_string( $raw['version'] )
1047 ? mb_substr( sanitize_text_field( $raw['version'] ), 0, 32 )
1048 : '',
1049 'author' => isset( $raw['author'] ) && is_string( $raw['author'] )
1050 ? mb_substr( sanitize_text_field( $raw['author'] ), 0, 120 )
1051 : '',
1052 'description' => isset( $raw['description'] ) && is_string( $raw['description'] )
1053 ? mb_substr( sanitize_textarea_field( $raw['description'] ), 0, 500 )
1054 : '',
1055 'preview' => $preview,
1056 'tokens' => openstation_sanitize_desktop_theme_tokens(
1057 isset( $raw['tokens'] ) ? $raw['tokens'] : null
1058 ),
1059 'iconColor' => $icon_color,
1060 'icons' => openstation_sanitize_desktop_theme_icons(
1061 isset( $raw['icons'] ) ? $raw['icons'] : null,
1062 $asset_resolver,
1063 $icon_color
1064 ),
1065 'textures' => openstation_sanitize_desktop_theme_textures(
1066 isset( $raw['textures'] ) ? $raw['textures'] : null,
1067 $asset_resolver
1068 ),
1069 'fonts' => openstation_sanitize_desktop_theme_fonts(
1070 isset( $raw['fonts'] ) ? $raw['fonts'] : null,
1071 $asset_resolver
1072 ),
1073 // `wallpaper` and `wallpapers` are both accepted — authors
1074 // guess either — and merge into one list.
1075 'wallpapers' => openstation_sanitize_desktop_theme_wallpapers(
1076 isset( $raw['wallpapers'] ) ? $raw['wallpapers'] : (
1077 isset( $raw['wallpaper'] ) ? $raw['wallpaper'] : null
1078 ),
1079 $asset_resolver
1080 ),
1081 // Presentation preferences the theme would like the user to
1082 // wear. Applied once, on first activation — never on load.
1083 'recommendedOsSettings' => openstation_sanitize_desktop_theme_recommended_os_settings(
1084 isset( $raw['recommendedOsSettings'] ) ? $raw['recommendedOsSettings'] : null
1085 ),
1086 );
1087
1088 /**
1089 * Filters a sanitized desktop-theme manifest just before it is
1090 * compiled and stored.
1091 *
1092 * Runs AFTER every value has been validated. Anything added here
1093 * bypasses the sanitizer, so treat it as trusted-code territory —
1094 * values land in the compiled stylesheet verbatim.
1095 *
1096 * @param array $manifest Sanitized manifest.
1097 * @param array $raw The manifest as the author wrote it.
1098 * @param string $slug Storage slug derived from `id`.
1099 */
1100 $manifest = (array) apply_filters( 'openstation_desktop_theme_manifest', $manifest, $raw, $slug );
1101
1102 return $manifest;
1103 }
1104
1105 /**
1106 * Build an asset resolver that validates paths inside a staging
1107 * directory and returns the theme-relative path.
1108 *
1109 * Rejects absolute paths, traversal, backslashes, NUL bytes, and
1110 * anything whose extension isn't allowed for the requested asset
1111 * kind. Uses `realpath()` containment as the final gate so a symlink
1112 * planted inside the ZIP can't point outward.
1113 *
1114 * @param string $staging_dir Absolute path of the extracted ZIP.
1115 * @return callable `fn( string $path, string $kind = 'image' ): string|false`
1116 */
1117 function openstation_desktop_theme_staging_asset_resolver( $staging_dir ) {
1118 $base = realpath( $staging_dir );
1119 return static function ( $path, $kind = 'image' ) use ( $base ) {
1120 if ( false === $base || ! is_string( $path ) ) {
1121 return false;
1122 }
1123 $path = trim( $path );
1124 if ( '' === $path || strlen( $path ) > 255 ) {
1125 return false;
1126 }
1127 if ( false !== strpos( $path, "\0" ) || false !== strpos( $path, '\\' ) ) {
1128 return false;
1129 }
1130 if ( '/' === $path[0] || preg_match( '~^[a-zA-Z]:~', $path ) ) {
1131 return false;
1132 }
1133 foreach ( explode( '/', $path ) as $segment ) {
1134 if ( '' === $segment || '.' === $segment || '..' === $segment ) {
1135 return false;
1136 }
1137 }
1138 $ext = strtolower( (string) pathinfo( $path, PATHINFO_EXTENSION ) );
1139 if ( ! in_array( $ext, openstation_desktop_theme_asset_extensions( $kind ), true ) ) {
1140 return false;
1141 }
1142 $full = realpath( $base . '/' . $path );
1143 if ( false === $full || ! is_file( $full ) ) {
1144 return false;
1145 }
1146 if ( 0 !== strpos( $full, $base . DIRECTORY_SEPARATOR ) ) {
1147 return false;
1148 }
1149 return $path;
1150 };
1151 }
1152
1153 /**
1154 * Build an asset resolver for code-registered themes, whose assets
1155 * are already-published http(s) URLs rather than files in a ZIP.
1156 *
1157 * @return callable `fn( string $url, string $kind = 'image' ): string|false`
1158 */
1159 function openstation_desktop_theme_url_asset_resolver() {
1160 return static function ( $url, $kind = 'image' ) {
1161 if ( ! is_string( $url ) ) {
1162 return false;
1163 }
1164 $url = trim( $url );
1165 // Require the scheme on the RAW input, before `esc_url_raw()`
1166 // gets a chance to invent one: given `icons/relative.svg` it
1167 // helpfully returns `http://icons/relative.svg`, which would
1168 // sail through a post-hoc scheme check and compile into a
1169 // `url()` pointing at a host called "icons". A code theme's
1170 // assets have to be fully qualified — the compiler emits them
1171 // verbatim, with no base to join against.
1172 if ( ! preg_match( '~^https?://~i', $url ) ) {
1173 return false;
1174 }
1175 $url = esc_url_raw( $url, array( 'http', 'https' ) );
1176 if ( '' === $url ) {
1177 return false;
1178 }
1179 $path = (string) wp_parse_url( $url, PHP_URL_PATH );
1180 $ext = strtolower( (string) pathinfo( $path, PATHINFO_EXTENSION ) );
1181 if ( ! in_array( $ext, openstation_desktop_theme_asset_extensions( $kind ), true ) ) {
1182 return false;
1183 }
1184 return $url;
1185 };
1186 }
1187