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

1,188 lines 39.1 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 * "dockPlacement": "left",
878 * "windowRadius": "default",
879 * "adminBarMode": "dynamic",
880 * "dockRailRenderer": "default"
881 * }
882 * ```
883 *
884 * These are recommendations, not settings. The shell writes them into
885 * user meta once — the first time that user activates the theme — and
886 * never again; a user who then moves the dock or squares the corners
887 * keeps their choice for good. See docs/desktop-themes.md §
888 * "Recommended OS settings" for the full contract.
889 *
890 * Every key is checked against
891 * {@see openstation_desktop_theme_recommended_os_settings_schema()},
892 * and the same drop-and-continue posture as the rest of the manifest
893 * applies: an unknown key or an out-of-enum value disappears and the
894 * remaining recommendations survive.
895 *
896 * @internal
897 *
898 * @param mixed $raw Raw `recommendedOsSettings` value.
899 * @return array<string,string|int>
900 */
901 function openstation_sanitize_desktop_theme_recommended_os_settings( $raw ) {
902 if ( ! is_array( $raw ) ) {
903 return array();
904 }
905 $schema = openstation_desktop_theme_recommended_os_settings_schema();
906 $out = array();
907 foreach ( $schema as $key => $rule ) {
908 if ( ! isset( $raw[ $key ] ) ) {
909 continue;
910 }
911 // Numeric grammar — clamped into range rather than dropped, so a
912 // theme asking for something outside what the shell will play
913 // still gets the nearest thing it will.
914 if ( isset( $rule['int'] ) ) {
915 if ( ! is_numeric( $raw[ $key ] ) ) {
916 continue;
917 }
918 $out[ $key ] = max(
919 (int) $rule['int']['min'],
920 min( (int) $rule['int']['max'], (int) round( (float) $raw[ $key ] ) )
921 );
922 continue;
923 }
924 if ( ! is_string( $raw[ $key ] ) ) {
925 continue;
926 }
927 $value = trim( $raw[ $key ] );
928 if ( '' === $value ) {
929 continue;
930 }
931 if ( isset( $rule['enum'] ) ) {
932 if ( in_array( $value, $rule['enum'], true ) ) {
933 $out[ $key ] = $value;
934 }
935 continue;
936 }
937 // Registry id — charset only. The shell resolves it against the
938 // live registry and skips the key when nothing answers to it.
939 $slug = sanitize_key( $value );
940 if ( '' !== $slug ) {
941 $out[ $key ] = $slug;
942 }
943 }
944 return $out;
945 }
946
947 /**
948 * Sanitize a whole `theme.json` manifest.
949 *
950 * @param mixed $raw Decoded manifest.
951 * @param callable $asset_resolver `fn( string $path, string $kind ): string|false`.
952 * Returns the reference the compiler
953 * should emit (theme-relative path
954 * for uploads, absolute URL for code
955 * registrations), or `false` to drop.
956 * `$kind` is `'image'` or `'font'`
957 * and selects the extension
958 * allowlist.
959 * @return array|WP_Error Sanitized manifest, or `WP_Error` when a
960 * structural field is missing/invalid.
961 */
962 function openstation_sanitize_desktop_theme_manifest( $raw, $asset_resolver ) {
963 if ( ! is_array( $raw ) ) {
964 return new WP_Error(
965 'openstation_desktop_theme_invalid_manifest',
966 __( 'The theme manifest is not a JSON object.', 'desktop-mode' ),
967 array( 'status' => 400 )
968 );
969 }
970 if ( ! is_callable( $asset_resolver ) ) {
971 return new WP_Error(
972 'openstation_desktop_theme_invalid_resolver',
973 __( 'No asset resolver was provided for this manifest.', 'desktop-mode' ),
974 array( 'status' => 500 )
975 );
976 }
977
978 // --- Fatal fields. ---
979 //
980 // Two versions are current. `2` says nothing about the shape of
981 // the fields below — it exists so an author can DECLARE that
982 // their manifest carries `recommendedOsSettings`, and so a future
983 // reader can tell a deliberate omission from an old file. A `1`
984 // manifest that ships the block still has it honoured: dropping a
985 // valid, individually-sanitized field over a version number would
986 // contradict the drop-and-continue contract everything else here
987 // follows.
988 $version_field = isset( $raw['manifestVersion'] ) ? $raw['manifestVersion'] : null;
989 $version = is_numeric( $version_field ) ? (int) $version_field : 0;
990 if ( ! in_array( $version, array( 1, 2 ), true ) ) {
991 return new WP_Error(
992 'openstation_desktop_theme_bad_version',
993 __( 'Unsupported theme manifest version. Expected "manifestVersion": 1 or 2.', 'desktop-mode' ),
994 array( 'status' => 400 )
995 );
996 }
997
998 $id = isset( $raw['id'] ) && is_string( $raw['id'] ) ? trim( $raw['id'] ) : '';
999 if ( '' === $id || strlen( $id ) > 64 || ! preg_match( '~^[a-z0-9_-]+(/[a-z0-9_-]+)?$~', $id ) ) {
1000 return new WP_Error(
1001 'openstation_desktop_theme_bad_id',
1002 __( 'The theme id must look like "neon-glass" or "vendor/neon-glass" (lowercase, max 64 characters).', 'desktop-mode' ),
1003 array( 'status' => 400 )
1004 );
1005 }
1006 $slug = openstation_desktop_theme_slug_from_id( $id );
1007 if ( '' === $slug ) {
1008 return new WP_Error(
1009 'openstation_desktop_theme_bad_id',
1010 __( 'The theme id does not reduce to a usable slug.', 'desktop-mode' ),
1011 array( 'status' => 400 )
1012 );
1013 }
1014
1015 $name = isset( $raw['name'] ) && is_string( $raw['name'] ) ? sanitize_text_field( $raw['name'] ) : '';
1016 if ( '' === $name ) {
1017 return new WP_Error(
1018 'openstation_desktop_theme_missing_name',
1019 __( 'The theme manifest requires a non-empty "name".', 'desktop-mode' ),
1020 array( 'status' => 400 )
1021 );
1022 }
1023
1024 // --- Everything below drops-and-continues. ---
1025 $preview = '';
1026 $preview_raw = isset( $raw['preview'] ) && is_string( $raw['preview'] ) ? $raw['preview'] : '';
1027 if ( '' !== $preview_raw ) {
1028 $resolved = call_user_func( $asset_resolver, $preview_raw );
1029 if ( is_string( $resolved ) && '' !== $resolved ) {
1030 $preview = $resolved;
1031 }
1032 }
1033
1034 // Manifest-wide icon tint. Applied to every icon that doesn't set
1035 // its own `color`, so a monochrome iconset is one line rather than
1036 // twenty-odd repetitions.
1037 $icon_color = '';
1038 if ( isset( $raw['iconColor'] ) && openstation_desktop_theme_is_color_value( $raw['iconColor'] ) ) {
1039 $icon_color = openstation_desktop_theme_normalize_color( $raw['iconColor'] );
1040 }
1041
1042 $manifest = array(
1043 'manifestVersion' => $version,
1044 'id' => $id,
1045 'slug' => $slug,
1046 'name' => mb_substr( $name, 0, 120 ),
1047 'version' => isset( $raw['version'] ) && is_string( $raw['version'] )
1048 ? mb_substr( sanitize_text_field( $raw['version'] ), 0, 32 )
1049 : '',
1050 'author' => isset( $raw['author'] ) && is_string( $raw['author'] )
1051 ? mb_substr( sanitize_text_field( $raw['author'] ), 0, 120 )
1052 : '',
1053 'description' => isset( $raw['description'] ) && is_string( $raw['description'] )
1054 ? mb_substr( sanitize_textarea_field( $raw['description'] ), 0, 500 )
1055 : '',
1056 'preview' => $preview,
1057 'tokens' => openstation_sanitize_desktop_theme_tokens(
1058 isset( $raw['tokens'] ) ? $raw['tokens'] : null
1059 ),
1060 'iconColor' => $icon_color,
1061 'icons' => openstation_sanitize_desktop_theme_icons(
1062 isset( $raw['icons'] ) ? $raw['icons'] : null,
1063 $asset_resolver,
1064 $icon_color
1065 ),
1066 'textures' => openstation_sanitize_desktop_theme_textures(
1067 isset( $raw['textures'] ) ? $raw['textures'] : null,
1068 $asset_resolver
1069 ),
1070 'fonts' => openstation_sanitize_desktop_theme_fonts(
1071 isset( $raw['fonts'] ) ? $raw['fonts'] : null,
1072 $asset_resolver
1073 ),
1074 // `wallpaper` and `wallpapers` are both accepted — authors
1075 // guess either — and merge into one list.
1076 'wallpapers' => openstation_sanitize_desktop_theme_wallpapers(
1077 isset( $raw['wallpapers'] ) ? $raw['wallpapers'] : (
1078 isset( $raw['wallpaper'] ) ? $raw['wallpaper'] : null
1079 ),
1080 $asset_resolver
1081 ),
1082 // Presentation preferences the theme would like the user to
1083 // wear. Applied once, on first activation — never on load.
1084 'recommendedOsSettings' => openstation_sanitize_desktop_theme_recommended_os_settings(
1085 isset( $raw['recommendedOsSettings'] ) ? $raw['recommendedOsSettings'] : null
1086 ),
1087 );
1088
1089 /**
1090 * Filters a sanitized desktop-theme manifest just before it is
1091 * compiled and stored.
1092 *
1093 * Runs AFTER every value has been validated. Anything added here
1094 * bypasses the sanitizer, so treat it as trusted-code territory —
1095 * values land in the compiled stylesheet verbatim.
1096 *
1097 * @param array $manifest Sanitized manifest.
1098 * @param array $raw The manifest as the author wrote it.
1099 * @param string $slug Storage slug derived from `id`.
1100 */
1101 $manifest = (array) apply_filters( 'openstation_desktop_theme_manifest', $manifest, $raw, $slug );
1102
1103 return $manifest;
1104 }
1105
1106 /**
1107 * Build an asset resolver that validates paths inside a staging
1108 * directory and returns the theme-relative path.
1109 *
1110 * Rejects absolute paths, traversal, backslashes, NUL bytes, and
1111 * anything whose extension isn't allowed for the requested asset
1112 * kind. Uses `realpath()` containment as the final gate so a symlink
1113 * planted inside the ZIP can't point outward.
1114 *
1115 * @param string $staging_dir Absolute path of the extracted ZIP.
1116 * @return callable `fn( string $path, string $kind = 'image' ): string|false`
1117 */
1118 function openstation_desktop_theme_staging_asset_resolver( $staging_dir ) {
1119 $base = realpath( $staging_dir );
1120 return static function ( $path, $kind = 'image' ) use ( $base ) {
1121 if ( false === $base || ! is_string( $path ) ) {
1122 return false;
1123 }
1124 $path = trim( $path );
1125 if ( '' === $path || strlen( $path ) > 255 ) {
1126 return false;
1127 }
1128 if ( false !== strpos( $path, "\0" ) || false !== strpos( $path, '\\' ) ) {
1129 return false;
1130 }
1131 if ( '/' === $path[0] || preg_match( '~^[a-zA-Z]:~', $path ) ) {
1132 return false;
1133 }
1134 foreach ( explode( '/', $path ) as $segment ) {
1135 if ( '' === $segment || '.' === $segment || '..' === $segment ) {
1136 return false;
1137 }
1138 }
1139 $ext = strtolower( (string) pathinfo( $path, PATHINFO_EXTENSION ) );
1140 if ( ! in_array( $ext, openstation_desktop_theme_asset_extensions( $kind ), true ) ) {
1141 return false;
1142 }
1143 $full = realpath( $base . '/' . $path );
1144 if ( false === $full || ! is_file( $full ) ) {
1145 return false;
1146 }
1147 if ( 0 !== strpos( $full, $base . DIRECTORY_SEPARATOR ) ) {
1148 return false;
1149 }
1150 return $path;
1151 };
1152 }
1153
1154 /**
1155 * Build an asset resolver for code-registered themes, whose assets
1156 * are already-published http(s) URLs rather than files in a ZIP.
1157 *
1158 * @return callable `fn( string $url, string $kind = 'image' ): string|false`
1159 */
1160 function openstation_desktop_theme_url_asset_resolver() {
1161 return static function ( $url, $kind = 'image' ) {
1162 if ( ! is_string( $url ) ) {
1163 return false;
1164 }
1165 $url = trim( $url );
1166 // Require the scheme on the RAW input, before `esc_url_raw()`
1167 // gets a chance to invent one: given `icons/relative.svg` it
1168 // helpfully returns `http://icons/relative.svg`, which would
1169 // sail through a post-hoc scheme check and compile into a
1170 // `url()` pointing at a host called "icons". A code theme's
1171 // assets have to be fully qualified — the compiler emits them
1172 // verbatim, with no base to join against.
1173 if ( ! preg_match( '~^https?://~i', $url ) ) {
1174 return false;
1175 }
1176 $url = esc_url_raw( $url, array( 'http', 'https' ) );
1177 if ( '' === $url ) {
1178 return false;
1179 }
1180 $path = (string) wp_parse_url( $url, PHP_URL_PATH );
1181 $ext = strtolower( (string) pathinfo( $path, PATHINFO_EXTENSION ) );
1182 if ( ! in_array( $ext, openstation_desktop_theme_asset_extensions( $kind ), true ) ) {
1183 return false;
1184 }
1185 return $url;
1186 };
1187 }
1188