PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 1.1.12
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v1.1.12
1.1.12 1.1.11 1.1.10 1.1.9 1.1.8 1.1.7 1.1.6 1.1.5 1.1.4 1.1.3 1.1.2 1.1.1 1.1.0 1.0.1 1.0.0 0.9.8 0.9.7 0.9.6 0.9.4 0.9.5 0.9.3 0.9.2 0.9.1 0.9.0 0.8.9 All 36 releases
desktop-mode / includes / desktop-themes / manifest.php

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

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